atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
058650ec79d6eb6841ce768ab49335ed74ae8b97
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/game/oracle.rs

3f9091557ec3atagen2026-10-11 11:43 UTC1
//! Where the game's decisions come from. The seeded rng is the default; an
3f9091557ec3atagen2026-10-11 11:43 UTC2
//! operator can instead point the server at a System One service and let the
3f9091557ec3atagen2026-10-11 11:43 UTC3
//! model answer every roll over HTTP (JELQD_ORACLE=clm).
3f9091557ec3atagen2026-10-11 11:43 UTC4
//!
3f9091557ec3atagen2026-10-11 11:43 UTC5
//! Identity randomness (run ids, etag salts, nonces) never goes to the model.
3f9091557ec3atagen2026-10-11 11:43 UTC6
//! The Clm arm carries its own rng for those, seeded like the rng arm's.
3f9091557ec3atagen2026-10-11 11:43 UTC7
3f9091557ec3atagen2026-10-11 11:43 UTC8
use rand::{Rng, SeedableRng, rngs::StdRng};
3f9091557ec3atagen2026-10-11 11:43 UTC9
use serde_json::{Value, json};
3f9091557ec3atagen2026-10-11 11:43 UTC10
3f9091557ec3atagen2026-10-11 11:43 UTC11
/// One named decision. The state prose is what the model reads as context, the
3f9091557ec3atagen2026-10-11 11:43 UTC12
/// ask is the question, and the site is a stable name for logs and caching.
3f9091557ec3atagen2026-10-11 11:43 UTC13
pub(crate) struct Roll {
3f9091557ec3atagen2026-10-11 11:43 UTC14
    pub(crate) state: String,
3f9091557ec3atagen2026-10-11 11:43 UTC15
    pub(crate) site: &'static str,
3f9091557ec3atagen2026-10-11 11:43 UTC16
    pub(crate) ask: String,
3f9091557ec3atagen2026-10-11 11:43 UTC17
}
3f9091557ec3atagen2026-10-11 11:43 UTC18
3f9091557ec3atagen2026-10-11 11:43 UTC19
pub(crate) enum Oracle {
3f9091557ec3atagen2026-10-11 11:43 UTC20
    Rng(StdRng),
3f9091557ec3atagen2026-10-11 11:43 UTC21
    Clm { clm: Clm, technical: StdRng },
3f9091557ec3atagen2026-10-11 11:43 UTC22
}
3f9091557ec3atagen2026-10-11 11:43 UTC23
3f9091557ec3atagen2026-10-11 11:43 UTC24
pub(crate) enum OracleTemplate {
3f9091557ec3atagen2026-10-11 11:43 UTC25
    Rng,
3f9091557ec3atagen2026-10-11 11:43 UTC26
    Clm { url: String },
3f9091557ec3atagen2026-10-11 11:43 UTC27
}
3f9091557ec3atagen2026-10-11 11:43 UTC28
3f9091557ec3atagen2026-10-11 11:43 UTC29
pub(crate) struct Clm {
3f9091557ec3atagen2026-10-11 11:43 UTC30
    url: String,
3f9091557ec3atagen2026-10-11 11:43 UTC31
    agent: ureq::Agent,
3f9091557ec3atagen2026-10-11 11:43 UTC32
}
3f9091557ec3atagen2026-10-11 11:43 UTC33
3f9091557ec3atagen2026-10-11 11:43 UTC34
impl Oracle {
3f9091557ec3atagen2026-10-11 11:43 UTC35
    pub(crate) fn build(template: &OracleTemplate, seed: u64) -> Self {
3f9091557ec3atagen2026-10-11 11:43 UTC36
        match template {
3f9091557ec3atagen2026-10-11 11:43 UTC37
            OracleTemplate::Rng => Self::Rng(StdRng::seed_from_u64(seed)),
3f9091557ec3atagen2026-10-11 11:43 UTC38
            OracleTemplate::Clm { url } => Self::Clm {
3f9091557ec3atagen2026-10-11 11:43 UTC39
                clm: Clm::new(url),
3f9091557ec3atagen2026-10-11 11:43 UTC40
                technical: StdRng::seed_from_u64(seed),
3f9091557ec3atagen2026-10-11 11:43 UTC41
            },
3f9091557ec3atagen2026-10-11 11:43 UTC42
        }
3f9091557ec3atagen2026-10-11 11:43 UTC43
    }
3f9091557ec3atagen2026-10-11 11:43 UTC44
3f9091557ec3atagen2026-10-11 11:43 UTC45
    fn technical(&mut self) -> &mut StdRng {
3f9091557ec3atagen2026-10-11 11:43 UTC46
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC47
            Self::Rng(rng) => rng,
3f9091557ec3atagen2026-10-11 11:43 UTC48
            Self::Clm { technical, .. } => technical,
3f9091557ec3atagen2026-10-11 11:43 UTC49
        }
3f9091557ec3atagen2026-10-11 11:43 UTC50
    }
