an http based roguelike rpg about jelqing
| 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 | .ok() |
| 232 | .filter(|&i| i < options.len()) |
| 233 | .unwrap_or_else(|| panic!("oracle chose {choice:?} for {site}, not an option index")) |
| 234 | } |
| 235 | } |