atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
c4d70621d377d45e3d418ac9197443c1b5d36399
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/oracle.rsRust241 lines8.5 KB
1
//! Where the game's decisions come from. The seeded rng is the default; an
2
//! operator can instead point the server at a System One service and let the
3
//! model answer every roll over HTTP (JELQD_ORACLE=clm).
4
//!
5
//! Identity randomness (run ids, etag salts, nonces) never goes to the model.
6
//! The Clm arm carries its own rng for those, seeded like the rng arm's.
7
8
use rand::{Rng, SeedableRng, rngs::StdRng};
9
use serde_json::{Value, json};
10
11
/// One named decision. The state prose is what the model reads as context, the
12
/// ask is the question, and the site is a stable name for logs and caching.
13
pub(crate) struct Roll {
14
    pub(crate) state: String,
15
    pub(crate) site: &'static str,
16
    pub(crate) ask: String,
17
}
18
19
pub(crate) enum Oracle {
20
    Rng(StdRng),
21
    Clm { clm: Clm, technical: StdRng },
22
}
23
24
pub(crate) enum OracleTemplate {
25
    Rng,
26
    Clm { url: String },
27
}
28
29
impl OracleTemplate {
30
    pub(crate) fn blocks(&self) -> bool {
31
        matches!(self, Self::Clm { .. })
32
    }
33
}
34
35
pub(crate) struct Clm {
36
    url: String,
37
    agent: ureq::Agent,
38
}
39
40
impl Oracle {
41
    pub(crate) fn build(template: &OracleTemplate, seed: u64) -> Self {
42
        match template {
43
            OracleTemplate::Rng => Self::Rng(StdRng::seed_from_u64(seed)),
44
            OracleTemplate::Clm { url } => Self::Clm {
45
                clm: Clm::new(url),
46
                technical: StdRng::seed_from_u64(seed),
47
            },
48
        }
49
    }
50
51
    fn technical(&mut self) -> &mut StdRng {
52
        match self {
53
            Self::Rng(rng) => rng,
54
            Self::Clm { technical, .. } => technical,
55
        }
56
    }
57
58
    /// Identity randomness, never a game decision.
59
    pub(crate) fn u64(&mut self) -> u64 {
60
        self.technical().random()
61
    }
62
63
    /// A yes/no game roll with stated odds of 1 in `odds`.
64
    pub(crate) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
65
        match self {
66
            Self::Rng(rng) => rng.random_ratio(1, odds),
67
            Self::Clm { clm, .. } => {
68
                let ask = format!("{}, with odds of 1 in {odds}.", roll.ask);
69
                clm.decide(&roll.state, roll.site, &ask) > 0.5
70
            }
71
        }
72
    }
73
74
    /// A yes/no game roll where the ask describes the calm outcome, not the
75
    /// event. The model believes dull statements more readily than rare
76
    /// warnings, so the interrupts that read as drama get phrased as calm.
77
    /// The rng arm still draws the event, never the calm.
78
    pub(crate) fn chance_not(&mut self, roll: Roll, odds: u32) -> bool {
79
        match self {
80
            Self::Rng(rng) => rng.random_ratio(1, odds),
81
            Self::Clm { clm, .. } => {
82
                let ask = format!("{}, with odds of 1 in {odds}.", roll.ask);
83
                clm.decide(&roll.state, roll.site, &ask) <= 0.5
84
            }
85
        }
86
    }
87
88
    /// A yes/no game roll with general odds of `num` in `den`, put to the
89
    /// model as a choice between the calm answer and the event. The noul head
90
    /// answers interrupt questions with yes; the choice head, offered the two
91
    /// answers side by side, does not.
92
    pub(crate) fn ratio_choice(
93
        &mut self,
94
        roll: Roll,
95
        num: u32,
96
        den: u32,
97
        calm: &str,
98
        event: &str,
99
    ) -> bool {
100
        match self {
101
            Self::Rng(rng) => rng.random_ratio(num, den),
102
            Self::Clm { clm, .. } => {
103
                let i = clm.choose(&roll.state, roll.site, &roll.ask, &[calm, event]);
104
                i == 1
105
            }
106
        }
107
    }
108
109
    /// A yes/no game roll with general odds of `num` in `den`.
110
    pub(crate) fn ratio(&mut self, roll: Roll, num: u32, den: u32) -> bool {
111
        match self {
112
            Self::Rng(rng) => rng.random_ratio(num, den),
113
            Self::Clm { clm, .. } => {
114
                let ask = format!("{}, with odds of about {num} in {den}.", roll.ask);
115
                clm.decide(&roll.state, roll.site, &ask) > 0.5
116
            }
117
        }
118
    }
119
120
    /// One bucket chosen by weighted draw. The rng arm draws by `weights` and
121
    /// ignores the descriptions; the clm arm reads them all and picks the one
122
    /// the model believes, weights unused.