3f9091557ec3atagen2026-10-11 11:43 UTC51
3f9091557ec3atagen2026-10-11 11:43 UTC52
    /// Identity randomness, never a game decision.
3f9091557ec3atagen2026-10-11 11:43 UTC53
    pub(crate) fn u64(&mut self) -> u64 {
3f9091557ec3atagen2026-10-11 11:43 UTC54
        self.technical().random()
3f9091557ec3atagen2026-10-11 11:43 UTC55
    }
3f9091557ec3atagen2026-10-11 11:43 UTC56
3f9091557ec3atagen2026-10-11 11:43 UTC57
    /// A yes/no game roll with stated odds of 1 in `odds`.
3f9091557ec3atagen2026-10-11 11:43 UTC58
    pub(crate) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
3f9091557ec3atagen2026-10-11 11:43 UTC59
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC60
            Self::Rng(rng) => rng.random_ratio(1, odds),
3f9091557ec3atagen2026-10-11 11:43 UTC61
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC62
                let ask = format!("{}, with odds of 1 in {odds}.", roll.ask);
3f9091557ec3atagen2026-10-11 11:43 UTC63
                clm.decide(&roll.state, roll.site, &ask) > 0.5
3f9091557ec3atagen2026-10-11 11:43 UTC64
            }
3f9091557ec3atagen2026-10-11 11:43 UTC65
        }
3f9091557ec3atagen2026-10-11 11:43 UTC66
    }
3f9091557ec3atagen2026-10-11 11:43 UTC67
3f9091557ec3atagen2026-10-11 11:43 UTC68
    /// A yes/no game roll where the ask describes the calm outcome, not the
3f9091557ec3atagen2026-10-11 11:43 UTC69
    /// event. The model believes dull statements more readily than rare
3f9091557ec3atagen2026-10-11 11:43 UTC70
    /// warnings, so the interrupts that read as drama get phrased as calm.
3f9091557ec3atagen2026-10-11 11:43 UTC71
    /// The rng arm still draws the event, never the calm.
3f9091557ec3atagen2026-10-11 11:43 UTC72
    pub(crate) fn chance_not(&mut self, roll: Roll, odds: u32) -> bool {
3f9091557ec3atagen2026-10-11 11:43 UTC73
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC74
            Self::Rng(rng) => rng.random_ratio(1, odds),
3f9091557ec3atagen2026-10-11 11:43 UTC75
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC76
                let ask = format!("{}, with odds of 1 in {odds}.", roll.ask);
3f9091557ec3atagen2026-10-11 11:43 UTC77
                clm.decide(&roll.state, roll.site, &ask) <= 0.5
3f9091557ec3atagen2026-10-11 11:43 UTC78
            }
3f9091557ec3atagen2026-10-11 11:43 UTC79
        }
