atagen

jelq

public

an http based roguelike rpg about jelqing

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