atagen

jelq

public

an http based roguelike rpg about jelqing

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