3f9091557ec3atagen2026-10-11 11:43 UTC80
    }
3f9091557ec3atagen2026-10-11 11:43 UTC81
3f9091557ec3atagen2026-10-11 11:43 UTC82
    /// A yes/no game roll with general odds of `num` in `den`, put to the
3f9091557ec3atagen2026-10-11 11:43 UTC83
    /// model as a choice between the calm answer and the event. The noul head
3f9091557ec3atagen2026-10-11 11:43 UTC84
    /// answers interrupt questions with yes; the choice head, offered the two
3f9091557ec3atagen2026-10-11 11:43 UTC85
    /// answers side by side, does not.
3f9091557ec3atagen2026-10-11 11:43 UTC86
    pub(crate) fn ratio_choice(
3f9091557ec3atagen2026-10-11 11:43 UTC87
        &mut self,
3f9091557ec3atagen2026-10-11 11:43 UTC88
        roll: Roll,
3f9091557ec3atagen2026-10-11 11:43 UTC89
        num: u32,
3f9091557ec3atagen2026-10-11 11:43 UTC90
        den: u32,
3f9091557ec3atagen2026-10-11 11:43 UTC91
        calm: &str,
3f9091557ec3atagen2026-10-11 11:43 UTC92
        event: &str,
3f9091557ec3atagen2026-10-11 11:43 UTC93
    ) -> bool {
3f9091557ec3atagen2026-10-11 11:43 UTC94
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC95
            Self::Rng(rng) => rng.random_ratio(num, den),
3f9091557ec3atagen2026-10-11 11:43 UTC96
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC97
                let i = clm.choose(&roll.state, roll.site, &roll.ask, &[calm, event]);
3f9091557ec3atagen2026-10-11 11:43 UTC98
                i == 1
3f9091557ec3atagen2026-10-11 11:43 UTC99
            }
3f9091557ec3atagen2026-10-11 11:43 UTC100
        }
3f9091557ec3atagen2026-10-11 11:43 UTC101
    }
3f9091557ec3atagen2026-10-11 11:43 UTC102
3f9091557ec3atagen2026-10-11 11:43 UTC103
    /// A yes/no game roll with general odds of `num` in `den`.
3f9091557ec3atagen2026-10-11 11:43 UTC104
    pub(crate) fn ratio(&mut self, roll: Roll, num: u32, den: u32) -> bool {
3f9091557ec3atagen2026-10-11 11:43 UTC105
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC106
            Self::Rng(rng) => rng.random_ratio(num, den),
3f9091557ec3atagen2026-10-11 11:43 UTC107
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC108
                let ask = format!("{}, with odds of about {num} in {den}.", roll.ask);
3f9091557ec3atagen2026-10-11 11:43 UTC109
                clm.decide(&roll.state, roll.site, &ask) > 0.5
3f9091557ec3atagen2026-10-11 11:43 UTC110
            }
3f9091557ec3atagen2026-10-11 11:43 UTC111
        }
3f9091557ec3atagen2026-10-11 11:43 UTC112
    }
3f9091557ec3atagen2026-10-11 11:43 UTC113
3f9091557ec3atagen2026-10-11 11:43 UTC114
    /// One bucket chosen by weighted draw. The rng arm draws by `weights` and
3f9091557ec3atagen2026-10-11 11:43 UTC115
    /// ignores the descriptions; the clm arm reads them all and picks the one
3f9091557ec3atagen2026-10-11 11:43 UTC116
    /// the model believes, weights unused.