123
    pub(crate) fn pick(&mut self, roll: Roll, weights: &[u32], options: &[&str]) -> usize {
124
        match self {
125
            Self::Rng(rng) => {
126
                let total: u32 = weights.iter().sum();
127
                let mut draw = rng.random_range(0..total);
128
                for (i, w) in weights.iter().enumerate() {
129
                    if draw < *w {
130
                        return i;
131
                    }
132
                    draw -= *w;
133
                }
134
                weights.len() - 1
135
            }
136
            Self::Clm { clm, .. } => clm.choose(&roll.state, roll.site, &roll.ask, options),
137
        }
138
    }
139
140
    /// A value in `lo..hi`. The rng arm draws uniformly; the clm arm picks from
141
    /// a 0.1-step grid so the model is choosing among described options.
142
    pub(crate) fn span(&mut self, roll: Roll, lo: f64, hi: f64) -> f64 {
143
        match self {
144
            Self::Rng(rng) => rng.random_range(lo..hi),
145
            Self::Clm { clm, .. } => {
146
                let mut last = format!("{lo:.1}");
147
                let mut values = Vec::new();
148
                let mut v = lo;
149
                while v < hi {
150
                    values.push(last);
151
                    v += 0.1;
152
                    last = format!("{v:.1}");
153
                }
154
                let options: Vec<&str> = values.iter().map(String::as_str).collect();
155
                let ask = format!("{}. Answer with a value.", roll.ask);
156
                let i = clm.choose(&roll.state, roll.site, &ask, &options);
157
                lo + 0.1 * i as f64
158
            }
159
        }
160
    }
161
162
    /// An integer in `lo..=hi`. The rng arm draws uniformly; the clm arm picks
163
    /// from the integers as described options.
164
    pub(crate) fn span_int(&mut self, roll: Roll, lo: i32, hi: i32) -> i32 {
165
        match self {
166
            Self::Rng(rng) => rng.random_range(lo..=hi),
167
            Self::Clm { clm, .. } => {
168
                let values: Vec<String> = (lo..=hi).map(|v| v.to_string()).collect();
169
                let options: Vec<&str> = values.iter().map(String::as_str).collect();
170
                let ask = format!("{}. Answer with a whole number.", roll.ask);
171
                let i = clm.choose(&roll.state, roll.site, &ask, &options);
172
                lo + i as i32
173
            }
174
        }
175
    }
176
}
177
178
impl Clm {
179
    fn new(url: &str) -> Self {
180
        Self {
181
            url: url.trim_end_matches('/').to_owned(),
182
            agent: ureq::AgentBuilder::new()
183
                .timeout(std::time::Duration::from_secs(60))
184
                .build(),
185
        }
186
    }
187
188
    /// POST /v1/systemone with a single question; panics on any failure, which
189
    /// surfaces as a 500 through the catch_panic layer. A dead oracle must not
190
    /// quietly fall back to the rng.
191
    fn ask(&self, state: &str, site: &str, question: Value) -> Value {
192
        let body = json!({
193
            "state": state,
194
            "model": "clm-latest",
195
            "questions": { site: question },
196
            "temperature": 1.0,
197
        });
198
        let response = self
199
            .agent
200
            .post(&self.url)
201
            .send_json(&body)
202
            .unwrap_or_else(|e| panic!("oracle unreachable at {}: {e}", self.url));
203
        let answer: Value = response
204
            .into_json()
205
            .unwrap_or_else(|e| panic!("oracle answer unreadable: {e}"));
206
        answer["answers"][site].clone()
207
    }
208
209
    fn decide(&self, state: &str, site: &str, ask: &str) -> f64 {
210
        self.ask(state, site, json!({ "type": "noul", "instructions": ask }))["noul"]
211
            .as_f64()
212
            .unwrap_or_else(|| panic!("oracle noul answer for {site} missing"))
213
    }
214
215
    fn choose(&self, state: &str, site: &str, ask: &str, options: &[&str]) -> usize {
216
        let criteria: serde_json::Map<String, Value> = options
217
            .iter()
218
            .enumerate()
219
            .map(|(i, text)| (i.to_string(), json!(text)))
220
            .collect();
221
        let q = if ask.trim_end().ends_with('?') {
222
            ""
223
        } else {
224
            "?"
225
        };
226
        let instructions = format!("{ask}{q}");
227
        let answer = self.ask(
228
            state,
229
            site,
230
            json!({ "type": "choice", "instructions": instructions, "criteria": criteria }),
231
        );
232
        let choice = answer["choice"]
233
            .as_str()
234
            .unwrap_or_else(|| panic!("oracle choice answer for {site} missing"));
235
        choice
236
            .parse::<usize>()
237
            .ok()
238
            .filter(|&i| i < options.len())
239
            .unwrap_or_else(|| panic!("oracle chose {choice:?} for {site}, not an option index"))
240
    }
241
}