atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 3f9091557ec3

tarball

atagen <boss@atagen.co>2026-10-12 05:51 UTC

14 files changed+933-55
game: let a model answer every roll through the oracle

The rng stays the default and its draw order is untouched, so every seed
still replays the same run. JELQD_ORACLE=clm points the server at a
System One service instead, where each roll becomes a plain question over
the rendered state prose and the model's belief, not the table, decides.
Identity randomness keeps its own rng, and a dead oracle 500s rather than
quietly falling back to dice and contaminating the experiment.

The model answers interrupt questions with yes whatever the stated odds,
so the wanderer, the window, the pulled muscle and the hold rupture are
put as calm statements instead, with chance_not and ratio_choice keeping
the rng arm's draw identical. Option lists stay out of choice
instructions after listing "someone walks in" primed the very answer it
describes. Every bot still dies under the model, 0 of 60 across two
campaigns, since each danger ask sits between 0.4 and 0.6 and rewording
one tips the next. It ships as a drama meter, not a die.
Modified file: Cargo.lock+279 additions
@@ -67,6 +67,12 @@ dependencies = [
6767 ]
6868
6969 [[package]]
70+name = "base64"
71+version = "0.22.1"
72+source = "registry+https://github.com/rust-lang/crates.io-index"
73+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
74+
75+[[package]]
7076 name = "bitflags"
7177 version = "2.13.2"
7278 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -133,6 +139,17 @@ dependencies = [
133139 ]
134140
135141 [[package]]
142+name = "displaydoc"
143+version = "0.2.7"
144+source = "registry+https://github.com/rust-lang/crates.io-index"
145+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
146+dependencies = [
147+ "proc-macro2",
148+ "quote",
149+ "syn 3.0.7",
150+]
151+
152+[[package]]
136153 name = "flate2"
137154 version = "1.1.10"
138155 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -299,6 +316,110 @@ dependencies = [
299316 ]
300317
301318 [[package]]
319+name = "icu_collections"
320+version = "2.3.0"
321+source = "registry+https://github.com/rust-lang/crates.io-index"
322+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
323+dependencies = [
324+ "displaydoc",
325+ "potential_utf",
326+ "utf8_iter",
327+ "yoke",
328+ "zerofrom",
329+ "zerovec",
330+]
331+
332+[[package]]
333+name = "icu_locale_core"
334+version = "2.3.0"
335+source = "registry+https://github.com/rust-lang/crates.io-index"
336+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
337+dependencies = [
338+ "displaydoc",
339+ "litemap",
340+ "tinystr",
341+ "writeable",
342+ "zerovec",
343+]
344+
345+[[package]]
346+name = "icu_normalizer"
347+version = "2.3.0"
348+source = "registry+https://github.com/rust-lang/crates.io-index"
349+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
350+dependencies = [
351+ "icu_collections",
352+ "icu_normalizer_data",
353+ "icu_properties",
354+ "icu_provider",
355+ "smallvec",
356+ "zerovec",
357+]
358+
359+[[package]]
360+name = "icu_normalizer_data"
361+version = "2.3.0"
362+source = "registry+https://github.com/rust-lang/crates.io-index"
363+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
364+
365+[[package]]
366+name = "icu_properties"
367+version = "2.3.0"
368+source = "registry+https://github.com/rust-lang/crates.io-index"
369+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
370+dependencies = [
371+ "displaydoc",
372+ "icu_collections",
373+ "icu_locale_core",
374+ "icu_properties_data",
375+ "icu_provider",
376+ "zerotrie",
377+ "zerovec",
378+]
379+
380+[[package]]
381+name = "icu_properties_data"
382+version = "2.3.0"
383+source = "registry+https://github.com/rust-lang/crates.io-index"
384+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
385+
386+[[package]]
387+name = "icu_provider"
388+version = "2.3.1"
389+source = "registry+https://github.com/rust-lang/crates.io-index"
390+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
391+dependencies = [
392+ "displaydoc",
393+ "icu_locale_core",
394+ "writeable",
395+ "yoke",
396+ "zerofrom",
397+ "zerotrie",
398+ "zerovec",
399+]
400+
401+[[package]]
402+name = "idna"
403+version = "1.1.0"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
406+dependencies = [
407+ "idna_adapter",
408+ "smallvec",
409+ "utf8_iter",
410+]
411+
412+[[package]]
413+name = "idna_adapter"
414+version = "1.2.2"
415+source = "registry+https://github.com/rust-lang/crates.io-index"
416+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
417+dependencies = [
418+ "icu_normalizer",
419+ "icu_properties",
420+]
421+
422+[[package]]
302423 name = "itoa"
303424 version = "1.0.18"
304425 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -322,6 +443,7 @@ dependencies = [
322443 "tokio",
323444 "tower",
324445 "tower-http",
446+ "ureq",
325447 ]
326448
327449 [[package]]
@@ -331,6 +453,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
331453 checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
332454
333455 [[package]]
456+name = "litemap"
457+version = "0.8.3"
458+source = "registry+https://github.com/rust-lang/crates.io-index"
459+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
460+
461+[[package]]
334462 name = "log"
335463 version = "0.4.34"
336464 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -394,6 +522,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
394522 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
395523
396524 [[package]]
525+name = "potential_utf"
526+version = "0.1.6"
527+source = "registry+https://github.com/rust-lang/crates.io-index"
528+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
529+dependencies = [
530+ "zerovec",
531+]
532+
533+[[package]]
397534 name = "ppv-lite86"
398535 version = "0.2.21"
399536 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -567,6 +704,12 @@ dependencies = [
567704 ]
568705
569706 [[package]]
707+name = "stable_deref_trait"
708+version = "1.2.1"
709+source = "registry+https://github.com/rust-lang/crates.io-index"
710+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
711+
712+[[package]]
570713 name = "subtle"
571714 version = "2.6.1"
572715 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -601,6 +744,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
601744 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
602745
603746 [[package]]
747+name = "synstructure"
748+version = "0.14.0"
749+source = "registry+https://github.com/rust-lang/crates.io-index"
750+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
751+dependencies = [
752+ "proc-macro2",
753+ "quote",
754+ "syn 3.0.7",
755+]
756+
757+[[package]]
758+name = "tinystr"
759+version = "0.8.4"
760+source = "registry+https://github.com/rust-lang/crates.io-index"
761+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
762+dependencies = [
763+ "displaydoc",
764+ "zerovec",
765+]
766+
767+[[package]]
604768 name = "tokio"
605769 version = "1.53.2"
606770 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -705,6 +869,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
705869 checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
706870
707871 [[package]]
872+name = "ureq"
873+version = "2.12.1"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
876+dependencies = [
877+ "base64",
878+ "log",
879+ "once_cell",
880+ "serde",
881+ "serde_json",
882+ "url",
883+]
884+
885+[[package]]
886+name = "url"
887+version = "2.5.8"
888+source = "registry+https://github.com/rust-lang/crates.io-index"
889+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
890+dependencies = [
891+ "form_urlencoded",
892+ "idna",
893+ "percent-encoding",
894+ "serde",
895+]
896+
897+[[package]]
898+name = "utf8_iter"
899+version = "1.0.4"
900+source = "registry+https://github.com/rust-lang/crates.io-index"
901+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
902+
903+[[package]]
708904 name = "version_check"
709905 version = "0.9.5"
710906 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -747,6 +943,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
747943 checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
748944
749945 [[package]]
946+name = "writeable"
947+version = "0.6.4"
948+source = "registry+https://github.com/rust-lang/crates.io-index"
949+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
950+
951+[[package]]
952+name = "yoke"
953+version = "0.8.3"
954+source = "registry+https://github.com/rust-lang/crates.io-index"
955+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
956+dependencies = [
957+ "stable_deref_trait",
958+ "yoke-derive",
959+ "zerofrom",
960+]
961+
962+[[package]]
963+name = "yoke-derive"
964+version = "0.8.4"
965+source = "registry+https://github.com/rust-lang/crates.io-index"
966+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
967+dependencies = [
968+ "proc-macro2",
969+ "quote",
970+ "syn 3.0.7",
971+ "synstructure",
972+]
973+
974+[[package]]
750975 name = "zerocopy"
751976 version = "0.8.62"
752977 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -767,6 +992,60 @@ dependencies = [
767992 ]
768993
769994 [[package]]
995+name = "zerofrom"
996+version = "0.1.8"
997+source = "registry+https://github.com/rust-lang/crates.io-index"
998+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
999+dependencies = [
1000+ "zerofrom-derive",
1001+]
1002+
1003+[[package]]
1004+name = "zerofrom-derive"
1005+version = "0.1.8"
1006+source = "registry+https://github.com/rust-lang/crates.io-index"
1007+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
1008+dependencies = [
1009+ "proc-macro2",
1010+ "quote",
1011+ "syn 3.0.7",
1012+ "synstructure",
1013+]
1014+
1015+[[package]]
1016+name = "zerotrie"
1017+version = "0.2.5"
1018+source = "registry+https://github.com/rust-lang/crates.io-index"
1019+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
1020+dependencies = [
1021+ "displaydoc",
1022+ "yoke",
1023+ "zerofrom",
1024+]
1025+
1026+[[package]]
1027+name = "zerovec"
1028+version = "0.11.8"
1029+source = "registry+https://github.com/rust-lang/crates.io-index"
1030+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
1031+dependencies = [
1032+ "yoke",
1033+ "zerofrom",
1034+ "zerovec-derive",
1035+]
1036+
1037+[[package]]
1038+name = "zerovec-derive"
1039+version = "0.11.6"
1040+source = "registry+https://github.com/rust-lang/crates.io-index"
1041+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
1042+dependencies = [
1043+ "proc-macro2",
1044+ "quote",
1045+ "syn 3.0.7",
1046+]
1047+
1048+[[package]]
7701049 name = "zlib-rs"
7711050 version = "0.6.8"
7721051 source = "registry+https://github.com/rust-lang/crates.io-index"
Modified file: Cargo.toml+1 additions
@@ -20,4 +20,5 @@ sha2 = "0.10"
2020 tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }
2121 tower = { version = "0.5", features = ["util"] }
2222 tower-http = { version = "0.6", features = ["catch-panic"] }
23+ureq = { version = "2", default-features = false, features = ["json"] }
2324
Added file: scripts/probe-oracle.nu+76 additions
@@ -0,0 +1,76 @@
1+# Probe the live System One model with the game's exact ask format.
2+
3+def main [url: string = "http://127.0.0.1:8700/v1/systemone", which: string = "all"] {
4+ let bedroom = "Floor 1, the bedroom. Turn 12.\n\neq: 55\nstamina: 7\nfriction: 8\nsuspicion: 1\nshame: 2\nwarmed up: yes\ngrip: overhand\nitems: baby-oil\n"
5+ let hot = "Floor 1, the bedroom. Turn 40.\n\neq: 96\nstamina: 4\nfriction: 30\nsuspicion: 2\nshame: 4\nwarmed up: yes\ngrip: overhand\nitems: ring\n"
6+ let bathroom = "Floor 2, the bathroom. Turn 30.\n\neq: 55\nstamina: 7\nfriction: 12\nsuspicion: 4\nshame: 3\nwarmed up: yes\ngrip: overhand\n"
7+ let changing = "Floor 3, the changing room. Turn 10.\n\neq: 60\nstamina: 8\nfriction: 10\nsuspicion: 2\nshame: 1\nwarmed up: yes\ngrip: overhand\n"
8+ let hotel = "Floor 5, the hotel room. Turn 40.\n\neq: 60\nstamina: 6\nfriction: 10\nsuspicion: 3\nshame: 4\nwarmed up: yes\ngrip: overhand\nitems: dnd-sign\n"
9+ let hold = "Floor 1, the bedroom. Turn 25.\n\neq: 75\nstamina: 1\nfriction: 20\nsuspicion: 2\nshame: 5\nwarmed up: yes\ngrip: overhand\n"
10+ let exit = "Floor 1, the bedroom. Turn 18.\n\neq: 55\nstamina: 8\nfriction: 6\nsuspicion: 1\nshame: 2\nwarmed up: yes\ngrip: overhand\nitems: ruler\n"
11+ let drawer = "Floor 1, the bedroom. Turn 2.\n\neq: 20\nstamina: 10\nfriction: 0\nsuspicion: 0\nshame: 0\nwarmed up: no\ngrip: overhand\n"
12+ let kitchen = "Floor 1, the bedroom. Turn 8.\n\neq: 92\nstamina: 9\nfriction: 4\nsuspicion: 1\nshame: 2\nwarmed up: yes\ngrip: overhand\n"
13+
14+ let current = [
15+ {site: "observed" ask: "Has someone quietly noticed what is going on?" odds: "1 in 6" state: $changing}
16+ {site: "wanderer" ask: "Given how much noise has been made, does someone wander in unannounced?" odds: "about 1 in 32" state: $bathroom}
17+ {site: "wanderer-hot" ask: "Given how much noise has been made, does someone wander in unannounced?" odds: "about 6 in 32" state: $bathroom}
18+ {site: "zip" ask: "As the floor ends and you put yourself away, does the zip catch?" odds: "1 in 200" state: $exit}
19+ {site: "kitchen-told" ask: "Has the household had enough of the raiding?" odds: "1 in 4" state: $kitchen}
20+ {site: "reflection" ask: "In the quiet afterwards, have you caught your own eye in the mirror?" odds: "1 in 12" state: $hold}
21+ {site: "cat-takes-towel" ask: "Has the cat taken the towel?" odds: "1 in 5" state: $bathroom}
22+ {site: "window" ask: "Is a neighbour at the window with a camera?" odds: "1 in 60" state: $bedroom}
23+ {site: "friend" ask: "Does a friend walk in and offer to help?" odds: "1 in 10" state: $changing}
24+ {site: "degloving" ask: "Dry at this intensity, does the skin tear?" odds: "1 in 60" state: $hot}
25+ {site: "friction-burn" ask: "With the skin this raw, has it finally burned?" odds: "1 in 10" state: $hot}
26+ {site: "pulled-muscle" ask: "Does this intensity pull a muscle?" odds: "1 in 12" state: $hot}
27+ {site: "ring-priapism" ask: "With the ring still on and everything this warm, will it not go down?" odds: "1 in 10" state: $hot}
28+ {site: "hotel-stress" ask: "Has the hotel's plumbing given up for the week?" odds: "1 in 3" state: $hotel}
29+ {site: "caught-meloned" ask: "Is Mum standing in the doorway as you look up?" odds: "1 in 3" state: $kitchen}
30+ {site: "misdirected" ask: "With the curve, does the stroke land sharply to the left?" odds: "1 in 3" state: $hot}
31+ {site: "key-in-drawer" ask: "Does the drawer finally give up the cage key?" odds: "1 in 3" state: $drawer}
32+ {site: "watermelon" ask: "Is there a watermelon in the fridge, exactly the right shape?" odds: "about 2 in 5" state: $kitchen}
33+ {site: "mum-summon" ask: "With this much noise in the house, is Mum on her way up?" odds: "about 1 in 16" state: $bedroom}
34+ {site: "mum-summon-hot" ask: "With this much noise in the house, is Mum on her way up?" odds: "about 6 in 16" state: $bedroom}
35+ {site: "teach-uli" ask: "Does this reread finally surface the 2009 reply that teaches uli?" odds: "1 in 4" state: $bedroom}
36+ {site: "rupture-calm" ask: "Does this jelq rupture something?" odds: "1 in 250" state: $bedroom}
37+ {site: "rupture-hot" ask: "Does this jelq rupture something?" odds: "1 in 10" state: $hot}
38+ {site: "rupture-premature" ask: "Right at the edge of finishing, does it rupture instead?" odds: "1 in 3" state: $hot}
39+ {site: "rupture-hold" ask: "Does even the pause rupture something?" odds: "1 in 250" state: $hold}
40+ {site: "viral" ask: "Has a neighbour already sent Mum the footage?" odds: "1 in 2" state: $bedroom}
41+ ]
42+
43+ let candidate = [
44+ {site: "observed" ask: "Does the moment pass unnoticed?" odds: "1 in 6" state: $changing}
45+ {site: "wanderer" ask: "Does the room stay private?" odds: "about 1 in 32" state: $bathroom}
46+ {site: "zip" ask: "Does the zip slide down without a fight?" odds: "1 in 200" state: $exit}
47+ {site: "kitchen-told" ask: "Does the raid pass without comment?" odds: "1 in 4" state: $kitchen}
48+ {site: "reflection" ask: "Do you keep your composure?" odds: "1 in 12" state: $hold}
49+ {site: "cat-takes-towel" ask: "Is the towel still where you left it?" odds: "1 in 5" state: $bathroom}
50+ {site: "window" ask: "Are the curtains still closed?" odds: "1 in 60" state: $bedroom}
51+ {site: "friend" ask: "Does the changing room stay empty?" odds: "1 in 10" state: $changing}
52+ {site: "degloving" ask: "Does the skin hold?" odds: "1 in 60" state: $hot}
53+ {site: "friction-burn" ask: "Does the skin hold up?" odds: "1 in 10" state: $hot}
54+ {site: "pulled-muscle" ask: "Do the muscles hold?" odds: "1 in 12" state: $hot}
55+ {site: "ring-priapism" ask: "With the ring on, does everything settle back down?" odds: "1 in 10" state: $hot}
56+ {site: "hotel-stress" ask: "Is the water still running?" odds: "1 in 3" state: $hotel}
57+ {site: "caught-meloned" ask: "Is the doorway empty?" odds: "1 in 3" state: $kitchen}
58+ {site: "misdirected" ask: "Does the stroke land straight?" odds: "1 in 3" state: $hot}
59+ {site: "key-in-drawer" ask: "Is the drawer still empty?" odds: "1 in 3" state: $drawer}
60+ ]
61+
62+ let rows = $current ++ $candidate
63+ mut n = 0
64+ for row in $rows {
65+ $n += 1
66+ let key = $"q($n)"
67+ let body = {
68+ state: $row.state
69+ model: "clm-latest"
70+ questions: { ($key): {type: "noul", instructions: $"($row.ask), with odds of ($row.odds)."} }
71+ temperature: 1.0
72+ }
73+ let answer = (http post $url $body --content-type application/json | get answers | get $key | get noul)
74+ print $"($row.site | fill -w 18) (($answer * 100) | math round --precision 1 | fill -w 6) ($row.ask | str substring 0..60)"
75+ }
76+}
Modified file: src/config.rs+10 additions
@@ -1,5 +1,6 @@
11 use std::time::Duration;
22
3+use crate::game::oracle::OracleTemplate;
34 use crate::secret::Secret;
45
56 pub struct Config {
@@ -8,6 +9,7 @@ pub struct Config {
89 pub secret: Secret,
910 pub max_runs: usize,
1011 pub idle_ttl: Duration,
12+ pub(crate) oracle: OracleTemplate,
1113 }
1214
1315 impl Default for Config {
@@ -18,6 +20,7 @@ impl Default for Config {
1820 secret: Secret::random(),
1921 max_runs: 10_000,
2022 idle_ttl: Duration::from_secs(6 * 60 * 60),
23+ oracle: OracleTemplate::Rng,
2124 }
2225 }
2326 }
@@ -32,6 +35,13 @@ impl Config {
3235 .and_then(|s| s.parse().ok())
3336 .map_or(defaults.refractory, Duration::from_secs),
3437 secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),
38+ oracle: match var("JELQD_ORACLE").as_deref() {
39+ Some("clm") => OracleTemplate::Clm {
40+ url: var("JELQD_CLM_URL")
41+ .unwrap_or_else(|| "http://127.0.0.1:8700/v1/systemone".into()),
42+ },
43+ _ => defaults.oracle,
44+ },
3545 ..defaults
3646 }
3747 }
Modified file: src/game/floors.rs+16 additions−3 deletions
@@ -62,7 +62,13 @@ impl Run {
6262 }
6363 _ => {}
6464 }
65- if self.chance(ZIP) {
65+ if self.chance(
66+ self.decision(
67+ "zip",
68+ "As the floor ends and you put yourself away, does the zip catch?",
69+ ),
70+ ZIP,
71+ ) {
6672 return Err(self.end(Cause::Zip));
6773 }
6874 let left = self.floor.name();
@@ -130,7 +136,11 @@ impl Run {
130136 if self.items.cage {
131137 return Ok(self.rummage_for_key());
132138 }
133- let ruler_here = self.floor == Floor::Bedroom && self.chance(RULER_IN_DRAWER);
139+ let ruler_here = self.floor == Floor::Bedroom
140+ && self.chance(
141+ self.decision("ruler", "Is there a ruler hiding in the drawer?"),
142+ RULER_IN_DRAWER,
143+ );
134144 match self.floor {
135145 Floor::Bedroom if first && !self.items.ruler && ruler_here => {
136146 self.items.grant(Item::Ruler);
@@ -207,7 +217,10 @@ impl Run {
207217 let was_below = self.suspicion < SUSPICION_LIMIT;
208218 self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);
209219 self.maybe_summon_mum(was_below);
210- if !self.chance(KEY_IN_DRAWER) {
220+ if !self.chance(
221+ self.decision("key", "Does the drawer finally give up the cage key?"),
222+ KEY_IN_DRAWER,
223+ ) {
211224 return Outcome::empty(StatusCode::NO_CONTENT);
212225 }
213226 self.items.grant(Item::CageKey);
Modified file: src/game/forum.rs+14 additions−2 deletions
@@ -119,10 +119,22 @@ impl Run {
119119 self.here.turns += 1;
120120 self.end_kitchen_visit();
121121 self.realise();
122- if self.chance(self.rupture_odds()) {
122+ if self.chance(
123+ self.decision(
124+ "rupture",
125+ "Does the third read of the same thread rupture something?",
126+ ),
127+ self.rupture_odds(),
128+ ) {
123129 return Err(self.rupture());
124130 }
125- if self.chance(ULI_ODDS) {
131+ if self.chance(
132+ self.decision(
133+ "teach-uli",
134+ "Does this reread finally surface the 2009 reply that teaches uli?",
135+ ),
136+ ULI_ODDS,
137+ ) {
126138 self.uli_taught = true;
127139 return Ok(Outcome::json(
128140 StatusCode::OK,
Modified file: src/game/hazards.rs+49 additions−8 deletions
@@ -1,7 +1,6 @@
11 use std::{ops::ControlFlow, time::SystemTime};
22
33 use axum::http::{StatusCode, header};
4-use rand::Rng;
54 use serde_json::json;
65
76 use super::{
@@ -40,7 +39,13 @@ impl Run {
4039
4140 /// Accidents tied to a specific technique.
4241 pub(super) fn mishaps(&mut self, training: Training, intensity: i32) -> Interruption {
43- if training == Training::Dry && intensity >= DEGLOVING_INTENSITY && self.chance(DEGLOVING) {
42+ if training == Training::Dry
43+ && intensity >= DEGLOVING_INTENSITY
44+ && self.chance(
45+ self.decision("degloving", "Dry at this intensity, does the skin tear?"),
46+ DEGLOVING,
47+ )
48+ {
4449 return Err(self.end(Cause::Degloved));
4550 }
4651 Ok(ControlFlow::Continue(()))
@@ -49,10 +54,21 @@ impl Run {
4954 /// Everything that can go wrong on a committed turn before the roll.
5055 pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {
5156 let burn = self.burn();
52- if self.eq <= PREMATURE_EQ - burn && self.chance(self.rupture_odds()) {
57+ if self.eq <= PREMATURE_EQ - burn
58+ && self.chance(
59+ self.decision("rupture", "Does this jelq rupture something?"),
60+ self.rupture_odds(),
61+ )
62+ {
5363 return Err(self.rupture());
5464 }
55- if self.floor.has_window() && !self.items.blinds && self.chance(WINDOW) {
65+ if self.floor.has_window()
66+ && !self.items.blinds
67+ && self.oracle.chance_not(
68+ self.decision("window", "Are the curtains still closed?"),
69+ WINDOW,
70+ )
71+ {
5672 self.shame += 30;
5773 self.filmed_at.get_or_insert_with(SystemTime::now);
5874 return Ok(ControlFlow::Break(
@@ -63,7 +79,12 @@ impl Run {
6379 .header(header::LOCATION, self.path("/footage")),
6480 ));
6581 }
66- if self.floor == Floor::ChangingRoom && self.chance(FRIEND) {
82+ if self.floor == Floor::ChangingRoom
83+ && self.chance(
84+ self.decision("friend", "Does a friend walk in and offer to help?"),
85+ FRIEND,
86+ )
87+ {
6788 self.shame += 1;
6889 return Err(Problem::new(
6990 StatusCode::USE_PROXY,
@@ -71,7 +92,15 @@ impl Run {
7192 "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",
7293 ));
7394 }
74- if self.conditions.peyronie && self.chance(CURVE_MISSES) {
95+ if self.conditions.peyronie
96+ && self.chance(
97+ self.decision(
98+ "misdirected",
99+ "With the curve, does the stroke land sharply to the left?",
100+ ),
101+ CURVE_MISSES,
102+ )
103+ {
75104 return Err(Problem::new(
76105 StatusCode::MISDIRECTED_REQUEST,
77106 "misdirected",
@@ -88,7 +117,13 @@ impl Run {
88117 .header(header::CONNECTION, "close"));
89118 }
90119 if self.eq > PREMATURE_EQ - burn {
91- if self.chance(RUPTURE_PREMATURE) {
120+ if self.chance(
121+ self.decision(
122+ "rupture",
123+ "Right at the edge of finishing, does it rupture instead?",
124+ ),
125+ RUPTURE_PREMATURE,
126+ ) {
92127 return Err(self.rupture());
93128 }
94129 self.shame += 5;
@@ -113,7 +148,13 @@ impl Run {
113148 pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {
114149 let visits = u32::from(self.suspicion) + 1;
115150 let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)
116- && self.rng.random_ratio(visits, WANDERER_VISIT);
151+ && self.oracle.ratio_choice(
152+ self.decision("wanderer", "Does anyone else come through the door?"),
153+ visits,
154+ WANDERER_VISIT,
155+ "No",
156+ "Yes",
157+ );
117158 if self.suspicion < SUSPICION_LIMIT && !wanders_in {
118159 return Ok(());
119160 }
Modified file: src/game/household.rs+13 additions−3 deletions
@@ -1,7 +1,6 @@
11 use std::collections::HashMap;
22
33 use axum::http::{StatusCode, header};
4-use rand::Rng;
54
65 use super::{
76 Cause, Floor, Run,
@@ -84,7 +83,12 @@ impl Run {
8483 if summoned {
8584 return "She heard a noise.";
8685 }
87- if self.filmed_at.is_some() && self.chance(VIRAL_ERRAND) {
86+ if self.filmed_at.is_some()
87+ && self.chance(
88+ self.decision("viral", "Has a neighbour already sent Mum the footage?"),
89+ VIRAL_ERRAND,
90+ )
91+ {
8892 return "A neighbour has sent her something.";
8993 }
9094 let errands = if self.floor == Floor::Nans {
@@ -92,7 +96,13 @@ impl Run {
9296 } else {
9397 BEDROOM_ERRANDS
9498 };
95- errands[self.rng.random_range(0..errands.len())]
99+ let weights = vec![1; errands.len()];
100+ let i = self.oracle.pick(
101+ self.decision("errand", "What has brought Mum to the door?"),
102+ &weights,
103+ &errands,
104+ );
105+ errands[i]
96106 }
97107
98108 /// Mum answers every request on the run until she has been given something she believes.
Modified file: src/game/kitchen.rs+45 additions−13 deletions
@@ -9,7 +9,6 @@ use super::{
99 },
1010 };
1111 use crate::problem::Problem;
12-use rand::Rng;
1312
1413 impl Run {
1514 /// Raid the fridge. It costs the floor turn like anything else, and the household keeps count.
@@ -35,32 +34,53 @@ impl Run {
3534 self.turn += 1;
3635 self.here.turns += 1;
3736 self.realise();
38- if self.chance(self.rupture_odds()) {
37+ if self.chance(
38+ self.decision("rupture", "Does this raid of the fridge rupture something?"),
39+ self.rupture_odds(),
40+ ) {
3941 return Err(self.rupture());
4042 }
4143 let was_below = self.suspicion < SUSPICION_LIMIT;
4244 // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5 and 1 in 8.
43- let draw = self.rng.random_range(0..120);
44- let oil = 120 / COCONUT_ODDS;
45- let energy = oil + 120 / ENERGY_ODDS;
46- let ice = energy + 120 / KITCHEN_ICE_ODDS;
47- let outcome = if self.eq > RUPTURE_LINE && self.rng.random_ratio(WATERMELON.0, WATERMELON.1)
48- {
45+ let loot = self.oracle.pick(
46+ self.decision("loot", "What does the fridge give up on this raid?"),
47+ &[
48+ 120 / COCONUT_ODDS,
49+ 120 / ENERGY_ODDS,
50+ 120 / KITCHEN_ICE_ODDS,
51+ 120 - (120 / COCONUT_ODDS + 120 / ENERGY_ODDS + 120 / KITCHEN_ICE_ODDS),
52+ ],
53+ &[
54+ "A jar of coconut oil, half of it already gone.",
55+ "An energy drink, cold and faintly alarming.",
56+ "An ice pack, buried under the peas.",
57+ "Nothing. The fridge is ordinary.",
58+ ],
59+ );
60+ let outcome = if self.eq > RUPTURE_LINE
61+ && self.oracle.ratio(
62+ self.decision(
63+ "watermelon",
64+ "Is there a watermelon in the fridge, exactly the right shape?",
65+ ),
66+ WATERMELON.0,
67+ WATERMELON.1,
68+ ) {
4969 self.here.melon_offered = true;
5070 self.melon_offer()
51- } else if draw < oil {
71+ } else if loot == 0 {
5272 self.items.grant(Item::CoconutOil);
5373 Ok(Outcome::json(
5474 StatusCode::OK,
5575 json!({ "detail": "A jar of coconut oil, half of it already gone." }),
5676 ))
57- } else if draw < energy {
77+ } else if loot == 1 {
5878 self.items.grant(Item::EnergyDrink);
5979 Ok(Outcome::json(
6080 StatusCode::OK,
6181 json!({ "detail": "An energy drink, cold and faintly alarming." }),
6282 ))
63- } else if draw < ice {
83+ } else if loot == 2 {
6484 self.items.grant(Item::IcePack);
6585 Ok(Outcome::json(
6686 StatusCode::OK,
@@ -71,7 +91,13 @@ impl Run {
7191 };
7292 self.settle(was_below);
7393 let outcome = outcome?;
74- if self.chance(KITCHEN_TOLD) {
94+ if self.chance(
95+ self.decision(
96+ "kitchen-told",
97+ "Has the household had enough of the raiding?",
98+ ),
99+ KITCHEN_TOLD,
100+ ) {
75101 self.kitchen_tellings += 1;
76102 if self.kitchen_tellings >= KITCHEN_TELLINGS {
77103 return Err(Problem::new(
@@ -122,7 +148,13 @@ impl Run {
122148 self.reset_eq(0);
123149 self.shame += MELON_SHAME;
124150 self.touch();
125- if self.chance(CAUGHT_MELONED) {
151+ if self.chance(
152+ self.decision(
153+ "caught-meloned",
154+ "Is Mum standing in the doorway as you look up?",
155+ ),
156+ CAUGHT_MELONED,
157+ ) {
126158 return Err(self.end(Cause::Meloned));
127159 }
128160 Ok(Outcome::json(
Modified file: src/game/mod.rs+102 additions−11 deletions
@@ -7,6 +7,7 @@ mod hazards;
77 mod household;
88 mod items;
99 mod kitchen;
10+pub(crate) mod oracle;
1011 mod outcome;
1112 mod shop;
1213 mod state;
@@ -29,7 +30,6 @@ use std::{
2930 };
3031
3132 use axum::http::{StatusCode, header};
32-use rand::{Rng, SeedableRng, rngs::StdRng};
3333 use serde_json::{Value, json};
3434
3535 use crate::problem::Problem;
@@ -37,6 +37,7 @@ use balance::{
3737 HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
3838 STARTING_CASH, STARTING_EQ,
3939 };
40+use oracle::{Oracle, OracleTemplate};
4041 use state::Pending;
4142
4243 pub struct Run {
@@ -81,7 +82,7 @@ pub struct Run {
8182 pub notices: Vec<&'static str>,
8283 pub ended: Option<Ending>,
8384 pub last_seen: Instant,
84- rng: StdRng,
85+ oracle: Oracle,
8586 }
8687
8788 pub fn round2(x: f64) -> f64 {
@@ -123,12 +124,34 @@ pub fn shaft(mm: usize) -> String {
123124 }
124125
125126 impl Run {
127+ #[cfg(test)]
126128 pub fn new(id: String, public_id: String, seed: u64) -> Self {
127- let mut rng = StdRng::seed_from_u64(seed);
128- let length = round2(rng.random_range(4.5..6.5));
129- let girth = round2(rng.random_range(4.0..5.0));
130- let foreskin = round2(rng.random_range(0.4..2.4));
131- let etag_salt = rng.random();
129+ Self::with_oracle(id, public_id, seed, &OracleTemplate::Rng)
130+ }
131+
132+ pub fn with_oracle(
133+ id: String,
134+ public_id: String,
135+ seed: u64,
136+ template: &OracleTemplate,
137+ ) -> Self {
138+ let mut oracle = Oracle::build(template, seed);
139+ let length = round2(oracle.span(
140+ Run::unborn("length", "How long is it, in inches?"),
141+ 4.5,
142+ 6.5,
143+ ));
144+ let girth = round2(oracle.span(
145+ Run::unborn("girth", "How thick is it, in inches?"),
146+ 4.0,
147+ 5.0,
148+ ));
149+ let foreskin = round2(oracle.span(
150+ Run::unborn("foreskin", "How much foreskin is there, in inches?"),
151+ 0.4,
152+ 2.4,
153+ ));
154+ let etag_salt = oracle.u64();
132155 Self {
133156 id,
134157 public_id,
@@ -170,7 +193,16 @@ impl Run {
170193 pending: Vec::new(),
171194 ended: None,
172195 last_seen: Instant::now(),
173- rng,
196+ oracle,
197+ }
198+ }
199+
200+ /// A roll for a run that does not exist yet, so there is no state to describe.
201+ fn unborn(site: &'static str, ask: &str) -> oracle::Roll {
202+ oracle::Roll {
203+ state: "A new run is being created. No game has been played yet.".into(),
204+ site,
205+ ask: ask.into(),
174206 }
175207 }
176208
@@ -213,11 +245,70 @@ impl Run {
213245 }
214246
215247 fn fresh_nonce(&mut self) -> String {
216- format!("{:016x}", self.rng.random::<u64>())
248+ format!("{:016x}", self.oracle.u64())
249+ }
250+
251+ /// One game decision for the oracle, phrased for the model and stamped
252+ /// with the state as the player sees it.
253+ fn decision(&self, site: &'static str, ask: impl std::fmt::Display) -> oracle::Roll {
254+ oracle::Roll {
255+ state: self.oracle_state(),
256+ site,
257+ ask: ask.to_string(),
258+ }
259+ }
260+
261+ fn oracle_state(&self) -> String {
262+ let mut s = String::new();
263+ let place = match self.floor {
264+ Floor::Bedroom => "the bedroom",
265+ Floor::Bathroom => "the bathroom",
266+ Floor::ChangingRoom => "the changing room",
267+ Floor::Nans => "Nan's house",
268+ Floor::Hotel => "the hotel room",
269+ Floor::Forum => "the forum floor",
270+ Floor::Clinic => "the clinic",
271+ };
272+ s.push_str(&format!(
273+ "Floor {}, {place}. Turn {}.\n\n",
274+ self.floor.number(),
275+ self.turn
276+ ));
277+ s.push_str(&format!(
278+ "eq: {}\nstamina: {}\nfriction: {}\n",
279+ self.eq, self.stamina, self.friction
280+ ));
281+ if self.conditions.friction_burn {
282+ s.push_str("friction burn: yes, untreated\n");
283+ }
284+ if self.conditions.priapism_turns > 0 {
285+ s.push_str("priapism: yes\n");
286+ }
287+ if self.conditions.peyronie {
288+ s.push_str("peyronie's disease: yes\n");
289+ }
290+ if self.conditions.pulled_muscle {
291+ s.push_str("pulled muscle: yes\n");
292+ }
293+ s.push_str(&format!(
294+ "suspicion: {}\nshame: {}\nwarmed up: {}\ngrip: {}\n",
295+ self.suspicion,
296+ self.shame,
297+ if self.warmed_up { "yes" } else { "no" },
298+ self.grip.name(),
299+ ));
300+ let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
301+ if !held.is_empty() {
302+ s.push_str(&format!("items: {}\n", held.join(", ")));
303+ }
304+ if self.mum.is_some() {
305+ s.push_str("Mum is at the door\n");
306+ }
307+ s
217308 }
218309
219- fn chance(&mut self, odds: u32) -> bool {
220- self.rng.random_ratio(1, odds)
310+ fn chance(&mut self, roll: oracle::Roll, odds: u32) -> bool {
311+ self.oracle.chance(roll, odds)
221312 }
222313
223314 pub fn path(&self, sub: &str) -> String {
Added file: src/game/oracle.rs+233 additions
@@ -0,0 +1,233 @@
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+}
Modified file: src/game/turn.rs+73 additions−12 deletions
@@ -1,7 +1,6 @@
11 use std::{fmt::Write as _, ops::ControlFlow, time::Instant};
22
33 use axum::http::{StatusCode, header};
4-use rand::Rng;
54 use serde_json::json;
65
76 use super::{
@@ -361,7 +360,13 @@ impl Run {
361360 self.friction = (self.friction + friction).min(FRICTION_MAX);
362361 if self.friction >= FRICTION_BURN_AT
363362 && !self.conditions.friction_burn
364- && self.chance(FRICTION_BURN)
363+ && self.chance(
364+ self.decision(
365+ "friction-burn",
366+ "With the skin this raw, has it finally burned?",
367+ ),
368+ FRICTION_BURN,
369+ )
365370 {
366371 self.conditions.friction_burn = true;
367372 }
@@ -385,7 +390,14 @@ impl Run {
385390 } else {
386391 0
387392 };
388- let wobble = self.rng.random_range(-EQ_WOBBLE..=EQ_WOBBLE);
393+ let wobble = self.oracle.span_int(
394+ self.decision(
395+ "wobble",
396+ "How does the count drift on this stroke, up or down?",
397+ ),
398+ -EQ_WOBBLE,
399+ EQ_WOBBLE,
400+ );
389401 self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
390402
391403 let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
@@ -402,7 +414,10 @@ impl Run {
402414
403415 if intensity >= PULLED_MUSCLE_INTENSITY
404416 && !self.conditions.pulled_muscle
405- && self.chance(PULLED_MUSCLE)
417+ && self.oracle.chance_not(
418+ self.decision("pulled-muscle", "Do the muscles hold?"),
419+ PULLED_MUSCLE,
420+ )
406421 {
407422 self.conditions.pulled_muscle = true;
408423 self.stamina = self.stamina.min(self.max_stamina());
@@ -414,15 +429,32 @@ impl Run {
414429 pub(super) fn settle(&mut self, was_below: bool) {
415430 self.maybe_summon_mum(was_below);
416431 if let Some(observer) = self.floor.observer()
417- && self.chance(OBSERVED)
432+ && self.chance(
433+ self.decision("observed", "Has someone quietly noticed what is going on?"),
434+ OBSERVED,
435+ )
418436 {
419437 self.observed = Some(observer);
420438 self.shame += 1;
421- if observer == Observer::Cat && self.chance(CAT_TAKES_TOWEL) {
439+ if observer == Observer::Cat
440+ && self.chance(
441+ self.decision("cat-takes-towel", "Has the cat taken the towel?"),
442+ CAT_TAKES_TOWEL,
443+ )
444+ {
422445 self.here.towel_stolen = true;
423446 }
424447 }
425- if self.items.ring && self.eq > HOT_EQ && self.chance(RING_PRIAPISM) {
448+ if self.items.ring
449+ && self.eq > HOT_EQ
450+ && self.chance(
451+ self.decision(
452+ "ring-priapism",
453+ "With the ring still on and everything this warm, will it not go down?",
454+ ),
455+ RING_PRIAPISM,
456+ )
457+ {
426458 self.conditions.priapism_turns = PRIAPISM_TURNS;
427459 }
428460 if self.conditions.priapism_turns > 0 {
@@ -453,7 +485,10 @@ impl Run {
453485 "The cage is locked. The key is not in the box.",
454486 ));
455487 }
456- if self.chance(RUPTURE_HOLD) {
488+ if self.oracle.chance_not(
489+ self.decision("rupture", "The pause is uneventful"),
490+ RUPTURE_HOLD,
491+ ) {
457492 return Err(self.rupture());
458493 }
459494 self.touch();
@@ -462,7 +497,13 @@ impl Run {
462497 self.end_kitchen_visit();
463498 self.realise();
464499 let was_below = self.suspicion < SUSPICION_LIMIT;
465- if self.chance(REFLECTION) {
500+ if self.chance(
501+ self.decision(
502+ "reflection",
503+ "In the quiet afterwards, have you caught your own eye in the mirror?",
504+ ),
505+ REFLECTION,
506+ ) {
466507 self.eq = 0;
467508 self.shame += 1;
468509 self.notices
@@ -507,7 +548,16 @@ impl Run {
507548 if self.here.brother_told {
508549 visits *= 2;
509550 }
510- if summoned || self.rng.random_ratio(visits, super::balance::MUM_VISIT) {
551+ if summoned
552+ || self.oracle.ratio(
553+ self.decision(
554+ "mum-summon",
555+ "With this much noise in the house, is Mum on her way up?",
556+ ),
557+ visits,
558+ super::balance::MUM_VISIT,
559+ )
560+ {
511561 let errand = self.errand(summoned);
512562 let nonce = self.fresh_nonce();
513563 self.mum = Some(MumVisit {
@@ -526,7 +576,14 @@ impl Run {
526576 lube: Option<Lube>,
527577 ) -> Result<Outcome, Problem> {
528578 let profile = training.profile();
529- let natural: i32 = self.rng.random_range(1..=20);
579+ let natural: i32 = self.oracle.span_int(
580+ self.decision(
581+ "natural",
582+ "What does the twenty-sided die show for this technique?",
583+ ),
584+ 1,
585+ 20,
586+ );
530587 let band = if EQ_BAND.contains(&self.eq) {
531588 BAND_BONUS
532589 } else {
@@ -607,7 +664,11 @@ impl Run {
607664 }),
608665 )
609666 } else {
610- let eta = self.rng.random_range(1..=4);
667+ let eta = self.oracle.span_int(
668+ self.decision("eta", "In how many turns will the queued gains arrive?"),
669+ 1,
670+ 4,
671+ ) as u32;
611672 self.pending.push(Pending {
612673 due: self.turn + eta,
613674 length,
Modified file: src/game/utility.rs+16 additions−2 deletions
@@ -19,7 +19,15 @@ const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
1919
2020 impl Run {
2121 pub(super) fn warm_up(&mut self) -> Result<Outcome, Problem> {
22- if self.floor == Floor::Hotel && self.chance(HOTEL_STRESS) {
22+ if self.floor == Floor::Hotel
23+ && self.chance(
24+ self.decision(
25+ "hotel-stress",
26+ "Has the hotel's plumbing given up for the week?",
27+ ),
28+ HOTEL_STRESS,
29+ )
30+ {
2331 return Err(Problem::new(
2432 StatusCode::SERVICE_UNAVAILABLE,
2533 "service-unavailable",
@@ -99,7 +107,13 @@ impl Run {
99107 }
100108 self.items.supplements -= 1;
101109 self.touch();
102- if self.chance(SUPPLEMENT_WORKS) {
110+ if self.chance(
111+ self.decision(
112+ "supplement",
113+ "Does the supplement from Pump-Bro actually work?",
114+ ),
115+ SUPPLEMENT_WORKS,
116+ ) {
103117 self.stamina = self.max_stamina();
104118 self.conditions.priapism_turns = PRIAPISM_TURNS;
105119 self.eq = self.eq.max(PRIAPISM_EQ);
Modified file: src/http/meta.rs+6 additions−1 deletions
@@ -84,7 +84,12 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
8484 }
8585 };
8686 let public_id = format!("{:08x}", rand::rng().random::<u32>());
87- let mut run = Run::new(id.clone(), public_id, config.secret.mix_seed(seed));
87+ let mut run = Run::with_oracle(
88+ id.clone(),
89+ public_id,
90+ config.secret.mix_seed(seed),
91+ &config.oracle,
92+ );
8893 run.seeded = seeded;
8994 run.refractory = config.refractory;
9095 run.last_seen = ctx.now;