3f9091557ec3atagen2026-10-11 11:43 UTC117
    pub(crate) fn pick(&mut self, roll: Roll, weights: &[u32], options: &[&str]) -> usize {
3f9091557ec3atagen2026-10-11 11:43 UTC118
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC119
            Self::Rng(rng) => {
3f9091557ec3atagen2026-10-11 11:43 UTC120
                let total: u32 = weights.iter().sum();
3f9091557ec3atagen2026-10-11 11:43 UTC121
                let mut draw = rng.random_range(0..total);
3f9091557ec3atagen2026-10-11 11:43 UTC122
                for (i, w) in weights.iter().enumerate() {
3f9091557ec3atagen2026-10-11 11:43 UTC123
                    if draw < *w {
3f9091557ec3atagen2026-10-11 11:43 UTC124
                        return i;
3f9091557ec3atagen2026-10-11 11:43 UTC125
                    }
3f9091557ec3atagen2026-10-11 11:43 UTC126
                    draw -= *w;
3f9091557ec3atagen2026-10-11 11:43 UTC127
                }
3f9091557ec3atagen2026-10-11 11:43 UTC128
                weights.len() - 1
3f9091557ec3atagen2026-10-11 11:43 UTC129
            }
3f9091557ec3atagen2026-10-11 11:43 UTC130
            Self::Clm { clm, .. } => clm.choose(&roll.state, roll.site, &roll.ask, options),
3f9091557ec3atagen2026-10-11 11:43 UTC131
        }
3f9091557ec3atagen2026-10-11 11:43 UTC132
    }
3f9091557ec3atagen2026-10-11 11:43 UTC133
3f9091557ec3atagen2026-10-11 11:43 UTC134
    /// A value in `lo..hi`. The rng arm draws uniformly; the clm arm picks from
3f9091557ec3atagen2026-10-11 11:43 UTC135
    /// a 0.1-step grid so the model is choosing among described options.
3f9091557ec3atagen2026-10-11 11:43 UTC136
    pub(crate) fn span(&mut self, roll: Roll, lo: f64, hi: f64) -> f64 {
3f9091557ec3atagen2026-10-11 11:43 UTC137
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC138
            Self::Rng(rng) => rng.random_range(lo..hi),
3f9091557ec3atagen2026-10-11 11:43 UTC139
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC140
                let mut last = format!("{lo:.1}");
3f9091557ec3atagen2026-10-11 11:43 UTC141
                let mut values = Vec::new();
3f9091557ec3atagen2026-10-11 11:43 UTC142
                let mut v = lo;
3f9091557ec3atagen2026-10-11 11:43 UTC143
                while v < hi {
3f9091557ec3atagen2026-10-11 11:43 UTC144
                    values.push(last);
3f9091557ec3atagen2026-10-11 11:43 UTC145
                    v += 0.1;
3f9091557ec3atagen2026-10-11 11:43 UTC146
                    last = format!("{v:.1}");
3f9091557ec3atagen2026-10-11 11:43 UTC147
                }
3f9091557ec3atagen2026-10-11 11:43 UTC148
                let options: Vec<&str> = values.iter().map(String::as_str).collect();
3f9091557ec3atagen2026-10-11 11:43 UTC149
                let ask = format!("{}. Answer with a value.", roll.ask);
3f9091557ec3atagen2026-10-11 11:43 UTC150
                let i = clm.choose(&roll.state, roll.site, &ask, &options);
3f9091557ec3atagen2026-10-11 11:43 UTC151
                lo + 0.1 * i as f64
3f9091557ec3atagen2026-10-11 11:43 UTC152
            }
3f9091557ec3atagen2026-10-11 11:43 UTC153
        }
3f9091557ec3atagen2026-10-11 11:43 UTC154
    }
3f9091557ec3atagen2026-10-11 11:43 UTC155
3f9091557ec3atagen2026-10-11 11:43 UTC156
    /// An integer in `lo..=hi`. The rng arm draws uniformly; the clm arm picks
3f9091557ec3atagen2026-10-11 11:43 UTC157
    /// from the integers as described options.
