an http based roguelike rpg about jelqing
Commit 3f9091557ec3
tarball14 files changed+933-55
@@ -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"@@ -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
@@ -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 ++ $candidate63
+ mut n = 064
+ for row in $rows {65
+ $n += 166
+ let key = $"q($n)"67
+ let body = {68
+ state: $row.state69
+ model: "clm-latest"70
+ questions: { ($key): {type: "noul", instructions: $"($row.ask), with odds of ($row.odds)."} }71
+ temperature: 1.072
+ }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
+}@@ -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
..defaults3646
}3747
}@@ -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::Bedroom140
+ && 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);@@ -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,@@ -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::Dry43
+ && intensity >= DEGLOVING_INTENSITY44
+ && 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 - burn58
+ && 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.blinds67
+ && 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::ChangingRoom83
+ && 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.peyronie96
+ && 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
}@@ -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_ERRANDS9498
};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.@@ -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_LINE61
+ && 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(@@ -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 stamped252
+ /// 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.turn276
+ ));277
+ s.push_str(&format!(278
+ "eq: {}\nstamina: {}\nfriction: {}\n",279
+ self.eq, self.stamina, self.friction280
+ ));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
+ s217308
}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 {@@ -0,0 +1,233 @@
1
+//! Where the game's decisions come from. The seeded rng is the default; an2
+//! operator can instead point the server at a System One service and let the3
+//! 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, the12
+/// 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.564
+ }65
+ }66
+ }67
+68
+ /// A yes/no game roll where the ask describes the calm outcome, not the69
+ /// event. The model believes dull statements more readily than rare70
+ /// 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.578
+ }79
+ }80
+ }81
+82
+ /// A yes/no game roll with general odds of `num` in `den`, put to the83
+ /// model as a choice between the calm answer and the event. The noul head84
+ /// answers interrupt questions with yes; the choice head, offered the two85
+ /// 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 == 199
+ }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.5110
+ }111
+ }112
+ }113
+114
+ /// One bucket chosen by weighted draw. The rng arm draws by `weights` and115
+ /// ignores the descriptions; the clm arm reads them all and picks the one116
+ /// 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() - 1129
+ }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 from135
+ /// 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 f64152
+ }153
+ }154
+ }155
+156
+ /// An integer in `lo..=hi`. The rng arm draws uniformly; the clm arm picks157
+ /// 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 i32167
+ }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, which183
+ /// surfaces as a 500 through the catch_panic layer. A dead oracle must not184
+ /// 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 = self193
+ .agent194
+ .post(&self.url)195
+ .send_json(&body)196
+ .unwrap_or_else(|e| panic!("oracle unreachable at {}: {e}", self.url));197
+ let answer: Value = response198
+ .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> = options211
+ .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
+ choice230
+ .parse::<usize>()231
+ .unwrap_or_else(|_| panic!("oracle chose {choice:?} for {site}, not an option index"))232
+ }233
+}@@ -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_AT363362
&& !self.conditions.friction_burn364
- && 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
0387392
};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_INTENSITY404416
&& !self.conditions.pulled_muscle405
- && 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::Cat440
+ && 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.ring449
+ && self.eq > HOT_EQ450
+ && 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 summoned552
+ || 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_BONUS532589
} 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,@@ -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::Hotel23
+ && 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);@@ -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;