3f9091557ec3atagen2026-10-11 11:43 UTC158
    pub(crate) fn span_int(&mut self, roll: Roll, lo: i32, hi: i32) -> i32 {
3f9091557ec3atagen2026-10-11 11:43 UTC159
        match self {
3f9091557ec3atagen2026-10-11 11:43 UTC160
            Self::Rng(rng) => rng.random_range(lo..=hi),
3f9091557ec3atagen2026-10-11 11:43 UTC161
            Self::Clm { clm, .. } => {
3f9091557ec3atagen2026-10-11 11:43 UTC162
                let values: Vec<String> = (lo..=hi).map(|v| v.to_string()).collect();
3f9091557ec3atagen2026-10-11 11:43 UTC163
                let options: Vec<&str> = values.iter().map(String::as_str).collect();
3f9091557ec3atagen2026-10-11 11:43 UTC164
                let ask = format!("{}. Answer with a whole number.", roll.ask);
3f9091557ec3atagen2026-10-11 11:43 UTC165
                let i = clm.choose(&roll.state, roll.site, &ask, &options);
3f9091557ec3atagen2026-10-11 11:43 UTC166
                lo + i as i32
3f9091557ec3atagen2026-10-11 11:43 UTC167
            }
3f9091557ec3atagen2026-10-11 11:43 UTC168
        }
3f9091557ec3atagen2026-10-11 11:43 UTC169
    }
3f9091557ec3atagen2026-10-11 11:43 UTC170
}
3f9091557ec3atagen2026-10-11 11:43 UTC171
3f9091557ec3atagen2026-10-11 11:43 UTC172
impl Clm {
3f9091557ec3atagen2026-10-11 11:43 UTC173
    fn new(url: &str) -> Self {
3f9091557ec3atagen2026-10-11 11:43 UTC174
        Self {
3f9091557ec3atagen2026-10-11 11:43 UTC175
            url: url.trim_end_matches('/').to_owned(),
3f9091557ec3atagen2026-10-11 11:43 UTC176
            agent: ureq::AgentBuilder::new()
3f9091557ec3atagen2026-10-11 11:43 UTC177
                .timeout(std::time::Duration::from_secs(60))
3f9091557ec3atagen2026-10-11 11:43 UTC178
                .build(),
3f9091557ec3atagen2026-10-11 11:43 UTC179
        }
3f9091557ec3atagen2026-10-11 11:43 UTC180
    }
3f9091557ec3atagen2026-10-11 11:43 UTC181
3f9091557ec3atagen2026-10-11 11:43 UTC182
    /// POST /v1/systemone with a single question; panics on any failure, which
3f9091557ec3atagen2026-10-11 11:43 UTC183
    /// surfaces as a 500 through the catch_panic layer. A dead oracle must not
3f9091557ec3atagen2026-10-11 11:43 UTC184
    /// quietly fall back to the rng.
3f9091557ec3atagen2026-10-11 11:43 UTC185
    fn ask(&self, state: &str, site: &str, question: Value) -> Value {
3f9091557ec3atagen2026-10-11 11:43 UTC186
        let body = json!({
3f9091557ec3atagen2026-10-11 11:43 UTC187
            "state": state,
3f9091557ec3atagen2026-10-11 11:43 UTC188
            "model": "clm-latest",
3f9091557ec3atagen2026-10-11 11:43 UTC189
            "questions": { site: question },
3f9091557ec3atagen2026-10-11 11:43 UTC190
            "temperature": 1.0,
3f9091557ec3atagen2026-10-11 11:43 UTC191
        });
3f9091557ec3atagen2026-10-11 11:43 UTC192
        let response = self
3f9091557ec3atagen2026-10-11 11:43 UTC193
            .agent
3f9091557ec3atagen2026-10-11 11:43 UTC194
            .post(&self.url)
3f9091557ec3atagen2026-10-11 11:43 UTC195
            .send_json(&body)
3f9091557ec3atagen2026-10-11 11:43 UTC196
            .unwrap_or_else(|e| panic!("oracle unreachable at {}: {e}", self.url));
3f9091557ec3atagen2026-10-11 11:43 UTC197
        let answer: Value = response
3f9091557ec3atagen2026-10-11 11:43 UTC198
            .into_json()
3f9091557ec3atagen2026-10-11 11:43 UTC199
            .unwrap_or_else(|e| panic!("oracle answer unreadable: {e}"));
3f9091557ec3atagen2026-10-11 11:43 UTC200
        answer["answers"][site].clone()
3f9091557ec3atagen2026-10-11 11:43 UTC201
    }
3f9091557ec3atagen2026-10-11 11:43 UTC202
3f9091557ec3atagen2026-10-11 11:43 UTC203
    fn decide(&self, state: &str, site: &str, ask: &str) -> f64 {
3f9091557ec3atagen2026-10-11 11:43 UTC204
        self.ask(state, site, json!({ "type": "noul", "instructions": ask }))["noul"]
3f9091557ec3atagen2026-10-11 11:43 UTC205
            .as_f64()
3f9091557ec3atagen2026-10-11 11:43 UTC206
            .unwrap_or_else(|| panic!("oracle noul answer for {site} missing"))
3f9091557ec3atagen2026-10-11 11:43 UTC207
    }
3f9091557ec3atagen2026-10-11 11:43 UTC208
3f9091557ec3atagen2026-10-11 11:43 UTC209
    fn choose(&self, state: &str, site: &str, ask: &str, options: &[&str]) -> usize {
3f9091557ec3atagen2026-10-11 11:43 UTC210
        let criteria: serde_json::Map<String, Value> = options
3f9091557ec3atagen2026-10-11 11:43 UTC211
            .iter()
3f9091557ec3atagen2026-10-11 11:43 UTC212
            .enumerate()
3f9091557ec3atagen2026-10-11 11:43 UTC213
            .map(|(i, text)| (i.to_string(), json!(text)))
3f9091557ec3atagen2026-10-11 11:43 UTC214
            .collect();
3f9091557ec3atagen2026-10-11 11:43 UTC215
        let q = if ask.trim_end().ends_with('?') {
3f9091557ec3atagen2026-10-11 11:43 UTC216
            ""
3f9091557ec3atagen2026-10-11 11:43 UTC217
        } else {
3f9091557ec3atagen2026-10-11 11:43 UTC218
            "?"
3f9091557ec3atagen2026-10-11 11:43 UTC219
        };
3f9091557ec3atagen2026-10-11 11:43 UTC220
        let instructions = format!("{ask}{q}");
3f9091557ec3atagen2026-10-11 11:43 UTC221
        let answer = self.ask(
3f9091557ec3atagen2026-10-11 11:43 UTC222
            state,
3f9091557ec3atagen2026-10-11 11:43 UTC223
            site,
3f9091557ec3atagen2026-10-11 11:43 UTC224
            json!({ "type": "choice", "instructions": instructions, "criteria": criteria }),
3f9091557ec3atagen2026-10-11 11:43 UTC225
        );
3f9091557ec3atagen2026-10-11 11:43 UTC226
        let choice = answer["choice"]
3f9091557ec3atagen2026-10-11 11:43 UTC227
            .as_str()
3f9091557ec3atagen2026-10-11 11:43 UTC228
            .unwrap_or_else(|| panic!("oracle choice answer for {site} missing"));
3f9091557ec3atagen2026-10-11 11:43 UTC229
        choice
3f9091557ec3atagen2026-10-11 11:43 UTC230
            .parse::<usize>()
e807266c323batagen2026-10-12 09:46 UTC231
            .ok()
e807266c323batagen2026-10-12 09:46 UTC232
            .filter(|&i| i < options.len())
e807266c323batagen2026-10-12 09:46 UTC233
            .unwrap_or_else(|| panic!("oracle chose {choice:?} for {site}, not an option index"))
3f9091557ec3atagen2026-10-11 11:43 UTC234
    }
3f9091557ec3atagen2026-10-11 11:43 UTC235
}