atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 8b8e83329a1d

tarball

atagen <boss@atagen.co>2026-10-12 13:18 UTC

74 files changed+6356-5352
game, http: regroup the run, type the strings, and stop blocking the runtime

Run carried forty loose fields that every module and the HTTP layer
reached into, so it is now body, standing, household, online and
identity, with the HTTP side reading gauges and reach instead of
internals. state.rs, turn.rs and http/runs.rs each did several jobs and
are split by job, statuses, causes, notices, excuses, branches and patch
ops are enums, and the turn tick lives in begin_turn. The dice no longer
pay for the model's state prose on every roll.

SQLite and the model both block, so every request runs on the blocking
pool, idle runs are swept on a timer instead of scanned on every POST
/runs, the listings page by fifty, records keep their first timestamp
under a user_version schema, and a run whose lock a panic poisoned is
buried as lost instead of resumed half-applied. The tests are split by
area, the ones that could not fail are gone, and the endings, Mum's
refusals, the Elder's ban and the records now have coverage.
Modified file: Cargo.toml+1 additions−1 deletions
@@ -18,7 +18,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
1818 serde = { version = "1", features = ["derive"] }
1919 serde_json = "1"
2020 sha2 = "0.10"
21-tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }
21+tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
2222 tower = { version = "0.5", features = ["util"] }
2323 tower-http = { version = "0.6", features = ["catch-panic"] }
2424 ureq = { version = "2", default-features = false, features = ["json"] }
Modified file: src/config.rs+12 additions−5 deletions
@@ -26,17 +26,24 @@ impl Default for Config {
2626 }
2727 }
2828
29+/// The XDG state directory, so `nix run` from anywhere finds the same graveyard.
30+fn default_db() -> PathBuf {
31+ let state = std::env::var_os("XDG_STATE_HOME")
32+ .map(PathBuf::from)
33+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")));
34+ match state {
35+ Some(dir) => dir.join("jelqd").join("jelqd.sqlite"),
36+ None => PathBuf::from("jelqd.sqlite"),
37+ }
38+}
39+
2940 impl Config {
3041 pub fn from_env() -> Self {
3142 let var = |name| std::env::var(name).ok();
3243 let defaults = Self::default();
3344 Self {
3445 addr: var("JELQD_ADDR").unwrap_or(defaults.addr),
35- db: Some(
36- var("JELQD_DB")
37- .unwrap_or_else(|| "jelqd.sqlite".into())
38- .into(),
39- ),
46+ db: Some(var("JELQD_DB").map_or_else(default_db, PathBuf::from)),
4047 secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),
4148 oracle: match var("JELQD_ORACLE").as_deref() {
4249 Some("clm") => OracleTemplate::Clm {
Deleted file: src/game/balance.rs−113 deletions
@@ -1,113 +0,0 @@
1-//! Every tunable number in the game. Odds are written as "1 in N".
2-
3-use std::ops::RangeInclusive;
4-
5-pub const MAX_STAMINA: i32 = 10;
6-pub const PULLED_STAMINA: i32 = 7;
7-pub const STARTING_EQ: i32 = 20;
8-pub const STARTING_CASH: u32 = 20;
9-pub const STARTING_ICE_PACKS: u32 = 1;
10-pub const SUSPICION_LIMIT: u8 = 5;
11-pub const PATIENCE: u8 = 3;
12-
13-pub const FLOOR_TURNS: u32 = 6;
14-pub const FLOOR_ALLOWANCE: u32 = 10;
15-pub const BRANCH_TURN: u32 = 10;
16-
17-pub const EQ_BAND: RangeInclusive<i32> = 50..=70;
18-pub const BAND_BONUS: i32 = 2;
19-pub const COLD_EQ: i32 = 30;
20-pub const DANGER_EQ: i32 = 70;
21-pub const HOT_EQ: i32 = 85;
22-pub const RUPTURE_LINE: i32 = 90;
23-pub const RING_RUPTURE_LINE: i32 = 80;
24-pub const PREMATURE_EQ: i32 = 95;
25-pub const EQ_WOBBLE: i32 = 3;
26-pub const CHANGING_ROOM_CHILL: i32 = 8;
27-
28-pub const WARMUP_EQ: i32 = 25;
29-pub const SHOWER_EQ: i32 = 35;
30-pub const TOWELLESS_EQ: i32 = 10;
31-pub const ICE_EQ: i32 = 40;
32-pub const EDGE_EQ: i32 = 11;
33-pub const EDGE_LOOP: u8 = 3;
34-pub const PRIAPISM_EQ: i32 = 75;
35-pub const PRIAPISM_TURNS: u8 = 5;
36-
37-pub const SELF_REPORT_INFLATION: f64 = 1.5;
38-pub const MM_PER_INCH: f64 = 25.4;
39-pub const HONEST_TOLERANCE: f64 = 0.06;
40-pub const EXAGGERATION: f64 = 1.0;
41-/// Girth plus a quarter of the length is what the jeans have to hold alongside the extender.
42-pub const STORAGE_BULK: f64 = 6.5;
43-pub const GIRTH_RESPECTED: f64 = 5.0;
44-pub const GIRTH_THICK: f64 = 5.5;
45-pub const FORESKIN_GLIDE: f64 = 1.0;
46-pub const LOCKER_ROOM_BAR: f64 = 8.0;
47-pub const LOCKER_ROOM_SHAME: u32 = 3;
48-pub const PUT_GAIN: f64 = 0.01;
49-pub const PUMP_GAIN: f64 = 0.5;
50-pub const EXTENDER_GAIN: f64 = 0.004;
51-pub const EDGE_GIRTH: f64 = 0.004;
52-
53-pub const RUPTURE_IN_BAND: u32 = 250;
54-pub const RUPTURE_WARM: u32 = 25;
55-pub const RUPTURE_HOT: u32 = 10;
56-pub const RUPTURE_PREMATURE: u32 = 3;
57-pub const ZIP: u32 = 200;
58-pub const RULER_IN_DRAWER: u32 = 2;
59-pub const KEY_IN_DRAWER: u32 = 3;
60-pub const GREGS_LOCKER: u32 = 20;
61-pub const DEGLOVING: u32 = 60;
62-pub const DEGLOVING_INTENSITY: i32 = 8;
63-pub const WINDOW: u32 = 60;
64-pub const PULLED_MUSCLE: u32 = 12;
65-pub const PULLED_MUSCLE_INTENSITY: i32 = 9;
66-pub const RING_PRIAPISM: u32 = 10;
67-pub const RING_TIGHT_PRIAPISM: u32 = 4;
68-pub const THIN_MARKUP: u32 = 3;
69-pub const SUPPLEMENT_WORKS: u32 = 5;
70-pub const RUPTURE_HOLD: u32 = 250;
71-pub const ULI_ODDS: u32 = 4;
72-pub const MEDITATION_EQ: i32 = 10;
73-pub const HOLD_EQ: i32 = 15;
74-pub const REFLECTION: u32 = 12;
75-pub const ARRIVAL_BONUS: i32 = 1;
76-pub const GREED_WARM: f64 = 1.5;
77-pub const GREED_HOT: f64 = 2.0;
78-pub const COCONUT_ODDS: u32 = 3;
79-pub const ENERGY_ODDS: u32 = 5;
80-pub const KITCHEN_ICE_ODDS: u32 = 8;
81-pub const KETTLE_ODDS: u32 = 6;
82-pub const ULI_READS: u8 = 2;
83-pub const DRAWER_JAMS: u32 = 4;
84-/// A telling is suspicion + 1 in this many, so a quiet house tolerates a raid and a noisy one does not.
85-pub const KITCHEN_TOLD: u32 = 8;
86-pub const RAID_NOISE: i32 = 1;
87-pub const PATCH_EQ: i32 = 3;
88-pub const KITCHEN_TELLINGS: u8 = 2;
89-pub const WATERMELON: (u32, u32) = (2, 5);
90-/// Caught with the melon is suspicion + 2 in this many.
91-pub const CAUGHT_MELONED: u32 = 9;
92-pub const MELON_SHAME: u32 = 5;
93-pub const ENERGY_STAMINA: i32 = 3;
94-pub const FRICTION_MAX: i32 = 10;
95-pub const FRICTION_DRY: i32 = 2;
96-pub const FRICTION_SPIT: i32 = 1;
97-pub const FRICTION_GRIP: i32 = 1;
98-pub const FRICTION_HOLD: i32 = 2;
99-pub const FRICTION_MEDITATE: i32 = 2;
100-pub const FRICTION_FLOOR: i32 = 1;
101-pub const FRICTION_BURN_AT: i32 = 6;
102-pub const FRICTION_BURN: u32 = 10;
103-pub const BURN_RUPTURE: i32 = 15;
104-pub const OBSERVED: u32 = 6;
105-pub const CAT_TAKES_TOWEL: u32 = 5;
106-pub const FRIEND: u32 = 10;
107-pub const CURVE_MISSES: u32 = 3;
108-pub const HOTEL_STRESS: u32 = 3;
109-pub const VIRAL_ERRAND: u32 = 2;
110-
111-/// Visits are (suspicion + 1) in N per turn.
112-pub const MUM_VISIT: u32 = 16;
113-pub const WANDERER_VISIT: u32 = 32;
Added file: src/game/balance/body.rs+18 additions
@@ -0,0 +1,18 @@
1+pub const SELF_REPORT_INFLATION: f64 = 1.5;
2+pub const MM_PER_INCH: f64 = 25.4;
3+pub const HONEST_TOLERANCE: f64 = 0.06;
4+pub const EXAGGERATION: f64 = 1.0;
5+/// Girth plus a quarter of the length is what the jeans have to hold alongside the extender.
6+pub const STORAGE_BULK: f64 = 6.5;
7+pub const GIRTH_RESPECTED: f64 = 5.0;
8+pub const GIRTH_THICK: f64 = 5.5;
9+pub const FORESKIN_GLIDE: f64 = 1.0;
10+pub const LOCKER_ROOM_BAR: f64 = 8.0;
11+pub const LOCKER_ROOM_SHAME: u32 = 3;
12+pub const PUT_GAIN: f64 = 0.01;
13+pub const PUMP_GAIN: f64 = 0.5;
14+pub const EXTENDER_GAIN: f64 = 0.004;
15+pub const EDGE_GIRTH: f64 = 0.004;
16+pub const QUEUED_ETA: std::ops::RangeInclusive<i32> = 1..=4;
17+pub const LOCKER_ROOM_OTHERS: [(&str, f64); 3] =
18+ [("Greg", 9.5), ("Dmitri", 8.75), ("a man in a towel", 11.0)];
Added file: src/game/balance/economy.rs+6 additions
@@ -0,0 +1,6 @@
1+pub const STARTING_CASH: u32 = 20;
2+pub const STARTING_ICE_PACKS: u32 = 1;
3+pub const FLOOR_ALLOWANCE: u32 = 10;
4+pub const THIN_MARKUP: u32 = 3;
5+/// The footage gains this many views a second, starting from one.
6+pub const VIEWS_PER_SECOND: u64 = 37;
Added file: src/game/balance/eq.rs+36 additions
@@ -0,0 +1,36 @@
1+use std::ops::RangeInclusive;
2+
3+pub const STARTING_EQ: i32 = 20;
4+pub const EQ_BAND: RangeInclusive<i32> = 50..=70;
5+pub const BAND_BONUS: i32 = 2;
6+pub const COLD_EQ: i32 = 30;
7+pub const DANGER_EQ: i32 = 70;
8+pub const HOT_EQ: i32 = 85;
9+pub const RUPTURE_LINE: i32 = 90;
10+pub const RING_RUPTURE_LINE: i32 = 80;
11+pub const PREMATURE_EQ: i32 = 95;
12+pub const EQ_WOBBLE: i32 = 3;
13+pub const CHANGING_ROOM_CHILL: i32 = 8;
14+pub const WARMUP_EQ: i32 = 25;
15+pub const SHOWER_EQ: i32 = 35;
16+pub const TOWELLESS_EQ: i32 = 10;
17+pub const ICE_EQ: i32 = 40;
18+pub const EDGE_EQ: i32 = 11;
19+pub const PRIAPISM_EQ: i32 = 75;
20+pub const PRIAPISM_TURNS: u8 = 5;
21+pub const MEDITATION_EQ: i32 = 10;
22+pub const HOLD_EQ: i32 = 15;
23+pub const ARRIVAL_BONUS: i32 = 1;
24+pub const GREED_WARM: f64 = 1.5;
25+pub const GREED_HOT: f64 = 2.0;
26+pub const PATCH_EQ: i32 = 3;
27+pub const STROKE_EQ: i32 = 2;
28+pub const STROKE_EQ_COST: i32 = 6;
29+pub const SOFT_INTENSITY: i32 = 2;
30+pub const LOST_IT_EQ: i32 = 10;
31+/// A roll this far under the DC is a 510 rather than a 204.
32+pub const NEAR_MISS: i32 = 3;
33+/// A roll this far over the DC lands its gains at once rather than queueing them.
34+pub const CLEAN_HIT: i32 = 5;
35+pub const UNDERHAND_STRETCH: i32 = 2;
36+pub const UNDERHAND_WET: i32 = -1;
Added file: src/game/balance/floors.rs+13 additions
@@ -0,0 +1,13 @@
1+pub const FLOOR_TURNS: u32 = 6;
2+pub const BRANCH_TURN: u32 = 10;
3+pub const ZIP: u32 = 200;
4+pub const RULER_IN_DRAWER: u32 = 2;
5+pub const KEY_IN_DRAWER: u32 = 3;
6+pub const GREGS_LOCKER: u32 = 20;
7+pub const ULI_ODDS: u32 = 4;
8+pub const ULI_READS: u8 = 2;
9+pub const DRAWER_JAMS: u32 = 4;
10+pub const OBSERVED: u32 = 6;
11+pub const CAT_TAKES_TOWEL: u32 = 5;
12+pub const HOTEL_STRESS: u32 = 3;
13+pub const BATHROOM_VASELINE: u32 = 2;
Added file: src/game/balance/friction.rs+11 additions
@@ -0,0 +1,11 @@
1+pub const FRICTION_MAX: i32 = 10;
2+pub const FRICTION_DRY: i32 = 2;
3+pub const FRICTION_SPIT: i32 = 1;
4+pub const FRICTION_GRIP: i32 = 1;
5+pub const FRICTION_HOLD: i32 = 2;
6+pub const FRICTION_MEDITATE: i32 = 2;
7+pub const FRICTION_FLOOR: i32 = 1;
8+pub const FRICTION_BURN_AT: i32 = 6;
9+pub const FRICTION_BURN: u32 = 10;
10+/// Extra friction by intensity, from 1 to 10.
11+pub const VIGOUR: [i32; 10] = [0, 0, 0, 0, 0, 1, 1, 1, 2, 2];
Added file: src/game/balance/household.rs+6 additions
@@ -0,0 +1,6 @@
1+pub const SUSPICION_LIMIT: u8 = 5;
2+pub const PATIENCE: u8 = 3;
3+pub const VIRAL_ERRAND: u32 = 2;
4+/// Visits are (suspicion + 1) in N per turn.
5+pub const MUM_VISIT: u32 = 16;
6+pub const WANDERER_VISIT: u32 = 32;
Added file: src/game/balance/kitchen.rs+12 additions
@@ -0,0 +1,12 @@
1+pub const COCONUT_ODDS: u32 = 3;
2+pub const ENERGY_ODDS: u32 = 5;
3+pub const KITCHEN_ICE_ODDS: u32 = 8;
4+pub const KETTLE_ODDS: u32 = 6;
5+/// A telling is suspicion + 1 in this many, so a quiet house tolerates a raid and a noisy one does not.
6+pub const KITCHEN_TOLD: u32 = 8;
7+pub const RAID_NOISE: i32 = 1;
8+pub const KITCHEN_TELLINGS: u8 = 2;
9+pub const WATERMELON: (u32, u32) = (2, 5);
10+/// Caught with the melon is suspicion + 2 in this many.
11+pub const CAUGHT_MELONED: u32 = 9;
12+pub const MELON_SHAME: u32 = 5;
Added file: src/game/balance/mishaps.rs+17 additions
@@ -0,0 +1,17 @@
1+pub const RUPTURE_IN_BAND: u32 = 250;
2+pub const RUPTURE_WARM: u32 = 25;
3+pub const RUPTURE_HOT: u32 = 10;
4+pub const RUPTURE_PREMATURE: u32 = 3;
5+pub const DEGLOVING: u32 = 60;
6+pub const DEGLOVING_INTENSITY: i32 = 8;
7+pub const WINDOW: u32 = 60;
8+pub const PULLED_MUSCLE: u32 = 12;
9+pub const PULLED_MUSCLE_INTENSITY: i32 = 9;
10+pub const RING_PRIAPISM: u32 = 10;
11+pub const RING_TIGHT_PRIAPISM: u32 = 4;
12+pub const SUPPLEMENT_WORKS: u32 = 5;
13+pub const RUPTURE_HOLD: u32 = 250;
14+pub const REFLECTION: u32 = 12;
15+pub const BURN_RUPTURE: i32 = 15;
16+pub const FRIEND: u32 = 10;
17+pub const CURVE_MISSES: u32 = 3;
Added file: src/game/balance/mod.rs+21 additions
@@ -0,0 +1,21 @@
1+//! Every tunable number in the game. Odds are written as "1 in N".
2+
3+mod body;
4+mod economy;
5+mod eq;
6+mod floors;
7+mod friction;
8+mod household;
9+mod kitchen;
10+mod mishaps;
11+mod stamina;
12+
13+pub use body::*;
14+pub use economy::*;
15+pub use eq::*;
16+pub use floors::*;
17+pub use friction::*;
18+pub use household::*;
19+pub use kitchen::*;
20+pub use mishaps::*;
21+pub use stamina::*;
Added file: src/game/balance/stamina.rs+4 additions
@@ -0,0 +1,4 @@
1+pub const MAX_STAMINA: i32 = 10;
2+pub const PULLED_STAMINA: i32 = 7;
3+pub const EDGE_LOOP: u8 = 3;
4+pub const ENERGY_STAMINA: i32 = 3;
Modified file: src/game/body.rs+81 additions−87 deletions
@@ -1,96 +1,95 @@
11 use axum::http::StatusCode;
2-use serde::Deserialize;
3-use serde_json::{Value, json};
42
53 use super::{
6- Grip, Outcome, Run,
7- balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PATCH_EQ, PUT_GAIN},
8- etag_matches, honest,
4+ Outcome, Run,
5+ balance::{GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, MAX_STAMINA, STARTING_EQ},
6+ credentials::etag_matches,
97 };
108 use crate::problem::Problem;
119
12-#[derive(Deserialize)]
13-pub struct PatchOp {
14- pub op: String,
15- pub path: String,
16- #[serde(default)]
17- pub value: Value,
10+pub struct Body {
11+ pub(crate) length: f64,
12+ pub(crate) girth: f64,
13+ pub(crate) foreskin: Option<f64>,
14+ pub(crate) eq: i32,
15+ pub(crate) stamina: i32,
16+ pub(crate) friction: i32,
17+ pub(crate) grip: Grip,
18+ /// The grip of the last stroke, which decides whether this one gets the switching discount.
19+ pub(crate) jelq_grip: Option<Grip>,
20+ pub(crate) warmed_up: bool,
21+ pub(crate) exhausted: bool,
22+ pub(crate) put_applied: bool,
23+ pub(crate) conditions: Conditions,
24+ pub(crate) pending: Vec<Pending>,
1825 }
1926
20-pub(super) fn exaggeration() -> Problem {
21- Problem::new(
22- StatusCode::BAD_REQUEST,
23- "exaggeration",
24- "The server has measured. Lying is not a technique.",
25- )
27+impl Body {
28+ pub fn new(length: f64, girth: f64, foreskin: f64) -> Self {
29+ Self {
30+ length,
31+ girth,
32+ foreskin: Some(foreskin),
33+ eq: STARTING_EQ,
34+ stamina: MAX_STAMINA,
35+ friction: 0,
36+ grip: Grip::Overhand,
37+ jelq_grip: None,
38+ warmed_up: false,
39+ exhausted: false,
40+ put_applied: false,
41+ conditions: Conditions::default(),
42+ pending: Vec::new(),
43+ }
44+ }
2645 }
2746
28-impl Run {
29- pub fn put(&mut self) -> Outcome {
30- if self.put_applied {
31- return Outcome::empty(StatusCode::NO_CONTENT);
47+#[derive(Default)]
48+pub struct Conditions {
49+ pub(crate) peyronie: bool,
50+ pub(crate) pulled_muscle: bool,
51+ pub(crate) priapism_turns: u8,
52+ pub(crate) friction_burn: bool,
53+}
54+
55+/// Gains a `202` promised, due on a later turn.
56+pub struct Pending {
57+ pub(crate) due: u32,
58+ pub(crate) length: f64,
59+ pub(crate) girth: f64,
60+ pub(crate) foreskin: f64,
61+}
62+
63+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
64+pub enum Grip {
65+ Overhand,
66+ Underhand,
67+}
68+
69+impl Grip {
70+ pub fn name(self) -> &'static str {
71+ match self {
72+ Self::Overhand => "overhand",
73+ Self::Underhand => "underhand",
3274 }
33- self.put_applied = true;
34- self.length += PUT_GAIN;
35- self.touch();
36- Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
3775 }
76+}
3877
39- /// Being measured is not arousing, so every PATCH costs a little EQ whatever it finds.
40- pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
41- self.reset_eq(self.eq - PATCH_EQ);
42- self.touch();
43- for op in ops {
44- if op.op != "test" {
45- return Err(Problem::new(
46- StatusCode::UNPROCESSABLE_ENTITY,
47- "wishful-thinking",
48- "The server accepts only the truth.",
49- )
50- .with("op", op.op.clone()));
51- }
52- let actual = match op.path.as_str() {
53- "/length" => self.current_length(),
54- "/girth" => self.girth,
55- "/foreskin" => self.foreskin.unwrap_or(0.0),
56- _ => {
57- return Err(Problem::new(
58- StatusCode::UNPROCESSABLE_ENTITY,
59- "no-such-measurement",
60- "There is no such measurement.",
61- )
62- .with("path", op.path.clone()));
63- }
64- };
65- let Some(claimed) = op.value.as_f64() else {
66- return Err(Problem::new(
67- StatusCode::BAD_REQUEST,
68- "malformed-patch",
69- "A test requires a numeric value.",
70- ));
71- };
72- if op.path == "/length" && claimed >= actual + EXAGGERATION {
73- self.shame += 2;
74- self.touch();
75- return Err(exaggeration());
76- }
77- if !honest(claimed, actual) {
78- self.shame += 1;
79- self.touch();
80- return Err(
81- Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
82- .with("path", op.path.clone()),
83- );
84- }
78+impl std::str::FromStr for Grip {
79+ type Err = ();
80+
81+ fn from_str(protocol: &str) -> Result<Self, ()> {
82+ match protocol.to_ascii_lowercase().as_str() {
83+ "overhand" => Ok(Self::Overhand),
84+ "underhand" => Ok(Self::Underhand),
85+ _ => Err(()),
8586 }
86- Ok(Outcome::json(
87- StatusCode::OK,
88- json!({ "detail": "Correct." }),
89- ))
9087 }
88+}
9189
90+impl Run {
9291 pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
93- if self.foreskin.is_none() {
92+ if self.body.foreskin.is_none() {
9493 return Err(Problem::new(
9594 StatusCode::GONE,
9695 "foreskin-gone",
@@ -111,27 +110,27 @@ impl Run {
111110 "The foreskin has changed since you last looked at it.",
112111 ));
113112 }
114- self.foreskin = None;
115- self.shame += 20;
113+ self.body.foreskin = None;
114+ self.standing.shame += 20;
116115 self.touch();
117116 Ok(Outcome::empty(StatusCode::NO_CONTENT))
118117 }
119118
120119 pub fn set_grip(&mut self, grip: Grip) -> Result<(), Problem> {
121120 self.grip_closes(grip)?;
122- self.grip = grip;
121+ self.body.grip = grip;
123122 self.touch();
124123 Ok(())
125124 }
126125
127126 pub(super) fn grip_closes(&self, grip: Grip) -> Result<(), Problem> {
128- if grip == Grip::Underhand && self.girth > GIRTH_THICK {
127+ if grip == Grip::Underhand && self.body.girth > GIRTH_THICK {
129128 return Err(Problem::new(
130129 StatusCode::PAYLOAD_TOO_LARGE,
131130 "grip-too-small",
132131 format!(
133132 "The underhand grip no longer closes around {:.2} inches of girth.",
134- self.girth
133+ self.body.girth
135134 ),
136135 )
137136 .with("maximum", GIRTH_THICK));
@@ -145,11 +144,6 @@ impl Run {
145144 }
146145
147146 pub fn girth_respected(&self) -> bool {
148- self.girth >= GIRTH_RESPECTED
147+ self.body.girth >= GIRTH_RESPECTED
149148 }
150149 }
151-
152-/// A cylinder whose circumference is the girth.
153-pub fn volume(length: f64, girth: f64) -> f64 {
154- girth * girth * length / (4.0 * std::f64::consts::PI)
155-}
Added file: src/game/cause.rs+134 additions
@@ -0,0 +1,134 @@
1+use axum::http::StatusCode;
2+
3+use crate::problem::Problem;
4+
5+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
6+pub enum Cause {
7+ Ruptured { ice_unused: bool },
8+ Caught,
9+ Opened,
10+ Banned,
11+ Degloved,
12+ Zip,
13+ Meloned,
14+ Abandoned,
15+ Lost,
16+}
17+
18+impl Cause {
19+ pub const ALL: [Self; 10] = [
20+ Self::Ruptured { ice_unused: true },
21+ Self::Ruptured { ice_unused: false },
22+ Self::Caught,
23+ Self::Opened,
24+ Self::Banned,
25+ Self::Degloved,
26+ Self::Zip,
27+ Self::Meloned,
28+ Self::Abandoned,
29+ Self::Lost,
30+ ];
31+
32+ pub fn slug(self) -> &'static str {
33+ match self {
34+ Self::Ruptured { .. } => "vascular-event",
35+ Self::Caught => "caught",
36+ Self::Opened => "opened",
37+ Self::Banned => "banned",
38+ Self::Degloved => "degloved",
39+ Self::Zip => "zip",
40+ Self::Meloned => "watermelon",
41+ Self::Abandoned => "abandoned",
42+ Self::Lost => "lost",
43+ }
44+ }
45+
46+ pub fn detail(self) -> &'static str {
47+ match self {
48+ Self::Ruptured { ice_unused: true } => {
49+ "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
50+ }
51+ Self::Ruptured { ice_unused: false } => {
52+ "A vessel has exceeded its rated pressure. The run has ended."
53+ }
54+ Self::Caught => {
55+ "Mum has seen enough. The run has ended. Nobody will mention it at dinner."
56+ }
57+ Self::Opened => {
58+ "Mum has stopped knocking and opened the door. The run has ended. Nobody will mention it at dinner."
59+ }
60+ Self::Banned => "Your account has been banned. The forum can tell. The run has ended.",
61+ Self::Degloved => {
62+ "The outer layer has been removed in one piece. It will not be returning. The run has ended."
63+ }
64+ Self::Zip => {
65+ "The request conflicts with the current state of the zip. The run has ended."
66+ }
67+ Self::Meloned => {
68+ "You have been caught with the watermelon. Nobody will mention it at dinner. The run has ended."
69+ }
70+ Self::Abandoned => "The run was abandoned.",
71+ Self::Lost => {
72+ "The server fell over mid-stroke and the run went with it. Nobody will mention it at dinner."
73+ }
74+ }
75+ }
76+
77+ /// The records keep the slug and the ice, which is all a cause needs to be told again.
78+ pub fn parse(slug: &str, ice_unused: bool) -> Option<Self> {
79+ Self::ALL.into_iter().find(|cause| {
80+ cause.slug() == slug
81+ && !matches!(cause, Self::Ruptured { ice_unused: unused } if *unused != ice_unused)
82+ })
83+ }
84+
85+ fn status(self) -> StatusCode {
86+ match self {
87+ Self::Ruptured { .. } | Self::Lost => StatusCode::INTERNAL_SERVER_ERROR,
88+ Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned | Self::Meloned => {
89+ StatusCode::GONE
90+ }
91+ Self::Banned => StatusCode::FORBIDDEN,
92+ Self::Zip => StatusCode::CONFLICT,
93+ }
94+ }
95+
96+ pub fn problem(self) -> Problem {
97+ Problem::new(self.status(), self.slug(), self.detail())
98+ }
99+}
100+
101+pub struct Ending {
102+ pub(crate) cause: Cause,
103+ pub(crate) turn: u32,
104+}
105+
106+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
107+pub enum RunStatus {
108+ Alive,
109+ Prescribed,
110+ /// The true ending, the clinic's `304`.
111+ Normal,
112+ Gone,
113+}
114+
115+impl RunStatus {
116+ pub const ALL: [Self; 4] = [Self::Alive, Self::Prescribed, Self::Normal, Self::Gone];
117+
118+ pub fn name(self) -> &'static str {
119+ match self {
120+ Self::Alive => "alive",
121+ Self::Prescribed => "prescribed",
122+ Self::Normal => "normal",
123+ Self::Gone => "gone",
124+ }
125+ }
126+
127+ pub fn parse(name: &str) -> Option<Self> {
128+ Self::ALL.into_iter().find(|status| status.name() == name)
129+ }
130+
131+ pub fn finished(self) -> bool {
132+ self != Self::Alive
133+ }
134+}
Added file: src/game/checks.rs+162 additions
@@ -0,0 +1,162 @@
1+use super::{
2+ LUBRICANTS, Lube, Run, Technique,
3+ balance::STORAGE_BULK,
4+ lube::LubeRule,
5+ techniques::{Training, Utility},
6+};
7+use crate::problem::Problem;
8+use axum::http::{StatusCode, header};
9+use serde_json::json;
10+
11+impl Run {
12+ pub(super) fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
13+ self.access(technique)?;
14+ match technique {
15+ Technique::Training(training) => Self::check_lube(training, lube),
16+ Technique::Utility(_) => Ok(()),
17+ }
18+ }
19+
20+ pub(super) fn access(&self, technique: Technique) -> Result<(), Problem> {
21+ if technique == Technique::Training(Training::Uli) && !self.online.uli_taught {
22+ return Err(Problem::new(
23+ StatusCode::FORBIDDEN,
24+ "technique-locked",
25+ "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not found the reply.",
26+ ));
27+ }
28+ if !self.body.warmed_up {
29+ return Err(Problem::new(
30+ StatusCode::PRECONDITION_FAILED,
31+ "cold-start",
32+ "Warm-up was skipped. The server has noted this.",
33+ )
34+ .with("technique", Utility::Warmup.name()));
35+ }
36+ if matches!(technique, Technique::Training(_)) {
37+ self.grip_closes(self.body.grip)?;
38+ }
39+ match technique {
40+ Technique::Utility(Utility::Pump) if !self.items.kit.pump_pro => Err(Problem::new(
41+ StatusCode::FAILED_DEPENDENCY,
42+ "no-pump",
43+ "The pump technique depends on a pump.",
44+ )),
45+ Technique::Utility(_) => Ok(()),
46+ Technique::Training(training) => match training.profile().foreskin {
47+ Some((min, max)) => self.check_foreskin(min, max),
48+ None => Ok(()),
49+ },
50+ }
51+ }
52+
53+ pub(super) fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {
54+ match (training.profile().lube, lube) {
55+ (LubeRule::Required, None) => Err(Problem::new(
56+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
57+ "lubricant-required",
58+ format!(
59+ "The {} technique requires a lubricant. None was declared.",
60+ training.name()
61+ ),
62+ )
63+ .with("lubricants", json!(LUBRICANTS))),
64+ (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
65+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
66+ "lubricant-declared",
67+ format!(
68+ "The {} technique was requested. A lubricant was declared.",
69+ training.name()
70+ ),
71+ )),
72+ _ => Ok(()),
73+ }
74+ }
75+
76+ /// One unit of whatever was declared. Spit is free and the tap is always on.
77+ pub(super) fn lube_available(&self, lube: Option<Lube>) -> Result<(), Problem> {
78+ let Some(bottle) = lube.and_then(Lube::item) else {
79+ return Ok(());
80+ };
81+ if !self.items.has(bottle) {
82+ return Err(Problem::new(
83+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
84+ "lubricant-exhausted",
85+ format!("{} was declared, but the bottle is empty.", bottle.slug()),
86+ ));
87+ }
88+ Ok(())
89+ }
90+
91+ pub(super) fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
92+ self.lube_available(lube)?;
93+ if let Some(lube) = lube {
94+ self.items.lubes.take(lube);
95+ }
96+ Ok(())
97+ }
98+
99+ pub fn techniques_now(&self) -> impl Iterator<Item = Technique> + '_ {
100+ let blocked = !self.floor.timed() || self.items.kit.cage || self.body.exhausted;
101+ Technique::ALL
102+ .into_iter()
103+ .filter(move |_| !blocked)
104+ .filter(|&technique| match technique {
105+ Technique::Utility(Utility::Warmup) => {
106+ !self.body.warmed_up || self.here.kitchen.kettle_boiled
107+ }
108+ Technique::Utility(Utility::Ice) => self.items.stores.ice_packs > 0,
109+ Technique::Utility(Utility::Supplement) => self.items.stores.supplements > 0,
110+ Technique::Utility(Utility::Meditate) => !self.here.meditated,
111+ Technique::Utility(Utility::Energy) => self.items.stores.energy_drinks > 0,
112+ _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
113+ })
114+ }
115+
116+ pub(super) fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
117+ let Some(f) = self.body.foreskin else {
118+ if min <= 0.0 {
119+ return Ok(());
120+ }
121+ return Err(Problem::new(
122+ StatusCode::GONE,
123+ "foreskin-gone",
124+ "This technique requires a foreskin. The foreskin is gone.",
125+ )
126+ .header(header::CONTENT_LOCATION, self.path("/foreskin")));
127+ };
128+ if f < min {
129+ return Err(Problem::new(
130+ StatusCode::LENGTH_REQUIRED,
131+ "foreskin-insufficient",
132+ format!(
133+ "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
134+ ),
135+ )
136+ .with("required", min));
137+ }
138+ if f > max {
139+ return Err(Problem::new(
140+ StatusCode::PAYLOAD_TOO_LARGE,
141+ "foreskin-excessive",
142+ format!(
143+ "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
144+ ),
145+ )
146+ .with("maximum", max));
147+ }
148+ Ok(())
149+ }
150+
151+ pub(super) fn check_storage(&self) -> Result<(), Problem> {
152+ if self.items.kit.extender && self.body.girth + self.current_length() / 4.0 > STORAGE_BULK {
153+ return Err(Problem::new(
154+ StatusCode::INSUFFICIENT_STORAGE,
155+ "jeans-full",
156+ "The jeans are full. The extender is occupying the remaining storage.",
157+ )
158+ .with("extender", self.path("/inventory/extender")));
159+ }
160+ Ok(())
161+ }
162+}
Modified file: src/game/clinic.rs+23 additions−11 deletions
@@ -1,12 +1,24 @@
11 use axum::http::{StatusCode, header};
22 use serde_json::json;
33
4-use super::{Floor, Outcome, Run, body::volume, etag_matches, parse_credentials, round2};
4+use super::{
5+ Floor, Outcome, Run,
6+ credentials::{Scheme, etag_matches, parse_credentials},
7+ measure::{round2, volume},
8+};
59 use crate::{
610 headers::{SERVER_TIMING, X_DIAGNOSIS},
711 problem::Problem,
812 };
913
14+#[derive(Default)]
15+pub struct Clinic {
16+ pub(crate) intake_attempts: usize,
17+ pub(crate) measured: bool,
18+ pub(crate) prescribed: bool,
19+ pub(crate) cured: bool,
20+}
21+
1022 const INTAKE: [&str; 3] = [
1123 "The clinic does not accept excuses.",
1224 "The clinic accepts one token. You have not been carrying it.",
@@ -35,7 +47,7 @@ impl Run {
3547 let Some(authorization) = authorization else {
3648 return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
3749 };
38- let truthful = parse_credentials(authorization, "bearer")
50+ let truthful = parse_credentials(authorization, Scheme::Bearer)
3951 .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
4052 if !truthful {
4153 let hint = INTAKE[self.clinic.intake_attempts.min(INTAKE.len() - 1)];
@@ -48,8 +60,8 @@ impl Run {
4860 ));
4961 }
5062
51- self.conditions.peyronie = false;
52- self.conditions.priapism_turns = 0;
63+ self.body.conditions.peyronie = false;
64+ self.body.conditions.priapism_turns = 0;
5365 let etag = self.origin_etag();
5466 if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {
5567 self.clinic.cured = true;
@@ -59,15 +71,15 @@ impl Run {
5971 }
6072 if !self.clinic.measured {
6173 self.clinic.measured = true;
62- if !self.items.ruler {
63- self.shame += 15;
74+ if !self.items.kit.ruler {
75+ self.standing.shame += 15;
6476 }
6577 }
66- let volume = volume(self.length, self.girth);
67- let foreskin = self.foreskin.unwrap_or(0.0);
78+ let volume = volume(self.body.length, self.body.girth);
79+ let foreskin = self.body.foreskin.unwrap_or(0.0);
6880 let timing = format!(
6981 "length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",
70- self.length, self.girth
82+ self.body.length, self.body.girth
7183 );
7284 self.clinic.prescribed = true;
7385 self.touch();
@@ -76,8 +88,8 @@ impl Run {
7688 json!({
7789 "diagnosis": "within normal range",
7890 "detail": "Congratulations. You have been cured of a condition you never had. The run is over.",
79- "measured_length": round2(self.length),
80- "measured_girth": round2(self.girth),
91+ "measured_length": round2(self.body.length),
92+ "measured_girth": round2(self.body.girth),
8193 "volume": round2(volume),
8294 "prescription": "Please stop.",
8395 }),
Added file: src/game/credentials.rs+32 additions
@@ -0,0 +1,32 @@
1+#[derive(Clone, Copy)]
2+pub enum Scheme {
3+ Basic,
4+ Bearer,
5+ Excuse,
6+ Ruler,
7+}
8+
9+impl Scheme {
10+ fn name(self) -> &'static str {
11+ match self {
12+ Self::Basic => "basic",
13+ Self::Bearer => "bearer",
14+ Self::Excuse => "excuse",
15+ Self::Ruler => "ruler",
16+ }
17+ }
18+}
19+
20+pub fn parse_credentials(authorization: &str, scheme: Scheme) -> Option<&str> {
21+ let (given, rest) = authorization.trim().split_once(' ')?;
22+ given
23+ .eq_ignore_ascii_case(scheme.name())
24+ .then(|| rest.trim().trim_matches('"'))
25+}
26+
27+pub fn etag_matches(condition: &str, etag: &str) -> bool {
28+ condition
29+ .split(',')
30+ .map(str::trim)
31+ .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
32+}
Added file: src/game/dispatch.rs+164 additions
@@ -0,0 +1,164 @@
1+use super::{
2+ Floor, Lube, Outcome, Run, Story, Technique,
3+ techniques::{Training, Utility},
4+};
5+use crate::problem::Problem;
6+use axum::http::{StatusCode, header};
7+use serde_json::json;
8+use std::{fmt::Write as _, ops::ControlFlow};
9+
10+impl Run {
11+ pub fn jelq(
12+ &mut self,
13+ techniques: &[Technique],
14+ intensity: i32,
15+ lube: Option<Lube>,
16+ ) -> Result<Outcome, Problem> {
17+ self.check_exhausted()?;
18+ if self.online.story == Story::Pending {
19+ return Ok(self.branch_offer());
20+ }
21+ if self.floor == Floor::Forum {
22+ return Ok(Outcome::json(
23+ StatusCode::SEE_OTHER,
24+ json!({ "detail": "Refer to the forum." }),
25+ )
26+ .header(header::LOCATION, super::FORUM_THREAD));
27+ }
28+ if self.items.kit.cage {
29+ return Err(Problem::new(
30+ StatusCode::LOCKED,
31+ "caged",
32+ "The cage is locked. The key is not in the box.",
33+ ));
34+ }
35+ match *techniques {
36+ [Technique::Utility(utility)] => self.utility(utility, lube),
37+ [Technique::Training(training)] => self.single(training, intensity, lube),
38+ [Technique::Training(a), Technique::Training(b)] => self.combo(a, b, intensity, lube),
39+ [_, _] => Err(Problem::new(
40+ StatusCode::BAD_REQUEST,
41+ "combo-invalid",
42+ "Only training techniques can be combined.",
43+ )),
44+ _ => Err(Problem::new(
45+ StatusCode::BAD_REQUEST,
46+ "too-many-techniques",
47+ "Two techniques at most. The server has limits even if you do not.",
48+ )),
49+ }
50+ }
51+
52+ pub(super) fn utility(
53+ &mut self,
54+ utility: Utility,
55+ lube: Option<Lube>,
56+ ) -> Result<Outcome, Problem> {
57+ match utility {
58+ Utility::Warmup => return self.warm_up(),
59+ Utility::Ice => return self.ice(),
60+ Utility::Supplement => return self.supplement(),
61+ Utility::Meditate => return self.meditate(),
62+ Utility::Energy => return self.energy(),
63+ Utility::Edge | Utility::Pump => {}
64+ }
65+ self.ready(Technique::Utility(utility), lube)?;
66+ self.check_storage()?;
67+ self.floor_enemy()?;
68+ match utility {
69+ Utility::Edge => self.edge(),
70+ _ => self.pump(),
71+ }
72+ }
73+
74+ pub(super) fn single(
75+ &mut self,
76+ training: Training,
77+ intensity: i32,
78+ lube: Option<Lube>,
79+ ) -> Result<Outcome, Problem> {
80+ self.ready(Technique::Training(training), lube)?;
81+ self.check_storage()?;
82+ self.lube_available(lube)?;
83+ self.floor_enemy()?;
84+ self.take_lube(lube)?;
85+ self.chafe(lube, intensity);
86+ self.commit(intensity, lube, 1);
87+ if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
88+ return Ok(interrupted);
89+ }
90+ if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
91+ return Ok(interrupted);
92+ }
93+ let outcome = self.roll(training, intensity, lube)?;
94+ Ok(self.gossip(outcome))
95+ }
96+
97+ pub(super) fn combo(
98+ &mut self,
99+ a: Training,
100+ b: Training,
101+ intensity: i32,
102+ lube: Option<Lube>,
103+ ) -> Result<Outcome, Problem> {
104+ if a == b {
105+ return Err(Problem::new(
106+ StatusCode::BAD_REQUEST,
107+ "combo-invalid",
108+ "A technique cannot be combined with itself. The server has checked.",
109+ ));
110+ }
111+ self.ready(Technique::Training(a), lube)?;
112+ self.ready(Technique::Training(b), lube)?;
113+ self.check_storage()?;
114+ self.lube_available(lube)?;
115+ self.floor_enemy()?;
116+ self.take_lube(lube)?;
117+ self.chafe(lube, intensity);
118+ self.chafe(lube, intensity);
119+ self.commit(intensity, lube, 2);
120+ for training in [a, b] {
121+ if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
122+ return Ok(interrupted);
123+ }
124+ }
125+ if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
126+ return Ok(interrupted);
127+ }
128+
129+ let mut statuses = Vec::new();
130+ for training in [a, b] {
131+ let result = self.roll(training, intensity, lube);
132+ if self.ended.is_some() {
133+ return result;
134+ }
135+ let status = match result {
136+ Ok(outcome) => self.gossip(outcome).status(),
137+ Err(problem) => problem.status(),
138+ };
139+ statuses.push((training, status));
140+ }
141+
142+ let mut xml = String::from(
143+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
144+ );
145+ for (training, status) in statuses {
146+ let _ = write!(
147+ xml,
148+ " <D:response>\n <D:href>{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",
149+ self.path(""),
150+ training.name(),
151+ status.as_u16(),
152+ crate::problem::reason(status),
153+ );
154+ }
155+ xml.push_str(
156+ " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
157+ );
158+ Ok(Outcome::raw(
159+ StatusCode::MULTI_STATUS,
160+ "application/xml; charset=utf-8",
161+ xml,
162+ ))
163+ }
164+}
Added file: src/game/drawer.rs+161 additions
@@ -0,0 +1,161 @@
1+use super::{
2+ Floor, Item, Notice, Outcome, Run,
3+ balance::{BATHROOM_VASELINE, DRAWER_JAMS, GREGS_LOCKER, KEY_IN_DRAWER, RULER_IN_DRAWER},
4+};
5+use crate::problem::Problem;
6+use axum::http::{StatusCode, header};
7+use serde_json::json;
8+
9+fn nothing() -> Result<Outcome, Problem> {
10+ Ok(Outcome::empty(StatusCode::NO_CONTENT))
11+}
12+
13+impl Run {
14+ pub(super) fn found(&self, item: Item, detail: &str) -> Outcome {
15+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail })).header(
16+ header::LOCATION,
17+ self.path(&format!("/inventory/{}", item.slug())),
18+ )
19+ }
20+
21+ pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
22+ self.touch();
23+ self.end_kitchen_visit();
24+ if self.items.kit.cage {
25+ return Ok(self.rummage_for_key());
26+ }
27+ let first = !self.here.drawer.searched;
28+ self.here.drawer.searched = true;
29+ match self.floor {
30+ Floor::Bedroom => self.search_bedroom(first),
31+ Floor::Bathroom if first => Ok(self.search_bathroom()),
32+ Floor::ChangingRoom => self.search_locker(first),
33+ Floor::Nans if first => {
34+ self.items.grant(Item::Cage);
35+ self.standing.shame += 5;
36+ Ok(self.found(
37+ Item::Cage,
38+ "A small box. Inside was a cage. It is now on. The key is not in the box.",
39+ ))
40+ }
41+ Floor::Hotel if first => {
42+ for item in [Item::DndSign, Item::IcePack, Item::IcePack, Item::Lotion] {
43+ self.items.grant(item);
44+ }
45+ Ok(self.found(
46+ Item::DndSign,
47+ "A Do Not Disturb sign, two ice packs from the machine down the hall, and a complimentary lotion.",
48+ ))
49+ }
50+ Floor::Forum if first => {
51+ self.items.grant(Item::Spreadsheet);
52+ Ok(self.found(
53+ Item::Spreadsheet,
54+ "Your own spreadsheet. Every line is flat.",
55+ ))
56+ }
57+ Floor::Clinic => Err(Problem::new(
58+ StatusCode::FORBIDDEN,
59+ "doctors-drawer",
60+ "That is the doctor's drawer.",
61+ )),
62+ _ => nothing(),
63+ }
64+ }
65+
66+ fn search_bedroom(&mut self, first: bool) -> Result<Outcome, Problem> {
67+ let ruler_here = self.chance(
68+ self.decision("ruler", "Is there a ruler hiding in the drawer?"),
69+ RULER_IN_DRAWER,
70+ );
71+ if !first || self.items.kit.ruler || !ruler_here {
72+ return nothing();
73+ }
74+ self.items.grant(Item::Ruler);
75+ self.standing.shame += 10;
76+ Ok(self.found(
77+ Item::Ruler,
78+ "You now know. This is the first honest measurement of the run.",
79+ ))
80+ }
81+
82+ fn search_bathroom(&mut self) -> Outcome {
83+ self.items.grant(Item::DoorLock);
84+ let vaseline = self.chance(
85+ self.decision(
86+ "bathroom-vaseline",
87+ "Is it the vaseline behind the door lock, rather than the lotion?",
88+ ),
89+ BATHROOM_VASELINE,
90+ );
91+ let (lube, detail) = if vaseline {
92+ (
93+ Item::Vaseline,
94+ "A door lock, which works once, and a tub of vaseline behind it.",
95+ )
96+ } else {
97+ (
98+ Item::Lotion,
99+ "A door lock, which works once, and a bottle of lotion behind it.",
100+ )
101+ };
102+ self.items.grant(lube);
103+ self.found(Item::DoorLock, detail)
104+ }
105+
106+ fn search_locker(&mut self, first: bool) -> Result<Outcome, Problem> {
107+ let open = first
108+ && self.chance(
109+ self.decision("gregs-locker", "Has Greg left his locker open?"),
110+ GREGS_LOCKER,
111+ );
112+ if open {
113+ self.items.grant(Item::BabyOil);
114+ return Ok(self.found(
115+ Item::BabyOil,
116+ "Greg has left his locker open. The baby oil is yours now, legally speaking.",
117+ ));
118+ }
119+ self.standing.shame += 3;
120+ Err(Problem::new(
121+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
122+ "not-your-locker",
123+ "That locker belongs to Greg. The server cannot serve this request for legal reasons.",
124+ ))
125+ }
126+
127+ /// Searching while caged is noisy, costs the floor turn, and the key only turns up some of
128+ /// the time. A jammed drawer takes a whole turn to work loose.
129+ pub(super) fn rummage_for_key(&mut self) -> Outcome {
130+ self.begin_turn();
131+ self.add_suspicion(1);
132+ self.maybe_summon_mum();
133+ if self.here.drawer.jammed {
134+ self.here.drawer.jammed = false;
135+ self.notices.push(Notice::DrawerComesLoose);
136+ return Outcome::empty(StatusCode::NO_CONTENT);
137+ }
138+ if !self.chance(
139+ self.decision("key", "Does the drawer finally give up the cage key?"),
140+ KEY_IN_DRAWER,
141+ ) {
142+ if self.chance(
143+ self.decision("drawer-jams", "Has the drawer jammed on its runners?"),
144+ DRAWER_JAMS,
145+ ) {
146+ self.here.drawer.jammed = true;
147+ self.notices.push(Notice::DrawerJammed);
148+ }
149+ return Outcome::empty(StatusCode::NO_CONTENT);
150+ }
151+ self.items.grant(Item::CageKey);
152+ Outcome::json(
153+ StatusCode::OK,
154+ json!({ "detail": "The key was in the drawer. The cage is off." }),
155+ )
156+ }
157+
158+ pub fn drawer_worth_searching(&self) -> bool {
159+ !self.here.drawer.searched || self.items.kit.cage
160+ }
161+}
Added file: src/game/floor.rs+147 additions
@@ -0,0 +1,147 @@
1+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2+#[repr(u8)]
3+pub enum Floor {
4+ Bedroom = 1,
5+ Bathroom = 2,
6+ ChangingRoom = 3,
7+ Nans = 4,
8+ Hotel = 5,
9+ Forum = 6,
10+ Clinic = 7,
11+}
12+
13+impl Floor {
14+ pub fn number(self) -> u8 {
15+ self as u8
16+ }
17+
18+ pub fn next(self) -> Option<Self> {
19+ match self {
20+ Self::Bedroom => Some(Self::Bathroom),
21+ Self::Bathroom => Some(Self::ChangingRoom),
22+ Self::ChangingRoom => Some(Self::Nans),
23+ Self::Nans => Some(Self::Hotel),
24+ Self::Hotel => Some(Self::Forum),
25+ Self::Forum => Some(Self::Clinic),
26+ Self::Clinic => None,
27+ }
28+ }
29+
30+ pub fn name(self) -> &'static str {
31+ match self {
32+ Self::Bedroom => "the bedroom",
33+ Self::Bathroom => "the shared bathroom",
34+ Self::ChangingRoom => "the gym changing room",
35+ Self::Nans => "Nan's house at Christmas",
36+ Self::Hotel => "a business trip hotel",
37+ Self::Forum => "the forum",
38+ Self::Clinic => "the urologist's office",
39+ }
40+ }
41+
42+ pub fn place(self) -> &'static str {
43+ match self {
44+ Self::Bedroom => "the bedroom",
45+ Self::Bathroom => "the bathroom",
46+ Self::ChangingRoom => "the changing room",
47+ Self::Nans => "Nan's house",
48+ Self::Hotel => "the hotel room",
49+ Self::Forum => "the forum floor",
50+ Self::Clinic => "the clinic",
51+ }
52+ }
53+
54+ pub fn note(self) -> &'static str {
55+ match self {
56+ Self::Bedroom => "The door does not lock. The cat is present.",
57+ Self::Bathroom => "The water is hot. Your brother showers at eight.",
58+ Self::ChangingRoom => {
59+ "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."
60+ }
61+ Self::Nans => {
62+ "The walls are thin. Mum is here, and she has been reading about Digest authentication."
63+ }
64+ Self::Hotel => {
65+ "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."
66+ }
67+ Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",
68+ Self::Clinic => "The urologist will see you now. He is bored.",
69+ }
70+ }
71+
72+ pub fn observer(self) -> Option<Observer> {
73+ match self {
74+ Self::Bedroom => Some(Observer::Cat),
75+ Self::ChangingRoom => Some(Observer::Greg),
76+ Self::Nans => Some(Observer::Nan),
77+ _ => None,
78+ }
79+ }
80+
81+ pub fn has_mum(self) -> bool {
82+ matches!(self, Self::Bedroom | Self::Nans)
83+ }
84+
85+ pub fn has_window(self) -> bool {
86+ matches!(self, Self::Bedroom | Self::Nans | Self::Hotel)
87+ }
88+
89+ pub fn shop_open(self) -> bool {
90+ matches!(self, Self::ChangingRoom | Self::Hotel)
91+ }
92+
93+ pub fn has_kitchen(self) -> bool {
94+ matches!(self, Self::Bedroom | Self::Nans)
95+ }
96+
97+ pub fn timed(self) -> bool {
98+ !matches!(self, Self::Forum | Self::Clinic)
99+ }
100+}
101+
102+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
103+pub enum Observer {
104+ Cat,
105+ Greg,
106+ Nan,
107+}
108+
109+impl Observer {
110+ pub fn name(self) -> &'static str {
111+ match self {
112+ Self::Cat => "cat",
113+ Self::Greg => "greg",
114+ Self::Nan => "nan",
115+ }
116+ }
117+}
118+
119+/// State that belongs to the current floor and resets when you leave it.
120+#[derive(Default)]
121+pub struct FloorState {
122+ pub(crate) turns: u32,
123+ pub(crate) warm_arrival: bool,
124+ pub(crate) drawer: Drawer,
125+ pub(crate) kitchen: KitchenVisit,
126+ pub(crate) thread_rolls: u8,
127+ pub(crate) edges: u8,
128+ pub(crate) temp_length: f64,
129+ pub(crate) teapot: bool,
130+ pub(crate) towel_stolen: bool,
131+ pub(crate) meditated: bool,
132+ pub(crate) brother_told: bool,
133+ pub(crate) shopping: bool,
134+ pub(crate) wifi_accepted: bool,
135+}
136+
137+#[derive(Default)]
138+pub struct Drawer {
139+ pub(crate) searched: bool,
140+ pub(crate) jammed: bool,
141+}
142+
143+#[derive(Default)]
144+pub struct KitchenVisit {
145+ pub(crate) kettle_boiled: bool,
146+ pub(crate) melon_offered: bool,
147+}
Deleted file: src/game/floors.rs−292 deletions
@@ -1,292 +0,0 @@
1-use axum::http::{StatusCode, header};
2-use serde_json::{Value, json};
3-
4-use super::{
5- Cause, Elder, Floor, FloorState, Item, Outcome, Run,
6- balance::{
7- DRAWER_JAMS, EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER,
8- KEY_IN_DRAWER, LOCKER_ROOM_BAR, LOCKER_ROOM_SHAME, RULER_IN_DRAWER, STARTING_EQ,
9- SUSPICION_LIMIT, ZIP,
10- },
11- round2,
12-};
13-use crate::problem::Problem;
14-
15-impl Run {
16- pub(super) fn next_floor(&mut self, paid: bool) {
17- let Some(next) = self.floor.next() else {
18- return;
19- };
20- let warm = EQ_BAND.contains(&self.eq);
21- self.floor = next;
22- self.here = FloorState {
23- warm_arrival: warm,
24- ..FloorState::default()
25- };
26- self.warmed_up = false;
27- self.reset_eq(STARTING_EQ);
28- self.suspicion = 0;
29- self.shed(FRICTION_FLOOR);
30- self.mum = None;
31- if self.floor == Floor::ChangingRoom && !self.holds_own_in_the_locker_room() {
32- self.shame += LOCKER_ROOM_SHAME;
33- self.notices
34- .push("Kev is 8 inches, he says. Nobody asked you, which is worse.");
35- }
36- if paid {
37- self.cash += FLOOR_ALLOWANCE;
38- }
39- self.touch();
40- }
41-
42- pub fn advance(&mut self) -> Result<Outcome, Problem> {
43- match self.floor {
44- Floor::Clinic => {
45- return Err(Problem::new(
46- StatusCode::CONFLICT,
47- "no-further-floor",
48- "There is no further floor. There is only the diagnosis.",
49- ));
50- }
51- Floor::Forum if self.elder != Elder::Beaten => {
52- return Err(Problem::new(
53- StatusCode::CONFLICT,
54- "elder-unaddressed",
55- "The Elder has not been addressed.",
56- )
57- .with("elder", self.path("/elder")));
58- }
59- floor if floor.timed() && self.here.turns < FLOOR_TURNS => {
60- return Err(Problem::new(
61- StatusCode::TOO_EARLY,
62- "floor-not-over",
63- format!(
64- "The floor is not over. {} turns remain.",
65- FLOOR_TURNS - self.here.turns
66- ),
67- ));
68- }
69- _ => {}
70- }
71- if self.chance(
72- self.decision(
73- "zip",
74- "As the floor ends and you put yourself away, does the zip catch?",
75- ),
76- ZIP,
77- ) {
78- return Err(self.end(Cause::Zip));
79- }
80- let left = self.floor.name();
81- let reverted = self.here.temp_length;
82- self.next_floor(true);
83- let warm = if self.here.warm_arrival {
84- ", and the warmth came with you"
85- } else {
86- ""
87- };
88- let mut body = json!({
89- "detail": format!("You leave {left}. You have arrived at {}{warm}.", self.floor.name()),
90- "note": self.floor.note(),
91- });
92- if reverted > 0.0 {
93- body["reverted"] = json!(round2(reverted));
94- }
95- Ok(
96- Outcome::json(StatusCode::SEE_OTHER, body)
97- .header(header::LOCATION, self.path("/floor")),
98- )
99- }
100-
101- pub fn floor_view(&self) -> Value {
102- let mut view = json!({
103- "name": self.floor.name(),
104- "note": self.floor.note(),
105- "turns": self.here.turns,
106- "turns_required": self.floor.timed().then_some(FLOOR_TURNS),
107- "exit": { "href": self.path("/floor"), "method": "POST" },
108- });
109- match self.floor {
110- Floor::ChangingRoom => {
111- view["others"] = json!([
112- { "name": "Greg", "self_reported_length": 9.5 },
113- { "name": "Dmitri", "self_reported_length": 8.75 },
114- { "name": "a man in a towel", "self_reported_length": 11.0 },
115- { "name": "Kev", "self_reported_length": LOCKER_ROOM_BAR },
116- ]);
117- }
118- Floor::Hotel => view["portal"] = json!(self.path("/portal")),
119- Floor::Forum => {
120- view["thread"] = json!(super::FORUM_THREAD);
121- view["elder"] = json!(self.path("/elder"));
122- }
123- Floor::Clinic => view["diagnosis"] = json!(self.path("/diagnosis")),
124- _ => {}
125- }
126- if self.floor.shop_open() {
127- view["shop"] = json!(self.path("/shop"));
128- }
129- view
130- }
131-
132- fn found(&self, item: Item, detail: &str) -> Outcome {
133- Outcome::json(StatusCode::FOUND, json!({ "detail": detail })).header(
134- header::LOCATION,
135- self.path(&format!("/inventory/{}", item.slug())),
136- )
137- }
138-
139- pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
140- self.touch();
141- self.end_kitchen_visit();
142- if self.items.cage {
143- return Ok(self.rummage_for_key());
144- }
145- let first = !self.here.drawer_searched;
146- self.here.drawer_searched = true;
147- let ruler_here = self.floor == Floor::Bedroom
148- && self.chance(
149- self.decision("ruler", "Is there a ruler hiding in the drawer?"),
150- RULER_IN_DRAWER,
151- );
152- let vaseline_here = self.floor == Floor::Bathroom
153- && first
154- && self.chance(
155- self.decision(
156- "bathroom-vaseline",
157- "Is it the vaseline behind the door lock, rather than the lotion?",
158- ),
159- 2,
160- );
161- let locker_open = self.floor == Floor::ChangingRoom
162- && first
163- && self.chance(
164- self.decision("gregs-locker", "Has Greg left his locker open?"),
165- GREGS_LOCKER,
166- );
167- match self.floor {
168- Floor::Bedroom if first && !self.items.ruler && ruler_here => {
169- self.items.grant(Item::Ruler);
170- self.shame += 10;
171- Ok(self.found(
172- Item::Ruler,
173- "You now know. This is the first honest measurement of the run.",
174- ))
175- }
176- Floor::Bathroom if first => {
177- self.items.grant(Item::DoorLock);
178- let detail = if vaseline_here {
179- self.items.grant(Item::Vaseline);
180- "A door lock, which works once, and a tub of vaseline behind it."
181- } else {
182- self.items.grant(Item::Lotion);
183- "A door lock, which works once, and a bottle of lotion behind it."
184- };
185- Ok(self.found(Item::DoorLock, detail))
186- }
187- Floor::ChangingRoom if locker_open => {
188- self.items.grant(Item::BabyOil);
189- Ok(self.found(
190- Item::BabyOil,
191- "Greg has left his locker open. The baby oil is yours now, legally speaking.",
192- ))
193- }
194- Floor::ChangingRoom => {
195- self.shame += 3;
196- Err(Problem::new(
197- StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
198- "not-your-locker",
199- "That locker belongs to Greg. The server cannot serve this request for legal reasons.",
200- ))
201- }
202- Floor::Nans if first => {
203- self.items.grant(Item::Cage);
204- self.shame += 5;
205- Ok(self.found(
206- Item::Cage,
207- "A small box. Inside was a cage. It is now on. The key is not in the box.",
208- ))
209- }
210- Floor::Hotel if first => {
211- self.items.grant(Item::DndSign);
212- self.items.grant(Item::IcePack);
213- self.items.grant(Item::IcePack);
214- self.items.grant(Item::Lotion);
215- Ok(self.found(
216- Item::DndSign,
217- "A Do Not Disturb sign, two ice packs from the machine down the hall, and a complimentary lotion.",
218- ))
219- }
220- Floor::Forum if first => {
221- self.items.grant(Item::Spreadsheet);
222- Ok(self.found(
223- Item::Spreadsheet,
224- "Your own spreadsheet. Every line is flat.",
225- ))
226- }
227- Floor::Clinic => Err(Problem::new(
228- StatusCode::FORBIDDEN,
229- "doctors-drawer",
230- "That is the doctor's drawer.",
231- )),
232- _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),
233- }
234- }
235-
236- pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {
237- if self.floor != Floor::Hotel {
238- return Err(Problem::new(
239- StatusCode::NOT_FOUND,
240- "no-portal",
241- "There is no captive portal here. There is only the regular kind of captivity.",
242- ));
243- }
244- self.here.wifi_accepted = true;
245- self.touch();
246- Ok(Outcome::json(
247- StatusCode::SEE_OTHER,
248- json!({ "detail": "Connected. The hotel does not want to know." }),
249- )
250- .header(header::LOCATION, self.path("")))
251- }
252-
253- /// Searching while caged is noisy, costs the floor turn, and the key only turns up some of
254- /// the time. A jammed drawer takes a whole turn to work loose.
255- fn rummage_for_key(&mut self) -> Outcome {
256- self.turn += 1;
257- self.here.turns += 1;
258- self.realise();
259- let was_below = self.suspicion < SUSPICION_LIMIT;
260- self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);
261- self.maybe_summon_mum(was_below);
262- if self.here.drawer_jammed {
263- self.here.drawer_jammed = false;
264- self.notices
265- .push("The drawer comes loose. That took a while.");
266- return Outcome::empty(StatusCode::NO_CONTENT);
267- }
268- if !self.chance(
269- self.decision("key", "Does the drawer finally give up the cage key?"),
270- KEY_IN_DRAWER,
271- ) {
272- if self.chance(
273- self.decision("drawer-jams", "Has the drawer jammed on its runners?"),
274- DRAWER_JAMS,
275- ) {
276- self.here.drawer_jammed = true;
277- self.notices.push("The drawer has jammed.");
278- }
279- return Outcome::empty(StatusCode::NO_CONTENT);
280- }
281- self.items.grant(Item::CageKey);
282- Outcome::json(
283- StatusCode::OK,
284- json!({ "detail": "The key was in the drawer. The cage is off." }),
285- )
286- }
287-
288- /// Whether searching the drawer on this floor would turn anything up.
289- pub fn drawer_worth_searching(&self) -> bool {
290- !self.here.drawer_searched || self.items.cage
291- }
292-}
Modified file: src/game/forum.rs+92 additions−38 deletions
@@ -1,18 +1,21 @@
1-use std::time::SystemTime;
1+use std::{sync::LazyLock, time::SystemTime};
22
33 use axum::http::{StatusCode, header};
44 use serde_json::{Value, json};
55
66 use super::{
7- Cause, Elder, Floor, Outcome, Run, Story,
7+ Cause, Floor, Outcome, Run,
88 balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},
9- body::exaggeration,
10- honest, parse_credentials,
9+ credentials::{Scheme, parse_credentials},
10+ measure::{exaggeration, honest},
1111 };
1212 use crate::{headers::X_FORUM_NOTE, problem::Problem};
1313
1414 pub const FORUM_THREAD: &str = "/forum/threads/2009";
1515 pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";
16+static ELDER_POSTED: LazyLock<SystemTime> = LazyLock::new(|| {
17+ httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("the Elder's date is a valid HTTP-date")
18+});
1619
1720 const SPREADSHEET: &str = "\
1821 date,length,girth,technique,notes,honest?
@@ -57,10 +60,71 @@ fn verification(detail: &str) -> Problem {
5760 .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")
5861 }
5962
63+/// The browser tab that opens on turn 10.
64+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65+pub enum Story {
66+ Unopened,
67+ Pending,
68+ ReadForum,
69+ ClosedTab,
70+}
71+
72+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
73+pub enum Branch {
74+ Forum,
75+ CloseTab,
76+}
77+
78+impl Branch {
79+ pub fn slug(self) -> &'static str {
80+ match self {
81+ Self::Forum => "forum",
82+ Self::CloseTab => "close-tab",
83+ }
84+ }
85+}
86+
87+impl std::str::FromStr for Branch {
88+ type Err = ();
89+
90+ fn from_str(slug: &str) -> Result<Self, ()> {
91+ [Self::Forum, Self::CloseTab]
92+ .into_iter()
93+ .find(|branch| branch.slug() == slug)
94+ .ok_or(())
95+ }
96+}
97+
98+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
99+pub enum Elder {
100+ Unverified { strikes: u8 },
101+ Verified,
102+ Beaten,
103+}
104+
105+/// The run's life online, from the tab on turn 10 to the footage the neighbour took.
106+pub struct Online {
107+ pub(crate) story: Story,
108+ pub(crate) uli_taught: bool,
109+ pub(crate) elder: Elder,
110+ pub(crate) filmed_at: Option<SystemTime>,
111+}
112+
113+impl Default for Online {
114+ fn default() -> Self {
115+ Self {
116+ story: Story::Unopened,
117+ uli_taught: false,
118+ elder: Elder::Unverified { strikes: 0 },
119+ filmed_at: None,
120+ }
121+ }
122+}
123+
60124 impl Run {
61125 pub(super) fn branch_offer(&self) -> Outcome {
62- let forum = self.path("/branches/forum");
63- let close = self.path("/branches/close-tab");
126+ let forum = self.path(&format!("/branches/{}", Branch::Forum.slug()));
127+ let close = self.path(&format!("/branches/{}", Branch::CloseTab.slug()));
64128 Outcome::json(
65129 StatusCode::MULTIPLE_CHOICES,
66130 json!({
@@ -79,8 +143,8 @@ impl Run {
79143 )
80144 }
81145
82- pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
83- if self.story != Story::Pending {
146+ pub fn choose(&mut self, choice: Branch) -> Result<Outcome, Problem> {
147+ if self.online.story != Story::Pending {
84148 return Err(Problem::new(
85149 StatusCode::CONFLICT,
86150 "no-decision-pending",
@@ -88,29 +152,22 @@ impl Run {
88152 ));
89153 }
90154 let (story, location, detail) = match choice {
91- "forum" => {
92- self.shame += 3;
155+ Branch::Forum => {
156+ self.standing.shame += 3;
93157 let detail =
94158 "The thread is from 2009. A technique called uli is discussed at length.";
95159 (Story::ReadForum, self.path("/techniques"), detail)
96160 }
97- "close-tab" => {
98- self.suspicion = 0;
161+ Branch::CloseTab => {
162+ self.standing.suspicion = 0;
99163 (
100164 Story::ClosedTab,
101165 self.path(""),
102166 "The tab is closed. Nobody saw it.",
103167 )
104168 }
105- _ => {
106- return Err(Problem::new(
107- StatusCode::NOT_FOUND,
108- "no-such-choice",
109- "That was not one of the choices.",
110- ));
111- }
112169 };
113- self.story = story;
170+ self.online.story = story;
114171 self.touch();
115172 Ok(
116173 Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))
@@ -121,7 +178,7 @@ impl Run {
121178 /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,
122179 /// after which the forum is down and the browser serves what it kept.
123180 pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
124- if self.story == Story::Pending {
181+ if self.online.story == Story::Pending {
125182 return Ok(self.branch_offer());
126183 }
127184 if !self.thread_reachable() {
@@ -132,13 +189,12 @@ impl Run {
132189 ));
133190 }
134191 self.end_kitchen_visit();
135- if self.uli_taught {
192+ if self.online.uli_taught {
136193 return Ok(Outcome::json(StatusCode::OK, forum_thread()));
137194 }
138195 if self.here.thread_rolls >= ULI_READS {
139- let age = httpdate::parse_http_date(ELDER_LAST_MODIFIED)
140- .ok()
141- .and_then(|posted| SystemTime::now().duration_since(posted).ok())
196+ let age = SystemTime::now()
197+ .duration_since(*ELDER_POSTED)
142198 .map_or(0, |age| age.as_secs());
143199 return Ok(Outcome::json(StatusCode::OK, forum_thread())
144200 .header(header::AGE, age.to_string())
@@ -153,7 +209,7 @@ impl Run {
153209 ),
154210 ULI_ODDS,
155211 ) {
156- self.uli_taught = true;
212+ self.online.uli_taught = true;
157213 return Ok(Outcome::json(
158214 StatusCode::OK,
159215 json!({ "detail": "Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it." }),
@@ -165,7 +221,7 @@ impl Run {
165221
166222 fn elder_strike(&mut self, problem: Problem) -> Problem {
167223 self.touch();
168- let Elder::Unverified { strikes } = &mut self.elder else {
224+ let Elder::Unverified { strikes } = &mut self.online.elder else {
169225 return problem;
170226 };
171227 *strikes += 1;
@@ -177,24 +233,24 @@ impl Run {
177233
178234 /// Failed verification counts toward a ban even on GET, the way repeated bad credentials lock an account.
179235 fn verify_member(&mut self, proxy_authorization: Option<&str>) -> Result<(), Problem> {
180- if !matches!(self.elder, Elder::Unverified { .. }) {
236+ if !matches!(self.online.elder, Elder::Unverified { .. }) {
181237 return Ok(());
182238 }
183239 let Some(credentials) = proxy_authorization else {
184240 return Err(verification("The forum requires verification."));
185241 };
186242 let Some(claimed) =
187- parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())
243+ parse_credentials(credentials, Scheme::Ruler).and_then(|v| v.parse::<f64>().ok())
188244 else {
189245 return Err(verification("The forum requires verification, in inches."));
190246 };
191247 let actual = self.current_length();
192248 if claimed >= actual + EXAGGERATION {
193- self.shame += 2;
249+ self.standing.shame += 2;
194250 return Err(self.elder_strike(exaggeration()));
195251 }
196252 if !honest(claimed, actual) {
197- self.shame += 1;
253+ self.standing.shame += 1;
198254 let failed = verification("Verification failed. The forum can tell.");
199255 if self.girth_respected() {
200256 return Err(failed.header(
@@ -204,7 +260,7 @@ impl Run {
204260 }
205261 return Err(self.elder_strike(failed));
206262 }
207- self.elder = Elder::Verified;
263+ self.online.elder = Elder::Verified;
208264 self.touch();
209265 Ok(())
210266 }
@@ -223,14 +279,12 @@ impl Run {
223279 }
224280 self.verify_member(proxy_authorization)?;
225281
226- let last_modified =
227- httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");
228282 let current = if_modified_since
229283 .and_then(|d| httpdate::parse_http_date(d).ok())
230- .is_some_and(|since| since >= last_modified);
284+ .is_some_and(|since| since >= *ELDER_POSTED);
231285 if current {
232- if self.elder != Elder::Beaten {
233- self.elder = Elder::Beaten;
286+ if self.online.elder != Elder::Beaten {
287+ self.online.elder = Elder::Beaten;
234288 self.touch();
235289 }
236290 return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
@@ -238,7 +292,7 @@ impl Run {
238292 }
239293 let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
240294 .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);
241- if self.filmed_at.is_some() {
295+ if self.online.filmed_at.is_some() {
242296 return Ok(spreadsheet.header(X_FORUM_NOTE, "We have seen the footage."));
243297 }
244298 Ok(spreadsheet)
Added file: src/game/gauges.rs+173 additions
@@ -0,0 +1,173 @@
1+use std::time::Instant;
2+
3+use serde_json::{Value, json};
4+
5+use super::{
6+ Elder, Excuse, Floor, Grip, Item, Observer, Run, Story,
7+ balance::{DANGER_EQ, KITCHEN_TELLINGS},
8+};
9+
10+pub enum Shown {
11+ Measured(f64),
12+ SelfReported(f64),
13+}
14+
15+pub struct Gauges {
16+ pub(crate) length: Shown,
17+ pub(crate) girth: f64,
18+ pub(crate) foreskin: Option<f64>,
19+ pub(crate) friction: i32,
20+ pub(crate) eq: i32,
21+ pub(crate) stamina: i32,
22+ pub(crate) max_stamina: i32,
23+ pub(crate) suspicion: u8,
24+ pub(crate) cash: u32,
25+ pub(crate) floor: u8,
26+ pub(crate) observed: Option<Observer>,
27+ pub(crate) mum_at_door: bool,
28+ pub(crate) warnings: Vec<&'static str>,
29+ pub(crate) ruler: bool,
30+ pub(crate) ring: bool,
31+}
32+
33+pub struct Reach {
34+ pub(crate) foreskin: bool,
35+ pub(crate) drawer: bool,
36+ pub(crate) kitchen: bool,
37+ pub(crate) thread: bool,
38+ pub(crate) shop: bool,
39+ pub(crate) portal: bool,
40+ pub(crate) elder: bool,
41+ pub(crate) footage: bool,
42+ pub(crate) diagnosis: bool,
43+ pub(crate) branch_pending: bool,
44+ pub(crate) shopping: bool,
45+}
46+
47+impl Run {
48+ /// Drains the notices, so call it once per response.
49+ pub fn gauges(&mut self) -> Gauges {
50+ let conditions = &self.body.conditions;
51+ let mut warnings = Vec::new();
52+ if conditions.pulled_muscle {
53+ warnings.push("a muscle has been pulled");
54+ }
55+ if conditions.friction_burn {
56+ warnings.push("friction burn");
57+ }
58+ warnings.extend(self.notices.drain(..).map(|notice| notice.text()));
59+ if self.body.eq > DANGER_EQ && self.ended.is_none() {
60+ warnings.push("it is turning purple");
61+ }
62+ Gauges {
63+ length: if self.items.kit.ruler {
64+ Shown::Measured(self.current_length())
65+ } else {
66+ Shown::SelfReported(self.self_reported())
67+ },
68+ girth: self.body.girth,
69+ foreskin: self.body.foreskin,
70+ friction: self.body.friction,
71+ eq: self.body.eq,
72+ stamina: self.body.stamina,
73+ max_stamina: self.max_stamina(),
74+ suspicion: self.standing.suspicion,
75+ cash: self.standing.cash,
76+ floor: self.floor.number(),
77+ observed: self.observed,
78+ mum_at_door: self.household.mum.is_some(),
79+ warnings,
80+ ruler: self.items.kit.ruler,
81+ ring: self.items.kit.ring,
82+ }
83+ }
84+
85+ pub fn reach(&self) -> Reach {
86+ Reach {
87+ foreskin: self.body.foreskin.is_some(),
88+ drawer: self.drawer_worth_searching(),
89+ kitchen: self.floor.has_kitchen() && self.household.kitchen_tellings < KITCHEN_TELLINGS,
90+ thread: self.thread_reachable(),
91+ shop: self.floor.shop_open(),
92+ portal: self.needs_portal(),
93+ elder: self.floor == Floor::Forum && self.online.elder != Elder::Beaten,
94+ footage: self.online.filmed_at.is_some(),
95+ diagnosis: self.floor == Floor::Clinic,
96+ branch_pending: self.online.story == Story::Pending,
97+ shopping: self.here.shopping,
98+ }
99+ }
100+
101+ pub fn needs_portal(&self) -> bool {
102+ self.floor == Floor::Hotel && !self.here.wifi_accepted
103+ }
104+
105+ /// Shame follows the player across runs in a signed cookie. Each response adds this run's new
106+ /// shame to whatever total the client presents, so two runs at once both count.
107+ pub fn settle_shame(&mut self, presented: Option<u32>) -> u32 {
108+ let standing = &mut self.standing;
109+ let total =
110+ presented.unwrap_or(standing.shame_total) + (standing.shame - standing.shame_reported);
111+ standing.shame_reported = standing.shame;
112+ standing.shame_total = total;
113+ total
114+ }
115+
116+ pub fn trace(&mut self) {
117+ self.standing.shame += 1;
118+ }
119+
120+ pub fn measured(&self) -> bool {
121+ self.items.kit.ruler
122+ }
123+
124+ pub fn id(&self) -> &str {
125+ &self.ident.id
126+ }
127+
128+ pub fn last_seen(&self) -> Instant {
129+ self.ident.last_seen
130+ }
131+
132+ /// A request has arrived. Whoever was watching last time has looked away.
133+ pub fn seen(&mut self, now: Instant) {
134+ self.ident.last_seen = now;
135+ self.observed = None;
136+ }
137+
138+ pub fn foreskin(&self) -> Option<f64> {
139+ self.body.foreskin
140+ }
141+
142+ pub fn grip(&self) -> Grip {
143+ self.body.grip
144+ }
145+
146+ pub fn knows_uli(&self) -> bool {
147+ self.online.uli_taught
148+ }
149+
150+ pub fn holds(&self, item: Item) -> bool {
151+ self.items.has(item)
152+ }
153+
154+ pub fn inventory_view(&self) -> Value {
155+ let items: Vec<Value> = self
156+ .items
157+ .owned()
158+ .into_iter()
159+ .map(|item| {
160+ json!({
161+ "name": item.slug(),
162+ "href": self.path(&format!("/inventory/{}", item.slug())),
163+ })
164+ })
165+ .collect();
166+ json!({
167+ "items": items,
168+ "ice_packs": self.items.stores.ice_packs,
169+ "supplements": self.items.stores.supplements,
170+ "excuses": self.excuses().into_iter().map(Excuse::phrase).collect::<Vec<_>>(),
171+ })
172+ }
173+}
Modified file: src/game/hazards.rs+37 additions−39 deletions
@@ -4,40 +4,41 @@ use axum::http::{StatusCode, header};
44 use serde_json::json;
55
66 use super::{
7- Cause, Floor, Outcome, Run,
7+ Cause, Floor, Notice, Outcome, Run,
88 balance::{
9- BURN_RUPTURE, COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING, DEGLOVING_INTENSITY, FRIEND,
10- HOT_EQ, PREMATURE_EQ, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,
11- SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,
9+ BURN_RUPTURE, COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING, DEGLOVING_INTENSITY, EQ_BAND,
10+ FRIEND, HOT_EQ, LOST_IT_EQ, PREMATURE_EQ, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE,
11+ RUPTURE_WARM, SOFT_INTENSITY, SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,
1212 },
1313 techniques::Training,
14+ travel::Exit,
1415 };
1516 use crate::problem::Problem;
1617
1718 type Interruption = Result<ControlFlow<Outcome>, Problem>;
1819
1920 impl Run {
20- /// How far the friction burn drags the rupture tiers down.
21- pub(super) fn burn(&self) -> i32 {
22- if self.conditions.friction_burn {
21+ /// Whether EQ is past `line`, with a friction burn dragging every line down.
22+ pub(super) fn hotter_than(&self, line: i32) -> bool {
23+ let burn = if self.body.conditions.friction_burn {
2324 BURN_RUPTURE
2425 } else {
2526 0
26- }
27+ };
28+ self.body.eq > line - burn
2729 }
2830
2931 /// The rupture tier for any turn that is not a jelq, so never the premature band's 1 in 3.
3032 pub(super) fn rupture_odds(&self) -> u32 {
31- if self.eq <= DANGER_EQ - self.burn() {
33+ if !self.hotter_than(DANGER_EQ) {
3234 RUPTURE_IN_BAND
33- } else if self.eq <= HOT_EQ - self.burn() {
35+ } else if !self.hotter_than(HOT_EQ) {
3436 RUPTURE_WARM
3537 } else {
3638 RUPTURE_HOT
3739 }
3840 }
3941
40- /// Accidents tied to a specific technique.
4142 pub(super) fn mishaps(&mut self, training: Training, intensity: i32) -> Interruption {
4243 if training == Training::Dry
4344 && intensity >= DEGLOVING_INTENSITY
@@ -51,10 +52,8 @@ impl Run {
5152 Ok(ControlFlow::Continue(()))
5253 }
5354
54- /// Everything that can go wrong on a committed turn before the roll.
5555 pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {
56- let burn = self.burn();
57- if self.eq <= PREMATURE_EQ - burn
56+ if !self.hotter_than(PREMATURE_EQ)
5857 && self.chance(
5958 self.decision("rupture", "Does this jelq rupture something?"),
6059 self.rupture_odds(),
@@ -63,14 +62,14 @@ impl Run {
6362 return Err(self.rupture());
6463 }
6564 if self.floor.has_window()
66- && !self.items.blinds
65+ && !self.items.kit.blinds
6766 && self.oracle.chance_not(
6867 self.decision("window", "Are the curtains still closed?"),
6968 WINDOW,
7069 )
7170 {
72- self.shame += 30;
73- self.filmed_at.get_or_insert_with(SystemTime::now);
71+ self.standing.shame += 30;
72+ self.online.filmed_at.get_or_insert_with(SystemTime::now);
7473 return Ok(ControlFlow::Break(
7574 Outcome::json(
7675 StatusCode::CREATED,
@@ -85,14 +84,14 @@ impl Run {
8584 FRIEND,
8685 )
8786 {
88- self.shame += 1;
87+ self.standing.shame += 1;
8988 return Err(Problem::new(
9089 StatusCode::USE_PROXY,
9190 "friend",
9291 "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",
9392 ));
9493 }
95- if self.conditions.peyronie
94+ if self.body.conditions.peyronie
9695 && self.chance(
9796 self.decision(
9897 "misdirected",
@@ -107,8 +106,8 @@ impl Run {
107106 "The technique landed slightly to the left. It was meant for a different server.",
108107 ));
109108 }
110- if intensity <= 2 && self.eq < 50 {
111- self.eq = (self.eq - 10).max(0);
109+ if intensity <= SOFT_INTENSITY && self.body.eq < *EQ_BAND.start() {
110+ self.body.eq = (self.body.eq - LOST_IT_EQ).max(0);
112111 return Err(Problem::new(
113112 StatusCode::REQUEST_TIMEOUT,
114113 "lost-it",
@@ -116,7 +115,7 @@ impl Run {
116115 )
117116 .header(header::CONNECTION, "close"));
118117 }
119- if self.eq > PREMATURE_EQ - burn {
118+ if self.hotter_than(PREMATURE_EQ) {
120119 if self.chance(
121120 self.decision(
122121 "rupture",
@@ -126,15 +125,15 @@ impl Run {
126125 ) {
127126 return Err(self.rupture());
128127 }
129- self.shame += 5;
130- self.next_floor(false);
128+ self.standing.shame += 5;
129+ self.next_floor(Exit::Premature);
131130 return Err(Problem::new(
132131 StatusCode::TOO_EARLY,
133132 "premature",
134133 "The floor has ended early. Everyone involved has agreed not to discuss it.",
135134 ));
136135 }
137- if self.eq < COLD_EQ {
136+ if self.body.eq < COLD_EQ {
138137 return Err(Problem::new(
139138 StatusCode::NOT_FOUND,
140139 "cold",
@@ -146,7 +145,7 @@ impl Run {
146145
147146 /// The enemies suspicion summons on floors without Mum.
148147 pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {
149- let visits = u32::from(self.suspicion) + 1;
148+ let visits = u32::from(self.standing.suspicion) + 1;
150149 let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)
151150 && self.oracle.ratio_choice(
152151 self.decision("wanderer", "Does anyone else come through the door?"),
@@ -155,16 +154,16 @@ impl Run {
155154 "No",
156155 "Yes",
157156 );
158- if self.suspicion < SUSPICION_LIMIT && !wanders_in {
157+ if self.standing.suspicion < SUSPICION_LIMIT && !wanders_in {
159158 return Ok(());
160159 }
161160 match self.floor {
162161 Floor::Bathroom => {
163- self.suspicion = 0;
162+ self.standing.suspicion = 0;
164163 self.touch();
165- if self.items.door_locks > 0 {
166- self.items.door_locks -= 1;
167- self.notices.push("your brother tried the door");
164+ if self.items.stores.door_locks > 0 {
165+ self.items.stores.door_locks -= 1;
166+ self.notices.push(Notice::BrotherTriedTheDoor);
168167 return Ok(());
169168 }
170169 self.here.brother_told = true;
@@ -175,14 +174,13 @@ impl Run {
175174 ))
176175 }
177176 Floor::ChangingRoom => {
178- self.suspicion = 0;
177+ self.standing.suspicion = 0;
179178 self.touch();
180179 if self.girth_respected() {
181- self.notices
182- .push("the gym staff have seen worse, and they leave it there");
180+ self.notices.push(Notice::GymStaffLeaveIt);
183181 return Ok(());
184182 }
185- self.shame += 5;
183+ self.standing.shame += 5;
186184 Err(Problem::new(
187185 StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
188186 "gym-staff",
@@ -190,13 +188,13 @@ impl Run {
190188 ))
191189 }
192190 Floor::Hotel => {
193- self.suspicion = 0;
191+ self.standing.suspicion = 0;
194192 self.touch();
195- if self.items.dnd_sign {
196- self.notices.push("housekeeping read the sign");
193+ if self.items.kit.dnd_sign {
194+ self.notices.push(Notice::HousekeepingReadTheSign);
197195 return Ok(());
198196 }
199- self.shame += 3;
197+ self.standing.shame += 3;
200198 Err(Problem::new(
201199 StatusCode::UNAUTHORIZED,
202200 "housekeeping",
Modified file: src/game/household.rs+190 additions−119 deletions
@@ -3,36 +3,85 @@ use std::collections::HashMap;
33 use axum::http::{StatusCode, header};
44
55 use super::{
6- Cause, Floor, Run,
6+ Cause, Floor, Notice, Run,
77 balance::{PATIENCE, VIRAL_ERRAND},
8- parse_credentials,
8+ credentials::{Scheme, parse_credentials},
99 };
1010 use crate::problem::Problem;
1111
12-const EXCUSES: [&str; 7] = [
13- "changing",
14- "homework",
15- "pulled a muscle",
16- "tidying",
17- "the washing up",
18- "fed the cat",
19- "revising",
20-];
21-const CHRISTMAS_EXCUSES: [&str; 6] = [
22- "on a call",
23- "wrapping presents",
24- "folding the washing",
25- "hanging the lights",
26- "helping Nan",
27- "peeling sprouts",
28-];
12+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13+pub enum Excuse {
14+ Changing,
15+ Homework,
16+ PulledAMuscle,
17+ Tidying,
18+ TheWashingUp,
19+ FedTheCat,
20+ Revising,
21+ OnACall,
22+ WrappingPresents,
23+ FoldingTheWashing,
24+ HangingTheLights,
25+ HelpingNan,
26+ PeelingSprouts,
27+}
28+
29+impl Excuse {
30+ const EVERYDAY: [Self; 7] = [
31+ Self::Changing,
32+ Self::Homework,
33+ Self::PulledAMuscle,
34+ Self::Tidying,
35+ Self::TheWashingUp,
36+ Self::FedTheCat,
37+ Self::Revising,
38+ ];
39+ const CHRISTMAS: [Self; 6] = [
40+ Self::OnACall,
41+ Self::WrappingPresents,
42+ Self::FoldingTheWashing,
43+ Self::HangingTheLights,
44+ Self::HelpingNan,
45+ Self::PeelingSprouts,
46+ ];
47+
48+ pub fn phrase(self) -> &'static str {
49+ match self {
50+ Self::Changing => "changing",
51+ Self::Homework => "homework",
52+ Self::PulledAMuscle => "pulled a muscle",
53+ Self::Tidying => "tidying",
54+ Self::TheWashingUp => "the washing up",
55+ Self::FedTheCat => "fed the cat",
56+ Self::Revising => "revising",
57+ Self::OnACall => "on a call",
58+ Self::WrappingPresents => "wrapping presents",
59+ Self::FoldingTheWashing => "folding the washing",
60+ Self::HangingTheLights => "hanging the lights",
61+ Self::HelpingNan => "helping Nan",
62+ Self::PeelingSprouts => "peeling sprouts",
63+ }
64+ }
65+
66+ fn all() -> impl Iterator<Item = Self> {
67+ Self::EVERYDAY.into_iter().chain(Self::CHRISTMAS)
68+ }
69+
70+ fn parse(spoken: &str) -> Option<Self> {
71+ Self::all().find(|excuse| excuse.phrase().eq_ignore_ascii_case(spoken))
72+ }
73+}
74+
2975 /// Every excuse Mum might hear, so a localised one can be traced back to the phrase she checks.
3076 pub fn vocabulary() -> impl Iterator<Item = &'static str> {
31- EXCUSES.into_iter().chain(CHRISTMAS_EXCUSES)
77+ Excuse::all().map(Excuse::phrase)
3278 }
3379
34-const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];
35-const TRUE_WHEN_PULLED: &str = "pulled a muscle";
80+fn is_truth(spoken: &str) -> bool {
81+ ["the truth", "truth", "honesty"]
82+ .iter()
83+ .any(|truth| truth.eq_ignore_ascii_case(spoken))
84+}
3685
3786 const BEDROOM_ERRANDS: [&str; 4] = [
3887 "She has brought your washing.",
@@ -47,6 +96,21 @@ const CHRISTMAS_ERRANDS: [&str; 4] = [
4796 "An uncle has asked where you went.",
4897 ];
4998
99+#[derive(Default)]
100+pub struct Household {
101+ pub(crate) mum: Option<MumVisit>,
102+ /// Every excuse Mum has accepted this run. Digest Mum remembers them all.
103+ pub(crate) heard: Vec<Excuse>,
104+ pub(crate) kitchen_tellings: u8,
105+}
106+
107+pub struct MumVisit {
108+ pub(crate) errand: &'static str,
109+ pub(crate) nonce: String,
110+ pub(crate) strikes: u8,
111+ pub(crate) knocked: bool,
112+}
113+
50114 fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
51115 let (scheme, rest) = authorization.trim().split_once(' ')?;
52116 if !scheme.eq_ignore_ascii_case("digest") {
@@ -65,22 +129,23 @@ fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
65129 )
66130 }
67131
68-fn spoken(authorization: &str) -> String {
132+fn spoken(authorization: &str) -> &str {
69133 authorization
70134 .split_once(' ')
71- .map(|(_, rest)| rest.trim().trim_matches('"').to_ascii_lowercase())
72- .unwrap_or_default()
135+ .map_or("", |(_, rest)| rest.trim().trim_matches('"'))
136+}
137+
138+fn turned_away(code: &'static str, detail: &'static str) -> Problem {
139+ Problem::new(StatusCode::UNAUTHORIZED, code, detail)
73140 }
74141
75142 impl Run {
76- pub fn excuses(&self) -> Vec<&'static str> {
143+ pub fn excuses(&self) -> Vec<Excuse> {
77144 if self.floor.number() < Floor::Nans.number() {
78- return EXCUSES.to_vec();
145+ return Excuse::EVERYDAY.to_vec();
79146 }
80- EXCUSES
81- .into_iter()
82- .chain(CHRISTMAS_EXCUSES)
83- .filter(|e| !self.heard.iter().any(|h| h.eq_ignore_ascii_case(e)))
147+ Excuse::all()
148+ .filter(|excuse| !self.household.heard.contains(excuse))
84149 .collect()
85150 }
86151
@@ -88,7 +153,7 @@ impl Run {
88153 if summoned {
89154 return "She heard a noise.";
90155 }
91- if self.filmed_at.is_some()
156+ if self.online.filmed_at.is_some()
92157 && self.chance(
93158 self.decision("viral", "Has a neighbour already sent Mum the footage?"),
94159 VIRAL_ERRAND,
@@ -112,114 +177,121 @@ impl Run {
112177
113178 /// Mum answers every request on the run until she has been given something she believes.
114179 pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
115- let Some((errand, nonce)) = self.mum.as_ref().map(|v| (v.errand, v.nonce.clone())) else {
180+ let Some(visit) = &self.household.mum else {
116181 return Ok(());
117182 };
118183 let digest = self.floor == Floor::Nans;
119- let challenge_value = if digest {
120- format!("Digest realm=\"household\", nonce=\"{nonce}\"")
184+ let challenge = if digest {
185+ format!("Digest realm=\"household\", nonce=\"{}\"", visit.nonce)
121186 } else {
122187 "Excuse realm=\"household\"".to_owned()
123188 };
124- let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);
125-
126- let Some(authorization) = authorization else {
127- let knocked_before = self.mum.as_ref().is_some_and(|visit| visit.knocked);
128- if let Some(visit) = &mut self.mum {
129- visit.knocked = true;
130- }
131- if !knocked_before {
132- self.touch();
133- return Err(challenge(
134- Problem::new(
135- StatusCode::UNAUTHORIZED,
136- "someone-at-the-door",
137- format!("Someone is at the door. {errand}"),
138- )
139- .with("inventory", self.path("/inventory")),
140- ));
141- }
142- return Err(self.strike(
143- challenge(Problem::new(
144- StatusCode::UNAUTHORIZED,
145- "knocking-again",
146- format!("Mum is knocking again. {errand}"),
147- )),
148- Cause::Opened,
149- ));
189+ let result = match authorization {
190+ None => Err(self.knock()),
191+ Some(authorization) => self.hear(authorization, digest),
192+ };
193+ if self.ended.is_some() {
194+ return result;
195+ }
196+ result.map_err(|problem| problem.header(header::WWW_AUTHENTICATE, &challenge))
197+ }
198+
199+ /// The first knock is free. Every one after it is a strike.
200+ fn knock(&mut self) -> Problem {
201+ let Some(visit) = &mut self.household.mum else {
202+ unreachable!("knock is only called while Mum is at the door");
150203 };
204+ let errand = visit.errand;
205+ if !std::mem::replace(&mut visit.knocked, true) {
206+ self.touch();
207+ return Problem::new(
208+ StatusCode::UNAUTHORIZED,
209+ "someone-at-the-door",
210+ format!("Someone is at the door. {errand}"),
211+ )
212+ .with("inventory", self.path("/inventory"));
213+ }
214+ self.strike(
215+ Problem::new(
216+ StatusCode::UNAUTHORIZED,
217+ "knocking-again",
218+ format!("Mum is knocking again. {errand}"),
219+ ),
220+ Cause::Opened,
221+ )
222+ }
151223
152- if TRUTHS.contains(&spoken(authorization).as_str()) {
153- self.shame += 10;
154- self.leave_door();
224+ fn hear(&mut self, authorization: &str, digest: bool) -> Result<(), Problem> {
225+ if is_truth(spoken(authorization)) {
226+ self.confess();
155227 return Ok(());
156228 }
157- if parse_credentials(authorization, "basic").is_some() {
158- return Err(self.mum_strike(challenge(Problem::new(
159- StatusCode::UNAUTHORIZED,
229+ if parse_credentials(authorization, Scheme::Basic).is_some() {
230+ return Err(self.mum_strike(turned_away(
160231 "basic",
161232 "Mum does not accept Basic authentication. Nobody should.",
162- ))));
233+ )));
163234 }
164-
165- let excuse = if digest {
166- let Some(params) = digest_params(authorization) else {
167- return Err(self.mum_strike(challenge(Problem::new(
168- StatusCode::UNAUTHORIZED,
169- "digest-required",
170- "Mum has started using Digest authentication.",
171- ))));
172- };
173- if params.get("nonce") != Some(&nonce) {
174- return Err(self.mum_strike(challenge(Problem::new(
175- StatusCode::UNAUTHORIZED,
176- "stale-nonce",
177- "That was a different conversation.",
178- ))));
179- }
180- params.get("username").cloned().unwrap_or_default()
235+ let said = if digest {
236+ self.digest_username(authorization)?
181237 } else {
182- parse_credentials(authorization, "excuse")
238+ parse_credentials(authorization, Scheme::Excuse)
183239 .unwrap_or_default()
184240 .to_owned()
185241 };
186- let excuse = excuse.to_ascii_lowercase();
187-
188- if TRUTHS.contains(&excuse.as_str()) {
189- self.shame += 10;
190- self.leave_door();
242+ if is_truth(&said) {
243+ self.confess();
191244 return Ok(());
192245 }
193- let true_now = self.conditions.pulled_muscle && excuse == TRUE_WHEN_PULLED;
194- if digest && self.heard.contains(&excuse) && !true_now {
195- self.shame += 3;
196- return Err(self.mum_strike(challenge(Problem::new(
197- StatusCode::UNAUTHORIZED,
246+ let excuse = Excuse::parse(&said);
247+ let true_now = self.body.conditions.pulled_muscle && excuse == Some(Excuse::PulledAMuscle);
248+ if digest && !true_now && excuse.is_some_and(|e| self.household.heard.contains(&e)) {
249+ self.standing.shame += 3;
250+ return Err(self.mum_strike(turned_away(
198251 "excuse-replayed",
199252 "Mum has heard that one before. She would have preferred the truth.",
200- ))));
253+ )));
201254 }
202- if true_now
203- || self
204- .excuses()
205- .iter()
206- .any(|e| e.eq_ignore_ascii_case(&excuse))
207- {
208- self.heard.push(excuse);
255+ if let Some(excuse) = excuse.filter(|e| true_now || self.excuses().contains(e)) {
256+ self.household.heard.push(excuse);
209257 self.leave_door();
210258 return Ok(());
211259 }
212- self.shame += 3;
213- let detail = if digest {
214- "Mum has not heard that one before. She does not believe it. She would have preferred the truth."
215- } else {
216- "Mum has not heard that one before. She does not believe it."
217- };
218- Err(self.mum_strike(challenge(Problem::new(
219- StatusCode::UNAUTHORIZED,
260+ self.standing.shame += 3;
261+ Err(self.mum_strike(turned_away(
220262 "excuse-rejected",
221- detail,
222- ))))
263+ if digest {
264+ "Mum has not heard that one before. She does not believe it. She would have preferred the truth."
265+ } else {
266+ "Mum has not heard that one before. She does not believe it."
267+ },
268+ )))
269+ }
270+
271+ fn digest_username(&mut self, authorization: &str) -> Result<String, Problem> {
272+ let Some(mut params) = digest_params(authorization) else {
273+ return Err(self.mum_strike(turned_away(
274+ "digest-required",
275+ "Mum has started using Digest authentication.",
276+ )));
277+ };
278+ let current = self
279+ .household
280+ .mum
281+ .as_ref()
282+ .is_some_and(|visit| params.get("nonce") == Some(&visit.nonce));
283+ if !current {
284+ return Err(self.mum_strike(turned_away(
285+ "stale-nonce",
286+ "That was a different conversation.",
287+ )));
288+ }
289+ Ok(params.remove("username").unwrap_or_default())
290+ }
291+
292+ fn confess(&mut self) {
293+ self.standing.shame += 10;
294+ self.leave_door();
223295 }
224296
225297 fn mum_strike(&mut self, problem: Problem) -> Problem {
@@ -228,7 +300,7 @@ impl Run {
228300
229301 fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {
230302 self.touch();
231- let Some(visit) = &mut self.mum else {
303+ let Some(visit) = &mut self.household.mum else {
232304 return problem;
233305 };
234306 visit.strikes += 1;
@@ -239,10 +311,9 @@ impl Run {
239311 }
240312
241313 fn leave_door(&mut self) {
242- self.mum = None;
243- self.suspicion = 0;
244- self.notices
245- .push("Mum seems satisfied. She takes the washing with her.");
314+ self.household.mum = None;
315+ self.standing.suspicion = 0;
316+ self.notices.push(Notice::MumSatisfied);
246317 self.touch();
247318 }
248319 }
Added file: src/game/inventory.rs+149 additions
@@ -0,0 +1,149 @@
1+use super::{Item, Lube, balance::STARTING_ICE_PACKS};
2+
3+#[derive(Default)]
4+pub struct Items {
5+ pub(crate) kit: Kit,
6+ pub(crate) stores: Stores,
7+ pub(crate) lubes: LubeStock,
8+}
9+
10+#[derive(Default)]
11+pub struct Kit {
12+ pub(crate) ruler: bool,
13+ pub(crate) dnd_sign: bool,
14+ pub(crate) blinds: bool,
15+ pub(crate) pump_pro: bool,
16+ pub(crate) extender: bool,
17+ pub(crate) ring: bool,
18+ pub(crate) cage: bool,
19+ pub(crate) spreadsheet: bool,
20+ pub(crate) teapot_spout: bool,
21+}
22+
23+pub struct Stores {
24+ pub(crate) ice_packs: u32,
25+ pub(crate) door_locks: u32,
26+ pub(crate) supplements: u32,
27+ pub(crate) energy_drinks: u32,
28+}
29+
30+impl Default for Stores {
31+ fn default() -> Self {
32+ Self {
33+ ice_packs: STARTING_ICE_PACKS,
34+ door_locks: 0,
35+ supplements: 0,
36+ energy_drinks: 0,
37+ }
38+ }
39+}
40+
41+pub struct LubeStock {
42+ pub(crate) baby_oil: u32,
43+ pub(crate) coconut_oil: u32,
44+ pub(crate) lotion: u32,
45+ pub(crate) vaseline: u32,
46+}
47+
48+impl Default for LubeStock {
49+ fn default() -> Self {
50+ Self {
51+ baby_oil: 1,
52+ coconut_oil: 0,
53+ lotion: 1,
54+ vaseline: 0,
55+ }
56+ }
57+}
58+
59+impl LubeStock {
60+ /// Spit is the only lubricant that never runs out, so it has no count.
61+ fn bottle(&mut self, lube: Lube) -> Option<&mut u32> {
62+ match lube {
63+ Lube::Spit => None,
64+ Lube::BabyOil => Some(&mut self.baby_oil),
65+ Lube::CoconutOil => Some(&mut self.coconut_oil),
66+ Lube::Lotion => Some(&mut self.lotion),
67+ Lube::Vaseline => Some(&mut self.vaseline),
68+ }
69+ }
70+
71+ pub fn has(&self, lube: Lube) -> bool {
72+ match lube {
73+ Lube::Spit => true,
74+ Lube::BabyOil => self.baby_oil > 0,
75+ Lube::CoconutOil => self.coconut_oil > 0,
76+ Lube::Lotion => self.lotion > 0,
77+ Lube::Vaseline => self.vaseline > 0,
78+ }
79+ }
80+
81+ pub fn take(&mut self, lube: Lube) {
82+ if let Some(bottle) = self.bottle(lube) {
83+ *bottle = bottle.saturating_sub(1);
84+ }
85+ }
86+
87+ fn add(&mut self, lube: Lube) {
88+ if let Some(bottle) = self.bottle(lube) {
89+ *bottle += 1;
90+ }
91+ }
92+}
93+
94+impl Items {
95+ pub fn has(&self, item: Item) -> bool {
96+ match item {
97+ Item::WarmTowel => true,
98+ Item::IcePack => self.stores.ice_packs > 0,
99+ Item::Ruler => self.kit.ruler,
100+ Item::DoorLock => self.stores.door_locks > 0,
101+ Item::DndSign => self.kit.dnd_sign,
102+ Item::Blinds => self.kit.blinds,
103+ Item::PumpPro => self.kit.pump_pro,
104+ Item::Extender => self.kit.extender,
105+ Item::Ring => self.kit.ring,
106+ Item::Supplement => self.stores.supplements > 0,
107+ Item::Cage => self.kit.cage,
108+ Item::Spreadsheet => self.kit.spreadsheet,
109+ Item::TeapotSpout => self.kit.teapot_spout,
110+ Item::EnergyDrink => self.stores.energy_drinks > 0,
111+ Item::BabyOil => self.lubes.has(Lube::BabyOil),
112+ Item::CoconutOil => self.lubes.has(Lube::CoconutOil),
113+ Item::Lotion => self.lubes.has(Lube::Lotion),
114+ Item::Vaseline => self.lubes.has(Lube::Vaseline),
115+ Item::Pump | Item::CageKey => false,
116+ }
117+ }
118+
119+ pub fn owned(&self) -> Vec<Item> {
120+ Item::ALL
121+ .into_iter()
122+ .filter(|&item| self.has(item))
123+ .collect()
124+ }
125+
126+ pub fn grant(&mut self, item: Item) {
127+ match item {
128+ Item::IcePack => self.stores.ice_packs += 1,
129+ Item::Ruler => self.kit.ruler = true,
130+ Item::DoorLock => self.stores.door_locks += 1,
131+ Item::DndSign => self.kit.dnd_sign = true,
132+ Item::Blinds => self.kit.blinds = true,
133+ Item::PumpPro => self.kit.pump_pro = true,
134+ Item::Extender => self.kit.extender = true,
135+ Item::Ring => self.kit.ring = true,
136+ Item::Supplement => self.stores.supplements += 1,
137+ Item::Cage => self.kit.cage = true,
138+ Item::CageKey => self.kit.cage = false,
139+ Item::Spreadsheet => self.kit.spreadsheet = true,
140+ Item::TeapotSpout => self.kit.teapot_spout = true,
141+ Item::EnergyDrink => self.stores.energy_drinks += 1,
142+ Item::BabyOil => self.lubes.add(Lube::BabyOil),
143+ Item::CoconutOil => self.lubes.add(Lube::CoconutOil),
144+ Item::Lotion => self.lubes.add(Lube::Lotion),
145+ Item::Vaseline => self.lubes.add(Lube::Vaseline),
146+ Item::WarmTowel | Item::Pump => {}
147+ }
148+ }
149+}
Modified file: src/game/items.rs+1 additions−104 deletions
@@ -1,5 +1,3 @@
1-use super::balance::STARTING_ICE_PACKS;
2-
31 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
42 pub enum Item {
53 WarmTowel,
@@ -43,7 +41,7 @@ pub const SHOP: [Item; 15] = [
4341 ];
4442
4543 impl Item {
46- const ALL: [Self; 20] = [
44+ pub(super) const ALL: [Self; 20] = [
4745 Self::WarmTowel,
4846 Self::IcePack,
4947 Self::Ruler,
@@ -156,104 +154,3 @@ impl Item {
156154 }
157155 }
158156 }
159-
160-pub struct Items {
161- pub ruler: bool,
162- pub ice_packs: u32,
163- pub door_locks: u32,
164- pub dnd_sign: bool,
165- pub blinds: bool,
166- pub pump_pro: bool,
167- pub extender: bool,
168- pub ring: bool,
169- pub supplements: u32,
170- pub cage: bool,
171- pub spreadsheet: bool,
172- pub teapot_spout: bool,
173- pub baby_oil: u32,
174- pub coconut_oil: u32,
175- pub lotion: u32,
176- pub vaseline: u32,
177- pub energy_drinks: u32,
178-}
179-
180-impl Default for Items {
181- fn default() -> Self {
182- Self {
183- ruler: false,
184- ice_packs: STARTING_ICE_PACKS,
185- door_locks: 0,
186- dnd_sign: false,
187- blinds: false,
188- pump_pro: false,
189- extender: false,
190- ring: false,
191- supplements: 0,
192- cage: false,
193- spreadsheet: false,
194- teapot_spout: false,
195- baby_oil: 1,
196- coconut_oil: 0,
197- lotion: 1,
198- vaseline: 0,
199- energy_drinks: 0,
200- }
201- }
202-}
203-
204-impl Items {
205- pub fn has(&self, item: Item) -> bool {
206- match item {
207- Item::WarmTowel => true,
208- Item::IcePack => self.ice_packs > 0,
209- Item::Ruler => self.ruler,
210- Item::DoorLock => self.door_locks > 0,
211- Item::DndSign => self.dnd_sign,
212- Item::Blinds => self.blinds,
213- Item::PumpPro => self.pump_pro,
214- Item::Extender => self.extender,
215- Item::Ring => self.ring,
216- Item::Supplement => self.supplements > 0,
217- Item::Cage => self.cage,
218- Item::Spreadsheet => self.spreadsheet,
219- Item::TeapotSpout => self.teapot_spout,
220- Item::BabyOil => self.baby_oil > 0,
221- Item::CoconutOil => self.coconut_oil > 0,
222- Item::Lotion => self.lotion > 0,
223- Item::Vaseline => self.vaseline > 0,
224- Item::EnergyDrink => self.energy_drinks > 0,
225- Item::Pump | Item::CageKey => false,
226- }
227- }
228-
229- pub fn owned(&self) -> Vec<Item> {
230- Item::ALL
231- .into_iter()
232- .filter(|&item| self.has(item))
233- .collect()
234- }
235-
236- pub fn grant(&mut self, item: Item) {
237- match item {
238- Item::IcePack => self.ice_packs += 1,
239- Item::Ruler => self.ruler = true,
240- Item::DoorLock => self.door_locks += 1,
241- Item::DndSign => self.dnd_sign = true,
242- Item::Blinds => self.blinds = true,
243- Item::PumpPro => self.pump_pro = true,
244- Item::Extender => self.extender = true,
245- Item::Ring => self.ring = true,
246- Item::Supplement => self.supplements += 1,
247- Item::Cage => self.cage = true,
248- Item::CageKey => self.cage = false,
249- Item::Spreadsheet => self.spreadsheet = true,
250- Item::TeapotSpout => self.teapot_spout = true,
251- Item::BabyOil => self.baby_oil += 1,
252- Item::CoconutOil => self.coconut_oil += 1,
253- Item::Lotion => self.lotion += 1,
254- Item::Vaseline => self.vaseline += 1,
255- Item::EnergyDrink => self.energy_drinks += 1,
256- Item::WarmTowel | Item::Pump => {}
257- }
258- }
259-}
Modified file: src/game/kitchen.rs+18 additions−22 deletions
@@ -2,10 +2,10 @@ use axum::http::{StatusCode, header};
22 use serde_json::json;
33
44 use super::{
5- Cause, Floor, Item, Outcome, Run, Story,
5+ Cause, Floor, Item, Notice, Outcome, Run, Story,
66 balance::{
77 CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,
8- KITCHEN_TOLD, MELON_SHAME, RAID_NOISE, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,
8+ KITCHEN_TOLD, MELON_SHAME, RAID_NOISE, RUPTURE_LINE, WATERMELON,
99 },
1010 };
1111 use crate::problem::Problem;
@@ -20,27 +20,23 @@ impl Run {
2020 "There is no kitchen on this floor.",
2121 ));
2222 }
23- if self.kitchen_tellings >= KITCHEN_TELLINGS {
23+ if self.household.kitchen_tellings >= KITCHEN_TELLINGS {
2424 return Err(Problem::new(
2525 StatusCode::GONE,
2626 "kitchen-off-limits",
2727 "The kitchen is off limits. You have been told twice.",
2828 ));
2929 }
30- if self.story == Story::Pending {
30+ if self.online.story == Story::Pending {
3131 return Ok(self.branch_offer());
3232 }
33- self.touch();
34- self.turn += 1;
35- self.here.turns += 1;
36- self.realise();
33+ self.begin_turn();
3734 if self.chance(
3835 self.decision("rupture", "Does this raid of the fridge rupture something?"),
3936 self.rupture_odds(),
4037 ) {
4138 return Err(self.rupture());
4239 }
43- let was_below = self.suspicion < SUSPICION_LIMIT;
4440 self.make_noise(RAID_NOISE);
4541 // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.
4642 let loot = self.oracle.pick(
@@ -63,7 +59,7 @@ impl Run {
6359 "Nothing. The fridge is ordinary.",
6460 ],
6561 );
66- let melon = self.eq > RUPTURE_LINE
62+ let melon = self.body.eq > RUPTURE_LINE
6763 && self.oracle.ratio(
6864 self.decision(
6965 "watermelon",
@@ -72,17 +68,17 @@ impl Run {
7268 WATERMELON.0,
7369 WATERMELON.1,
7470 );
75- self.settle(was_below);
71+ self.settle();
7672 if self.oracle.ratio(
7773 self.decision(
7874 "kitchen-told",
7975 "Has the household had enough of the raiding?",
8076 ),
81- u32::from(self.suspicion) + 1,
77+ u32::from(self.standing.suspicion) + 1,
8278 KITCHEN_TOLD,
8379 ) {
84- self.kitchen_tellings += 1;
85- if self.kitchen_tellings >= KITCHEN_TELLINGS {
80+ self.household.kitchen_tellings += 1;
81+ if self.household.kitchen_tellings >= KITCHEN_TELLINGS {
8682 self.end_kitchen_visit();
8783 return Err(Problem::new(
8884 StatusCode::GONE,
@@ -91,13 +87,13 @@ impl Run {
9187 ));
9288 }
9389 self.notices.push(if self.floor == Floor::Nans {
94- "Stop raiding the fridge."
90+ Notice::StopRaidingTheFridge
9591 } else {
96- "Stop raiding the kitchen."
92+ Notice::StopRaidingTheKitchen
9793 });
9894 }
9995 if melon {
100- self.here.melon_offered = true;
96+ self.here.kitchen.melon_offered = true;
10197 return Ok(self.melon_offer());
10298 }
10399 Ok(self.unpack(loot))
@@ -115,7 +111,7 @@ impl Run {
115111 ),
116112 2 => (Item::IcePack, "An ice pack, buried under the peas."),
117113 3 => {
118- self.here.kettle_boiled = true;
114+ self.here.kitchen.kettle_boiled = true;
119115 return Outcome::json(
120116 StatusCode::OK,
121117 json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),
@@ -149,23 +145,23 @@ impl Run {
149145
150146 /// A bail-out that costs no turn. The more the household has already heard, the likelier Mum is in the doorway.
151147 pub fn commit_melon(&mut self) -> Result<Outcome, Problem> {
152- if !self.here.melon_offered {
148+ if !self.here.kitchen.melon_offered {
153149 return Err(Problem::new(
154150 StatusCode::CONFLICT,
155151 "no-melon-on-offer",
156152 "There is no watermelon on offer.",
157153 ));
158154 }
159- self.here.melon_offered = false;
155+ self.here.kitchen.melon_offered = false;
160156 self.reset_eq(0);
161- self.shame += MELON_SHAME;
157+ self.standing.shame += MELON_SHAME;
162158 self.touch();
163159 if self.oracle.ratio(
164160 self.decision(
165161 "caught-meloned",
166162 "Is Mum standing in the doorway as you look up?",
167163 ),
168- u32::from(self.suspicion) + 2,
164+ u32::from(self.standing.suspicion) + 2,
169165 CAUGHT_MELONED,
170166 ) {
171167 return Err(self.end(Cause::Meloned));
Added file: src/game/lube.rs+90 additions
@@ -0,0 +1,90 @@
1+use axum::http::StatusCode;
2+use serde_json::json;
3+
4+use super::Item;
5+use crate::problem::Problem;
6+
7+pub const LUBRICANTS: [&str; 5] = [
8+ "application/baby-oil",
9+ "application/coconut-oil",
10+ "application/lotion",
11+ "application/vaseline",
12+ "text/plain; charset=spit",
13+];
14+
15+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16+pub enum Lube {
17+ BabyOil,
18+ CoconutOil,
19+ Lotion,
20+ Vaseline,
21+ Spit,
22+}
23+
24+impl Lube {
25+ pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
26+ let Some(raw) = content_type else {
27+ return Ok(None);
28+ };
29+ let lowered = raw.to_ascii_lowercase();
30+ let mut parts = lowered.split(';').map(str::trim);
31+ let essence = parts.next().unwrap_or_default();
32+ let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
33+ match essence {
34+ "application/baby-oil" => Ok(Some(Self::BabyOil)),
35+ "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
36+ "application/lotion" => Ok(Some(Self::Lotion)),
37+ "application/vaseline" => Ok(Some(Self::Vaseline)),
38+ "text/plain" if spit => Ok(Some(Self::Spit)),
39+ e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
40+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
41+ "no",
42+ "No.",
43+ )),
44+ _ => Err(Problem::new(
45+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
46+ "unsupported-lubricant",
47+ format!("{essence} is not a lubricant."),
48+ )
49+ .with("lubricants", json!(LUBRICANTS))),
50+ }
51+ }
52+
53+ pub fn modifier(self) -> i32 {
54+ match self {
55+ Self::BabyOil => 1,
56+ Self::CoconutOil => 3,
57+ Self::Lotion => 0,
58+ Self::Vaseline => 2,
59+ Self::Spit => -2,
60+ }
61+ }
62+
63+ /// The bottle this lube comes in. Spit has none and never runs out.
64+ pub fn item(self) -> Option<Item> {
65+ match self {
66+ Self::Spit => None,
67+ Self::BabyOil => Some(Item::BabyOil),
68+ Self::CoconutOil => Some(Item::CoconutOil),
69+ Self::Lotion => Some(Item::Lotion),
70+ Self::Vaseline => Some(Item::Vaseline),
71+ }
72+ }
73+}
74+
75+#[derive(Clone, Copy)]
76+pub enum LubeRule {
77+ Required,
78+ Forbidden,
79+ Optional,
80+}
81+
82+impl LubeRule {
83+ pub(super) fn name(self) -> &'static str {
84+ match self {
85+ Self::Required => "required",
86+ Self::Forbidden => "forbidden",
87+ Self::Optional => "optional",
88+ }
89+ }
90+}
Added file: src/game/measure.rs+134 additions
@@ -0,0 +1,134 @@
1+use axum::http::StatusCode;
2+use serde::Deserialize;
3+use serde_json::{Value, json};
4+
5+use super::{
6+ Outcome, Run,
7+ balance::{EXAGGERATION, HONEST_TOLERANCE, PATCH_EQ, PUT_GAIN},
8+};
9+use crate::problem::Problem;
10+
11+pub fn round2(x: f64) -> f64 {
12+ (x * 100.0).round() / 100.0
13+}
14+
15+pub(super) fn round3(x: f64) -> f64 {
16+ (x * 1000.0).round() / 1000.0
17+}
18+
19+pub(super) fn honest(claimed: f64, actual: f64) -> bool {
20+ (claimed - actual).abs() < HONEST_TOLERANCE
21+}
22+
23+/// A cylinder whose circumference is the girth.
24+pub fn volume(length: f64, girth: f64) -> f64 {
25+ girth * girth * length / (4.0 * std::f64::consts::PI)
26+}
27+
28+pub fn shaft(mm: usize) -> String {
29+ format!("8{}D", "=".repeat(mm.saturating_sub(2)))
30+}
31+
32+pub(super) fn exaggeration() -> Problem {
33+ Problem::new(
34+ StatusCode::BAD_REQUEST,
35+ "exaggeration",
36+ "The server has measured. Lying is not a technique.",
37+ )
38+}
39+
40+/// One JSON Patch operation as sent, before the server decides what it will honour.
41+#[derive(Deserialize)]
42+pub struct PatchOp {
43+ op: String,
44+ path: String,
45+ #[serde(default)]
46+ value: Value,
47+}
48+
49+#[derive(Clone, Copy, PartialEq, Eq)]
50+enum Measurement {
51+ Length,
52+ Girth,
53+ Foreskin,
54+}
55+
56+struct Claim {
57+ measurement: Measurement,
58+ value: f64,
59+}
60+
61+impl PatchOp {
62+ fn claim(&self) -> Result<Claim, Problem> {
63+ if self.op != "test" {
64+ return Err(Problem::new(
65+ StatusCode::UNPROCESSABLE_ENTITY,
66+ "wishful-thinking",
67+ "The server accepts only the truth.",
68+ )
69+ .with("op", self.op.clone()));
70+ }
71+ let measurement = match self.path.as_str() {
72+ "/length" => Measurement::Length,
73+ "/girth" => Measurement::Girth,
74+ "/foreskin" => Measurement::Foreskin,
75+ _ => {
76+ return Err(Problem::new(
77+ StatusCode::UNPROCESSABLE_ENTITY,
78+ "no-such-measurement",
79+ "There is no such measurement.",
80+ )
81+ .with("path", self.path.clone()));
82+ }
83+ };
84+ let value = self.value.as_f64().ok_or_else(|| {
85+ Problem::new(
86+ StatusCode::BAD_REQUEST,
87+ "malformed-patch",
88+ "A test requires a numeric value.",
89+ )
90+ })?;
91+ Ok(Claim { measurement, value })
92+ }
93+}
94+
95+impl Run {
96+ pub fn put(&mut self) -> Outcome {
97+ if self.body.put_applied {
98+ return Outcome::empty(StatusCode::NO_CONTENT);
99+ }
100+ self.body.put_applied = true;
101+ self.body.length += PUT_GAIN;
102+ self.touch();
103+ Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
104+ }
105+
106+ /// Being measured is not arousing, so every PATCH costs a little EQ whatever it finds.
107+ pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
108+ self.reset_eq(self.body.eq - PATCH_EQ);
109+ self.touch();
110+ for op in ops {
111+ let claim = op.claim()?;
112+ let actual = match claim.measurement {
113+ Measurement::Length => self.current_length(),
114+ Measurement::Girth => self.body.girth,
115+ Measurement::Foreskin => self.body.foreskin.unwrap_or(0.0),
116+ };
117+ if claim.measurement == Measurement::Length && claim.value >= actual + EXAGGERATION {
118+ self.standing.shame += 2;
119+ return Err(exaggeration());
120+ }
121+ if !honest(claim.value, actual) {
122+ self.standing.shame += 1;
123+ return Err(
124+ Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
125+ .with("path", op.path.clone()),
126+ );
127+ }
128+ }
129+ Ok(Outcome::json(
130+ StatusCode::OK,
131+ json!({ "detail": "Correct." }),
132+ ))
133+ }
134+}
Modified file: src/game/mod.rs+34 additions−435 deletions
@@ -1,450 +1,49 @@
11 pub mod balance;
22 mod body;
3+mod cause;
4+mod checks;
35 mod clinic;
4-mod floors;
6+mod credentials;
7+mod dispatch;
8+mod drawer;
9+mod floor;
510 mod forum;
11+mod gauges;
612 mod hazards;
713 mod household;
14+mod inventory;
815 mod items;
916 mod kitchen;
17+mod lube;
18+mod measure;
19+mod notice;
1020 pub(crate) mod oracle;
1121 mod outcome;
22+mod progress;
23+mod rest;
24+mod roll;
25+mod run;
1226 mod shop;
13-mod state;
27+mod standing;
1428 mod techniques;
15-mod turn;
29+mod travel;
1630 mod utility;
1731
18-pub use body::{PatchOp, volume};
19-pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
20-pub use household::vocabulary as excuse_vocabulary;
21-pub use items::{Item, Items, SHOP};
32+#[cfg(test)]
33+pub use body::Pending;
34+pub use body::{Body, Grip};
35+pub use cause::{Cause, Ending, RunStatus};
36+pub use clinic::Clinic;
37+pub use floor::{Floor, FloorState, Observer};
38+pub use forum::{Branch, ELDER_LAST_MODIFIED, Elder, FORUM_THREAD, Online, Story, forum_thread};
39+pub use gauges::{Gauges, Shown};
40+pub use household::{Excuse, Household, MumVisit, vocabulary as excuse_vocabulary};
41+pub use inventory::Items;
42+pub use items::{Item, SHOP};
43+pub use lube::{LUBRICANTS, Lube};
44+pub use measure::{PatchOp, shaft, volume};
45+pub use notice::Notice;
2246 pub use outcome::{Outcome, Payload};
23-pub use state::{
24- Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer,
25- RunStatus, Story,
26-};
27-pub use techniques::{LUBRICANTS, Lube, Technique};
28-
29-use std::{
30- hash::{DefaultHasher, Hash, Hasher},
31- time::{Instant, SystemTime},
32-};
33-
34-use axum::http::StatusCode;
35-use serde_json::{Value, json};
36-
37-use crate::problem::Problem;
38-use balance::{
39- BRANCH_TURN, HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
40- STARTING_CASH, STARTING_EQ,
41-};
42-use oracle::{Oracle, OracleTemplate};
43-use state::Pending;
44-
45-pub struct Run {
46- /// The private id in the run's URL. Knowing it is the only authorisation a run has.
47- pub id: String,
48- pub public_id: String,
49- etag_salt: u64,
50- pub version: u64,
51- pub turn: u32,
52- pub floor: Floor,
53- pub here: FloorState,
54- pub length: f64,
55- pub girth: f64,
56- pub foreskin: Option<f64>,
57- pub eq: i32,
58- pub stamina: i32,
59- pub suspicion: u8,
60- pub shame: u32,
61- pub shame_reported: u32,
62- pub shame_total: u32,
63- pub cash: u32,
64- pub grip: Grip,
65- pub friction: i32,
66- pub jelq_grip: Option<Grip>,
67- pub items: Items,
68- pub conditions: Conditions,
69- pub warmed_up: bool,
70- pub put_applied: bool,
71- pub story: Story,
72- pub uli_taught: bool,
73- pub kitchen_tellings: u8,
74- pub mum: Option<MumVisit>,
75- pub heard: Vec<String>,
76- pub elder: Elder,
77- pub clinic: Clinic,
78- pub seeded: bool,
79- pub filmed_at: Option<SystemTime>,
80- pub observed: Option<Observer>,
81- pub exhausted: bool,
82- pub pending: Vec<Pending>,
83- pub notices: Vec<&'static str>,
84- pub ended: Option<Ending>,
85- pub last_seen: Instant,
86- oracle: Oracle,
87-}
88-
89-pub fn round2(x: f64) -> f64 {
90- (x * 100.0).round() / 100.0
91-}
92-
93-fn round3(x: f64) -> f64 {
94- (x * 1000.0).round() / 1000.0
95-}
96-
97-fn honest(claimed: f64, actual: f64) -> bool {
98- (claimed - actual).abs() < HONEST_TOLERANCE
99-}
100-
101-fn not_extended() -> Problem {
102- Problem::new(
103- StatusCode::NOT_EXTENDED,
104- "not-extended",
105- "The requested extension was not applied. The server has recorded the attempt.",
106- )
107-}
108-
109-pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
110- let (given, rest) = authorization.trim().split_once(' ')?;
111- given
112- .eq_ignore_ascii_case(scheme)
113- .then(|| rest.trim().trim_matches('"'))
114-}
115-
116-pub fn etag_matches(condition: &str, etag: &str) -> bool {
117- condition
118- .split(',')
119- .map(str::trim)
120- .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
121-}
122-
123-pub fn shaft(mm: usize) -> String {
124- format!("8{}D", "=".repeat(mm.saturating_sub(2)))
125-}
126-
127-impl Run {
128- #[cfg(test)]
129- pub fn new(id: String, public_id: String, seed: u64) -> Self {
130- Self::with_oracle(id, public_id, seed, &OracleTemplate::Rng)
131- }
132-
133- pub fn with_oracle(
134- id: String,
135- public_id: String,
136- seed: u64,
137- template: &OracleTemplate,
138- ) -> Self {
139- let mut oracle = Oracle::build(template, seed);
140- let length = round2(oracle.span(
141- Run::unborn("length", "How long is it, in inches?"),
142- 4.5,
143- 6.5,
144- ));
145- let girth = round2(oracle.span(
146- Run::unborn("girth", "How thick is it, in inches?"),
147- 4.0,
148- 5.0,
149- ));
150- let foreskin = round2(oracle.span(
151- Run::unborn("foreskin", "How much foreskin is there, in inches?"),
152- 0.4,
153- 2.4,
154- ));
155- let etag_salt = oracle.u64();
156- Self {
157- id,
158- public_id,
159- etag_salt,
160- version: 0,
161- turn: 0,
162- floor: Floor::Bedroom,
163- here: FloorState::default(),
164- length,
165- girth,
166- foreskin: Some(foreskin),
167- eq: STARTING_EQ,
168- stamina: MAX_STAMINA,
169- suspicion: 0,
170- shame: 0,
171- shame_reported: 0,
172- shame_total: 0,
173- cash: STARTING_CASH,
174- grip: Grip::Overhand,
175- friction: 0,
176- jelq_grip: None,
177- items: Items::default(),
178- conditions: Conditions::default(),
179- warmed_up: false,
180- put_applied: false,
181- story: Story::Unopened,
182- uli_taught: false,
183- kitchen_tellings: 0,
184- mum: None,
185- heard: Vec::new(),
186- notices: Vec::new(),
187- elder: Elder::Unverified { strikes: 0 },
188- clinic: Clinic::default(),
189- seeded: false,
190- filmed_at: None,
191- observed: None,
192- exhausted: false,
193- pending: Vec::new(),
194- ended: None,
195- last_seen: Instant::now(),
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(),
206- }
207- }
208-
209- fn etag_at(&self, version: u64) -> String {
210- let mut hasher = DefaultHasher::new();
211- (self.etag_salt, version).hash(&mut hasher);
212- format!("\"{:016x}\"", hasher.finish())
213- }
214-
215- pub fn etag(&self) -> String {
216- self.etag_at(self.version)
217- }
218-
219- /// The ETag the run had when it was created, which the true ending asks for.
220- pub fn origin_etag(&self) -> String {
221- self.etag_at(0)
222- }
223-
224- pub fn touch(&mut self) {
225- self.version += 1;
226- }
227-
228- pub(super) fn shed(&mut self, friction: i32) {
229- self.friction = (self.friction - friction).max(0);
230- }
231-
232- /// Priapism holds EQ up no matter what else moves it.
233- pub(super) fn reset_eq(&mut self, eq: i32) {
234- let floor = if self.conditions.priapism_turns > 0 {
235- PRIAPISM_EQ
236- } else {
237- 0
238- };
239- self.eq = eq.max(floor);
240- }
241-
242- /// A melon offer stands only while the raiding streak continues. Any other action ends it.
243- pub(super) fn end_kitchen_visit(&mut self) {
244- self.here.melon_offered = false;
245- }
246-
247- /// Going back to the jelq walks away from both the fridge and Pump-Bro.
248- pub(super) fn step_away(&mut self) {
249- self.end_kitchen_visit();
250- self.here.shopping = false;
251- }
252-
253- fn fresh_nonce(&mut self) -> String {
254- format!("{:016x}", self.oracle.u64())
255- }
256-
257- /// One game decision for the oracle, phrased for the model and stamped
258- /// with the state as the player sees it.
259- fn decision(&self, site: &'static str, ask: impl std::fmt::Display) -> oracle::Roll {
260- oracle::Roll {
261- state: self.oracle_state(),
262- site,
263- ask: ask.to_string(),
264- }
265- }
266-
267- fn oracle_state(&self) -> String {
268- let mut s = String::new();
269- let place = match self.floor {
270- Floor::Bedroom => "the bedroom",
271- Floor::Bathroom => "the bathroom",
272- Floor::ChangingRoom => "the changing room",
273- Floor::Nans => "Nan's house",
274- Floor::Hotel => "the hotel room",
275- Floor::Forum => "the forum floor",
276- Floor::Clinic => "the clinic",
277- };
278- s.push_str(&format!(
279- "Floor {}, {place}. Turn {}.\n\n",
280- self.floor.number(),
281- self.turn
282- ));
283- s.push_str(&format!(
284- "eq: {}\nstamina: {}\nfriction: {}\n",
285- self.eq, self.stamina, self.friction
286- ));
287- if self.conditions.friction_burn {
288- s.push_str("friction burn: yes, untreated\n");
289- }
290- if self.conditions.priapism_turns > 0 {
291- s.push_str("priapism: yes\n");
292- }
293- if self.conditions.peyronie {
294- s.push_str("peyronie's disease: yes\n");
295- }
296- if self.conditions.pulled_muscle {
297- s.push_str("pulled muscle: yes\n");
298- }
299- s.push_str(&format!(
300- "suspicion: {}\nshame: {}\nwarmed up: {}\ngrip: {}\n",
301- self.suspicion,
302- self.shame,
303- if self.warmed_up { "yes" } else { "no" },
304- self.grip.name(),
305- ));
306- let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
307- if !held.is_empty() {
308- s.push_str(&format!("items: {}\n", held.join(", ")));
309- }
310- if self.mum.is_some() {
311- s.push_str("Mum is at the door\n");
312- }
313- s
314- }
315-
316- fn chance(&mut self, roll: oracle::Roll, odds: u32) -> bool {
317- self.oracle.chance(roll, odds)
318- }
319-
320- pub fn path(&self, sub: &str) -> String {
321- format!("/runs/{}{sub}", self.id)
322- }
323-
324- pub fn current_length(&self) -> f64 {
325- self.length + self.here.temp_length
326- }
327-
328- pub fn self_reported(&self) -> f64 {
329- self.current_length() + SELF_REPORT_INFLATION
330- }
331-
332- pub fn max_stamina(&self) -> i32 {
333- if self.conditions.pulled_muscle {
334- PULLED_STAMINA
335- } else {
336- MAX_STAMINA
337- }
338- }
339-
340- pub fn forum_read(&self) -> bool {
341- self.story == Story::ReadForum
342- }
343-
344- pub fn thread_reachable(&self) -> bool {
345- self.turn >= BRANCH_TURN
346- || self.forum_read()
347- || self.floor.number() >= Floor::Forum.number()
348- }
349-
350- pub fn footage(&self) -> Option<(SystemTime, u64)> {
351- let filmed = self.filmed_at?;
352- let age = filmed.elapsed().map_or(0, |d| d.as_secs());
353- Some((filmed, age))
354- }
355-
356- pub fn status(&self) -> RunStatus {
357- if self.ended.is_some() {
358- RunStatus::Gone
359- } else if self.clinic.cured {
360- RunStatus::Normal
361- } else if self.clinic.prescribed {
362- RunStatus::Prescribed
363- } else {
364- RunStatus::Alive
365- }
366- }
367-
368- /// The parts of the run that its response headers do not already carry.
369- pub fn view(&self) -> Value {
370- let conditions: Vec<&str> = [
371- (self.here.teapot, "short and stout"),
372- (self.conditions.peyronie, "curved"),
373- (self.items.cage, "caged"),
374- (self.conditions.priapism_turns > 0, "priapism"),
375- (self.conditions.pulled_muscle, "pulled muscle"),
376- (self.filmed_at.is_some(), "viral"),
377- ]
378- .into_iter()
379- .filter_map(|(active, name)| active.then_some(name))
380- .collect();
381- json!({
382- "public_id": self.public_id,
383- "status": self.status().name(),
384- "turn": self.turn,
385- "run_shame": self.shame,
386- "grip": self.grip.name(),
387- "warmed_up": self.warmed_up,
388- "ranked": !self.seeded,
389- "conditions": conditions,
390- })
391- }
392-
393- pub fn end(&mut self, cause: Cause) -> Problem {
394- self.ended = Some(Ending {
395- cause,
396- turn: self.turn,
397- });
398- cause.problem().with("turn", self.turn)
399- }
400-
401- /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.
402- fn recover(&mut self) {
403- self.exhausted = false;
404- self.stamina = self.max_stamina();
405- self.reset_eq(STARTING_EQ);
406- self.warmed_up = false;
407- }
408-
409- fn check_exhausted(&self) -> Result<(), Problem> {
410- if !self.exhausted {
411- return Ok(());
412- }
413- Err(Problem::new(
414- StatusCode::TOO_MANY_REQUESTS,
415- "overtrained",
416- "Stamina is exhausted. HOLD for the refractory period.",
417- ))
418- }
419-
420- fn exhaust(&mut self) {
421- self.stamina = 0;
422- self.exhausted = true;
423- }
424-
425- fn realise(&mut self) {
426- let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
427- .into_iter()
428- .partition(|p| p.due <= self.turn);
429- self.pending = waiting;
430- for p in due {
431- self.length += p.length;
432- self.girth += p.girth;
433- }
434- }
435-
436- fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
437- self.length += length;
438- self.girth += girth;
439- if let Some(f) = &mut self.foreskin {
440- *f += foreskin;
441- }
442- }
443-
444- fn rupture(&mut self) -> Problem {
445- let cause = Cause::Ruptured {
446- ice_unused: self.items.ice_packs > 0,
447- };
448- self.end(cause)
449- }
450-}
47+pub use run::Run;
48+pub use standing::Standing;
49+pub use techniques::Technique;
Added file: src/game/notice.rs+33 additions
@@ -0,0 +1,33 @@
1+/// Something that happened alongside the turn, which rides on the response as a `Warning`.
2+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
3+pub enum Notice {
4+ BrotherTriedTheDoor,
5+ GymStaffLeaveIt,
6+ HousekeepingReadTheSign,
7+ StopRaidingTheKitchen,
8+ StopRaidingTheFridge,
9+ KevIsTheBar,
10+ DrawerJammed,
11+ DrawerComesLoose,
12+ ThreadHasReplies,
13+ Reflection,
14+ MumSatisfied,
15+}
16+
17+impl Notice {
18+ pub fn text(self) -> &'static str {
19+ match self {
20+ Self::BrotherTriedTheDoor => "your brother tried the door",
21+ Self::GymStaffLeaveIt => "the gym staff have seen worse, and they leave it there",
22+ Self::HousekeepingReadTheSign => "housekeeping read the sign",
23+ Self::StopRaidingTheKitchen => "Stop raiding the kitchen.",
24+ Self::StopRaidingTheFridge => "Stop raiding the fridge.",
25+ Self::KevIsTheBar => "Kev is 8 inches, he says. Nobody asked you, which is worse.",
26+ Self::DrawerJammed => "The drawer has jammed.",
27+ Self::DrawerComesLoose => "The drawer comes loose. That took a while.",
28+ Self::ThreadHasReplies => "Somewhere, a forum thread from 2009 still has replies.",
29+ Self::Reflection => "the count reached ten, and then the reflection started",
30+ Self::MumSatisfied => "Mum seems satisfied. She takes the washing with her.",
31+ }
32+ }
33+}
Modified file: src/game/oracle.rs+11 additions−28 deletions
@@ -8,12 +8,10 @@
88 use rand::{Rng, SeedableRng, rngs::StdRng};
99 use serde_json::{Value, json};
1010
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.
1311 pub(crate) struct Roll {
1412 pub(crate) state: String,
1513 pub(crate) site: &'static str,
16- pub(crate) ask: String,
14+ pub(crate) ask: &'static str,
1715 }
1816
1917 pub(crate) enum Oracle {
@@ -26,12 +24,6 @@ pub(crate) enum OracleTemplate {
2624 Clm { url: String },
2725 }
2826
29-impl OracleTemplate {
30- pub(crate) fn blocks(&self) -> bool {
31- matches!(self, Self::Clm { .. })
32- }
33-}
34-
3527 pub(crate) struct Clm {
3628 url: String,
3729 agent: ureq::Agent,
@@ -55,12 +47,15 @@ impl Oracle {
5547 }
5648 }
5749
50+ pub(crate) fn listens(&self) -> bool {
51+ matches!(self, Self::Clm { .. })
52+ }
53+
5854 /// Identity randomness, never a game decision.
5955 pub(crate) fn u64(&mut self) -> u64 {
6056 self.technical().random()
6157 }
6258
63- /// A yes/no game roll with stated odds of 1 in `odds`.
6459 pub(crate) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
6560 match self {
6661 Self::Rng(rng) => rng.random_ratio(1, odds),
@@ -71,10 +66,7 @@ impl Oracle {
7166 }
7267 }
7368
74- /// A yes/no game roll where the ask describes the calm outcome, not the
75- /// event. The model believes dull statements more readily than rare
76- /// warnings, so the interrupts that read as drama get phrased as calm.
77- /// The rng arm still draws the event, never the calm.
69+ /// The model believes dull statements more readily than rare warnings, so the ask states the calm outcome.
7870 pub(crate) fn chance_not(&mut self, roll: Roll, odds: u32) -> bool {
7971 match self {
8072 Self::Rng(rng) => rng.random_ratio(1, odds),
@@ -85,10 +77,7 @@ impl Oracle {
8577 }
8678 }
8779
88- /// A yes/no game roll with general odds of `num` in `den`, put to the
89- /// model as a choice between the calm answer and the event. The noul head
90- /// answers interrupt questions with yes; the choice head, offered the two
91- /// answers side by side, does not.
80+ /// The noul head answers interrupt questions with yes, and the choice head, offered both answers, does not.
9281 pub(crate) fn ratio_choice(
9382 &mut self,
9483 roll: Roll,
@@ -100,13 +89,12 @@ impl Oracle {
10089 match self {
10190 Self::Rng(rng) => rng.random_ratio(num, den),
10291 Self::Clm { clm, .. } => {
103- let i = clm.choose(&roll.state, roll.site, &roll.ask, &[calm, event]);
92+ let i = clm.choose(&roll.state, roll.site, roll.ask, &[calm, event]);
10493 i == 1
10594 }
10695 }
10796 }
10897
109- /// A yes/no game roll with general odds of `num` in `den`.
11098 pub(crate) fn ratio(&mut self, roll: Roll, num: u32, den: u32) -> bool {
11199 match self {
112100 Self::Rng(rng) => rng.random_ratio(num, den),
@@ -117,9 +105,7 @@ impl Oracle {
117105 }
118106 }
119107
120- /// One bucket chosen by weighted draw. The rng arm draws by `weights` and
121- /// ignores the descriptions; the clm arm reads them all and picks the one
122- /// the model believes, weights unused.
108+ /// The model picks among the descriptions and never sees the weights.
123109 pub(crate) fn pick(&mut self, roll: Roll, weights: &[u32], options: &[&str]) -> usize {
124110 match self {
125111 Self::Rng(rng) => {
@@ -133,12 +119,11 @@ impl Oracle {
133119 }
134120 weights.len() - 1
135121 }
136- Self::Clm { clm, .. } => clm.choose(&roll.state, roll.site, &roll.ask, options),
122+ Self::Clm { clm, .. } => clm.choose(&roll.state, roll.site, roll.ask, options),
137123 }
138124 }
139125
140- /// A value in `lo..hi`. The rng arm draws uniformly; the clm arm picks from
141- /// a 0.1-step grid so the model is choosing among described options.
126+ /// The model picks from a 0.1-step grid, since it can only choose among described options.
142127 pub(crate) fn span(&mut self, roll: Roll, lo: f64, hi: f64) -> f64 {
143128 match self {
144129 Self::Rng(rng) => rng.random_range(lo..hi),
@@ -159,8 +144,6 @@ impl Oracle {
159144 }
160145 }
161146
162- /// An integer in `lo..=hi`. The rng arm draws uniformly; the clm arm picks
163- /// from the integers as described options.
164147 pub(crate) fn span_int(&mut self, roll: Roll, lo: i32, hi: i32) -> i32 {
165148 match self {
166149 Self::Rng(rng) => rng.random_range(lo..=hi),
Modified file: src/game/outcome.rs+19 additions−3 deletions
@@ -9,9 +9,9 @@ pub enum Payload {
99
1010 /// A successful result of a game action, before it becomes an HTTP response.
1111 pub struct Outcome {
12- pub status: StatusCode,
13- pub payload: Payload,
14- pub headers: Vec<(HeaderName, String)>,
12+ status: StatusCode,
13+ payload: Payload,
14+ headers: Vec<(HeaderName, String)>,
1515 }
1616
1717 impl Outcome {
@@ -41,4 +41,20 @@ impl Outcome {
4141 self.headers.push((name, value.into()));
4242 self
4343 }
44+
45+ pub fn status(&self) -> StatusCode {
46+ self.status
47+ }
48+
49+ pub(super) fn retold(mut self, status: StatusCode, detail: &str) -> Self {
50+ self.status = status;
51+ if let Payload::Json(body) = &mut self.payload {
52+ body["detail"] = Value::from(detail);
53+ }
54+ self
55+ }
56+
57+ pub fn into_parts(self) -> (StatusCode, Payload, Vec<(HeaderName, String)>) {
58+ (self.status, self.payload, self.headers)
59+ }
4460 }
Added file: src/game/progress.rs+185 additions
@@ -0,0 +1,185 @@
1+use super::{
2+ Floor, Lube, MumVisit, Notice, Observer, Run, Story,
3+ balance::{
4+ BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, EQ_WOBBLE, EXTENDER_GAIN,
5+ FORESKIN_GLIDE, FRICTION_BURN, FRICTION_BURN_AT, FRICTION_DRY, FRICTION_GRIP, FRICTION_MAX,
6+ FRICTION_SPIT, GIRTH_THICK, HOT_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS, PULLED_MUSCLE,
7+ PULLED_MUSCLE_INTENSITY, RING_PRIAPISM, RING_TIGHT_PRIAPISM, STROKE_EQ, STROKE_EQ_COST,
8+ SUSPICION_LIMIT, VIGOUR,
9+ },
10+};
11+
12+impl Run {
13+ pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32) {
14+ let (base, slip) = match lube {
15+ None => (FRICTION_DRY, 0),
16+ Some(Lube::Spit) => (FRICTION_SPIT, 0),
17+ Some(Lube::Lotion) => (0, 0),
18+ Some(Lube::BabyOil | Lube::CoconutOil) => (0, 1),
19+ Some(Lube::Vaseline) => (0, 2),
20+ };
21+ let vigour = usize::try_from(intensity - 1).map_or(0, |i| VIGOUR[i.min(VIGOUR.len() - 1)]);
22+ let unlubed = matches!(lube, None | Some(Lube::Spit));
23+ let thick = i32::from(unlubed && self.body.girth > GIRTH_THICK);
24+ let glide = i32::from(unlubed && self.body.foreskin.is_some_and(|f| f >= FORESKIN_GLIDE));
25+ let mut friction = (base + vigour + thick - slip - glide).max(0);
26+ if friction > 0
27+ && self
28+ .body
29+ .jelq_grip
30+ .is_some_and(|grip| grip != self.body.grip)
31+ {
32+ friction -= FRICTION_GRIP;
33+ }
34+ self.body.jelq_grip = Some(self.body.grip);
35+ self.body.friction = (self.body.friction + friction).min(FRICTION_MAX);
36+ if self.body.friction >= FRICTION_BURN_AT
37+ && !self.body.conditions.friction_burn
38+ && self.chance(
39+ self.decision(
40+ "friction-burn",
41+ "With the skin this raw, has it finally burned?",
42+ ),
43+ FRICTION_BURN,
44+ )
45+ {
46+ self.body.conditions.friction_burn = true;
47+ }
48+ }
49+
50+ pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {
51+ self.begin_turn();
52+ self.step_away();
53+ if self.items.kit.extender {
54+ self.body.length += EXTENDER_GAIN;
55+ }
56+ self.body.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
57+ if self.body.stamina <= 0 {
58+ self.exhaust();
59+ }
60+ let chill = if self.floor == Floor::ChangingRoom {
61+ CHANGING_ROOM_CHILL
62+ } else {
63+ 0
64+ };
65+ let wobble = self.oracle.span_int(
66+ self.decision(
67+ "wobble",
68+ "How does the count drift on this stroke, up or down?",
69+ ),
70+ -EQ_WOBBLE,
71+ EQ_WOBBLE,
72+ );
73+ self.body.eq =
74+ (self.body.eq + intensity * STROKE_EQ - STROKE_EQ_COST - chill + wobble).clamp(0, 100);
75+
76+ self.make_noise(intensity / 5 + i32::from(lube == Some(Lube::Vaseline)));
77+ if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
78+ self.add_suspicion(2);
79+ }
80+
81+ if intensity >= PULLED_MUSCLE_INTENSITY
82+ && !self.body.conditions.pulled_muscle
83+ && self.oracle.chance_not(
84+ self.decision("pulled-muscle", "Do the muscles hold?"),
85+ PULLED_MUSCLE,
86+ )
87+ {
88+ self.body.conditions.pulled_muscle = true;
89+ self.body.stamina = self.body.stamina.min(self.max_stamina());
90+ }
91+ self.settle();
92+ }
93+
94+ pub(super) fn make_noise(&mut self, noise: i32) {
95+ let doubled = match self.floor {
96+ Floor::Nans => true,
97+ Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),
98+ _ => false,
99+ };
100+ self.add_suspicion(if doubled { noise * 2 } else { noise });
101+ }
102+
103+ pub(super) fn add_suspicion(&mut self, amount: i32) {
104+ self.standing.suspicion = u8::try_from(
105+ (i32::from(self.standing.suspicion) + amount).clamp(0, i32::from(SUSPICION_LIMIT)),
106+ )
107+ .unwrap_or(SUSPICION_LIMIT);
108+ }
109+
110+ pub(super) fn settle(&mut self) {
111+ self.maybe_summon_mum();
112+ if let Some(observer) = self.floor.observer()
113+ && self.chance(
114+ self.decision("observed", "Has someone quietly noticed what is going on?"),
115+ OBSERVED,
116+ )
117+ {
118+ self.observed = Some(observer);
119+ self.standing.shame += 1;
120+ if observer == Observer::Cat
121+ && self.chance(
122+ self.decision("cat-takes-towel", "Has the cat taken the towel?"),
123+ CAT_TAKES_TOWEL,
124+ )
125+ {
126+ self.here.towel_stolen = true;
127+ }
128+ }
129+ if self.items.kit.ring
130+ && self.body.eq > HOT_EQ
131+ && self.chance(
132+ self.decision(
133+ "ring-priapism",
134+ "With the ring still on and everything this warm, will it not go down?",
135+ ),
136+ if self.body.girth > GIRTH_THICK {
137+ RING_TIGHT_PRIAPISM
138+ } else {
139+ RING_PRIAPISM
140+ },
141+ )
142+ {
143+ self.body.conditions.priapism_turns = PRIAPISM_TURNS;
144+ }
145+ if self.body.conditions.priapism_turns > 0 {
146+ self.body.conditions.priapism_turns -= 1;
147+ self.body.eq = self.body.eq.max(PRIAPISM_EQ);
148+ }
149+ if self.turn == BRANCH_TURN && self.online.story == Story::Unopened {
150+ self.online.story = Story::Pending;
151+ self.notices.push(Notice::ThreadHasReplies);
152+ }
153+ }
154+
155+ pub(super) fn maybe_summon_mum(&mut self) {
156+ let was_below = self.standing.at_turn_start < SUSPICION_LIMIT;
157+ if !self.floor.has_mum() || self.household.mum.is_some() {
158+ return;
159+ }
160+ let summoned = was_below && self.standing.suspicion == SUSPICION_LIMIT;
161+ let mut visits = u32::from(self.standing.suspicion) + 1;
162+ if self.here.brother_told {
163+ visits *= 2;
164+ }
165+ if summoned
166+ || self.oracle.ratio(
167+ self.decision(
168+ "mum-summon",
169+ "With this much noise in the house, is Mum on her way up?",
170+ ),
171+ visits,
172+ super::balance::MUM_VISIT,
173+ )
174+ {
175+ let errand = self.errand(summoned);
176+ let nonce = self.fresh_nonce();
177+ self.household.mum = Some(MumVisit {
178+ errand,
179+ nonce,
180+ strikes: 0,
181+ knocked: false,
182+ });
183+ }
184+ }
185+}
Added file: src/game/rest.rs+87 additions
@@ -0,0 +1,87 @@
1+use super::{
2+ Notice, Outcome, Run, Story,
3+ balance::{
4+ DANGER_EQ, FRICTION_HOLD, FRICTION_MEDITATE, HOLD_EQ, MEDITATION_EQ, REFLECTION,
5+ RUPTURE_HOLD,
6+ },
7+};
8+use crate::problem::Problem;
9+use axum::http::StatusCode;
10+use serde_json::json;
11+
12+impl Run {
13+ pub fn can_hold(&self) -> bool {
14+ self.body.eq > DANGER_EQ || self.body.stamina <= 2
15+ }
16+
17+ pub fn hold(&mut self) -> Result<Outcome, Problem> {
18+ if self.online.story == Story::Pending {
19+ return Ok(self.branch_offer());
20+ }
21+ if self.items.kit.cage {
22+ return Err(Problem::new(
23+ StatusCode::LOCKED,
24+ "caged",
25+ "The cage is locked. The key is not in the box.",
26+ ));
27+ }
28+ self.begin_turn();
29+ if self.oracle.chance_not(
30+ self.decision("rupture", "The pause is uneventful"),
31+ RUPTURE_HOLD,
32+ ) {
33+ return Err(self.rupture());
34+ }
35+ if self.body.exhausted {
36+ self.recover();
37+ self.shed(FRICTION_HOLD);
38+ self.settle();
39+ let detail = if self.body.conditions.priapism_turns > 0 {
40+ "The count went well past ten. Stamina is back. It went down a little. Maybe. You hope."
41+ } else {
42+ "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again."
43+ };
44+ return Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })));
45+ }
46+ if self.chance(
47+ self.decision(
48+ "reflection",
49+ "In the quiet afterwards, have you caught your own eye in the mirror?",
50+ ),
51+ REFLECTION,
52+ ) {
53+ self.body.eq = 0;
54+ self.standing.shame += 1;
55+ self.notices.push(Notice::Reflection);
56+ } else {
57+ self.body.eq = (self.body.eq - HOLD_EQ).clamp(0, 100);
58+ }
59+ self.body.stamina = (self.body.stamina + 1).min(self.max_stamina());
60+ self.shed(FRICTION_HOLD);
61+ self.settle();
62+ Ok(Outcome::empty(StatusCode::NO_CONTENT))
63+ }
64+
65+ /// The breathing technique from the forum's replies. It spends no stamina and the turn is
66+ /// not the floor's, but each floor allows it once.
67+ pub(super) fn meditate(&mut self) -> Result<Outcome, Problem> {
68+ if self.here.meditated {
69+ return Err(Problem::new(
70+ StatusCode::CONFLICT,
71+ "already-meditated",
72+ "This floor has had its meditation.",
73+ ));
74+ }
75+ self.here.meditated = true;
76+ self.step_away();
77+ self.reset_eq(self.body.eq - MEDITATION_EQ);
78+ self.shed(FRICTION_MEDITATE);
79+ self.touch();
80+ Ok(Outcome::json(
81+ StatusCode::OK,
82+ json!({
83+ "detail": "In through the nose, out through the mouth. EQ falls by ten.",
84+ }),
85+ ))
86+ }
87+}
Added file: src/game/roll.rs+222 additions
@@ -0,0 +1,222 @@
1+use super::{
2+ Grip, Lube, Outcome, Run,
3+ balance::{
4+ ARRIVAL_BONUS, BAND_BONUS, CLEAN_HIT, DANGER_EQ, EQ_BAND, GREED_HOT, GREED_WARM, HOT_EQ,
5+ NEAR_MISS, QUEUED_ETA, RING_RUPTURE_LINE, RUPTURE_LINE, UNDERHAND_STRETCH, UNDERHAND_WET,
6+ },
7+ body::Pending,
8+ measure::round3,
9+ techniques::{Profile, Training},
10+};
11+use crate::{headers::SERVER_TIMING, problem::Problem};
12+use axum::http::{StatusCode, header};
13+use serde_json::json;
14+
15+impl Run {
16+ pub(super) fn roll(
17+ &mut self,
18+ training: Training,
19+ intensity: i32,
20+ lube: Option<Lube>,
21+ ) -> Result<Outcome, Problem> {
22+ let profile = training.profile();
23+ let natural: i32 = self.oracle.span_int(
24+ self.decision(
25+ "natural",
26+ "What does the twenty-sided die show for this technique?",
27+ ),
28+ 1,
29+ 20,
30+ );
31+ let modifier = self.modifier(training, lube);
32+ let dc = profile.dc + intensity / 3;
33+ let total = natural + modifier;
34+ let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
35+ let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
36+
37+ let (length, girth) = self.scaled_gains(&profile, intensity);
38+ let rupture_line = if self.items.kit.ring {
39+ RING_RUPTURE_LINE
40+ } else {
41+ RUPTURE_LINE
42+ };
43+
44+ if natural == 1 {
45+ return Err(timed(self.critical_failure(training, &profile)));
46+ }
47+ self.here.warm_arrival = false;
48+ if natural == 2 && self.hotter_than(DANGER_EQ) {
49+ return Err(timed(self.rupture()));
50+ }
51+ if self.hotter_than(rupture_line) && total < dc {
52+ return Err(timed(self.rupture()));
53+ }
54+ if natural == 20 {
55+ self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
56+ return Ok(Outcome::json(
57+ StatusCode::PERMANENT_REDIRECT,
58+ json!({
59+ "detail": "Your length has moved permanently.",
60+ "length": round3(length * 2.0),
61+ "girth": round3(girth * 2.0),
62+ }),
63+ )
64+ .header(header::LOCATION, self.path("/length"))
65+ .header(SERVER_TIMING, timing));
66+ }
67+ if total < dc - NEAR_MISS {
68+ return Err(timed(not_extended()));
69+ }
70+ if total < dc {
71+ return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
72+ }
73+ Ok(self
74+ .land(&profile, length, girth, total >= dc + CLEAN_HIT)
75+ .header(SERVER_TIMING, timing))
76+ }
77+
78+ fn modifier(&self, training: Training, lube: Option<Lube>) -> i32 {
79+ let band = if EQ_BAND.contains(&self.body.eq) {
80+ BAND_BONUS
81+ } else {
82+ 0
83+ };
84+ let grip = match (self.body.grip, training) {
85+ (Grip::Underhand, Training::Stretch) => UNDERHAND_STRETCH,
86+ (Grip::Underhand, Training::Wet) => UNDERHAND_WET,
87+ _ => 0,
88+ };
89+ let arrival = if self.here.warm_arrival {
90+ ARRIVAL_BONUS
91+ } else {
92+ 0
93+ };
94+ lube.map_or(0, Lube::modifier) + band + grip + arrival
95+ }
96+
97+ fn land(&mut self, profile: &Profile, length: f64, girth: f64, clean: bool) -> Outcome {
98+ if self.body.eq < *EQ_BAND.start() {
99+ self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
100+ Outcome::json(
101+ StatusCode::PARTIAL_CONTENT,
102+ json!({
103+ "detail": "Partial gains were applied. EQ was below the band.",
104+ "length": round3(length / 2.0),
105+ "girth": round3(girth / 2.0),
106+ }),
107+ )
108+ } else if clean {
109+ self.gain(length, girth, profile.foreskin_growth);
110+ Outcome::json(
111+ StatusCode::OK,
112+ json!({
113+ "detail": "Gains were applied.",
114+ "length": round3(length),
115+ "girth": round3(girth),
116+ }),
117+ )
118+ } else {
119+ let eta = self.oracle.span_int(
120+ self.decision("eta", "In how many turns will the queued gains arrive?"),
121+ *QUEUED_ETA.start(),
122+ *QUEUED_ETA.end(),
123+ );
124+ self.body.pending.push(Pending {
125+ due: self.turn + eta.unsigned_abs(),
126+ length,
127+ girth,
128+ foreskin: profile.foreskin_growth,
129+ });
130+ Outcome::json(
131+ StatusCode::ACCEPTED,
132+ json!({
133+ "detail": "Gains have been queued. They may arrive.",
134+ "eta_turns": eta,
135+ }),
136+ )
137+ }
138+ }
139+
140+ pub(super) fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {
141+ let scale = f64::from(intensity);
142+ let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
143+ let greed = if self.body.eq > HOT_EQ {
144+ GREED_HOT
145+ } else if self.body.eq > DANGER_EQ {
146+ GREED_WARM
147+ } else {
148+ 1.0
149+ };
150+ length *= greed;
151+ girth *= greed;
152+ if self.here.teapot {
153+ length *= 0.5;
154+ girth *= 1.5;
155+ }
156+ if self.items.kit.ring {
157+ girth *= 2.0;
158+ }
159+ if self.body.conditions.friction_burn {
160+ length *= 0.5;
161+ girth *= 0.5;
162+ }
163+ (length, girth)
164+ }
165+
166+ pub(super) fn critical_failure(&mut self, training: Training, profile: &Profile) -> Problem {
167+ let uses_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
168+ if uses_foreskin && self.body.foreskin.is_some() {
169+ self.body.foreskin = None;
170+ self.standing.shame += 25;
171+ return Problem::new(
172+ StatusCode::GONE,
173+ "foreskin-lost",
174+ "The technique has removed the foreskin. It will not be returning.",
175+ )
176+ .header(header::CONTENT_LOCATION, self.path("/foreskin"));
177+ }
178+ if self.hotter_than(DANGER_EQ) {
179+ return self.rupture();
180+ }
181+ if training == Training::Stretch && !self.here.teapot {
182+ self.here.teapot = true;
183+ self.items.kit.teapot_spout = true;
184+ self.standing.shame += 5;
185+ return Problem::new(
186+ StatusCode::IM_A_TEAPOT,
187+ "teapot",
188+ "The subject is now short and stout. Length gains are halved for the rest of the floor.",
189+ );
190+ }
191+ if training == Training::Uli && !self.body.conditions.peyronie {
192+ self.body.conditions.peyronie = true;
193+ self.standing.shame += 5;
194+ return Problem::new(
195+ StatusCode::MISDIRECTED_REQUEST,
196+ "curvature",
197+ "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
198+ );
199+ }
200+ not_extended()
201+ }
202+
203+ pub(super) fn gossip(&mut self, outcome: Outcome) -> Outcome {
204+ let reportable = matches!(outcome.status().as_u16(), 200 | 202 | 206 | 308);
205+ if !self.here.brother_told || !reportable {
206+ return outcome;
207+ }
208+ self.here.brother_told = false;
209+ outcome.retold(
210+ StatusCode::ALREADY_REPORTED,
211+ "Your brother has already told Mum.",
212+ )
213+ }
214+}
215+
216+pub(super) fn not_extended() -> Problem {
217+ Problem::new(
218+ StatusCode::NOT_EXTENDED,
219+ "not-extended",
220+ "The requested extension was not applied. The server has recorded the attempt.",
221+ )
222+}
Added file: src/game/run.rs+364 additions
@@ -0,0 +1,364 @@
1+use std::{
2+ hash::{DefaultHasher, Hash, Hasher},
3+ time::{Instant, SystemTime},
4+};
5+
6+use axum::http::StatusCode;
7+use serde_json::{Value, json};
8+
9+use super::{
10+ Body, Cause, Clinic, Ending, Floor, FloorState, Household, Items, Notice, Observer, Online,
11+ RunStatus, Standing, Story,
12+ balance::{
13+ BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_EQ,
14+ },
15+ measure::round2,
16+ oracle::{Oracle, OracleTemplate, Roll},
17+};
18+use crate::problem::Problem;
19+
20+pub struct Identity {
21+ /// The private id in the run's URL. Knowing it is the only authorisation a run has.
22+ pub(crate) id: String,
23+ pub(crate) public_id: String,
24+ pub(crate) seeded: bool,
25+ etag_salt: u64,
26+ pub(crate) version: u64,
27+ pub(crate) last_seen: Instant,
28+}
29+
30+pub struct Run {
31+ pub(crate) ident: Identity,
32+ pub(crate) body: Body,
33+ pub(crate) standing: Standing,
34+ pub(crate) household: Household,
35+ pub(crate) online: Online,
36+ pub(crate) items: Items,
37+ pub(crate) clinic: Clinic,
38+ pub(crate) turn: u32,
39+ pub(crate) floor: Floor,
40+ pub(crate) here: FloorState,
41+ pub(crate) observed: Option<Observer>,
42+ pub(crate) notices: Vec<Notice>,
43+ pub(crate) ended: Option<Ending>,
44+ pub(super) oracle: Oracle,
45+}
46+
47+impl Run {
48+ #[cfg(test)]
49+ pub fn new(id: String, public_id: String, seed: u64) -> Self {
50+ Self::with_oracle(id, public_id, seed, false, &OracleTemplate::Rng)
51+ }
52+
53+ pub fn with_oracle(
54+ id: String,
55+ public_id: String,
56+ seed: u64,
57+ seeded: bool,
58+ template: &OracleTemplate,
59+ ) -> Self {
60+ let mut oracle = Oracle::build(template, seed);
61+ let length = round2(oracle.span(
62+ Run::unborn("length", "How long is it, in inches?"),
63+ 4.5,
64+ 6.5,
65+ ));
66+ let girth = round2(oracle.span(
67+ Run::unborn("girth", "How thick is it, in inches?"),
68+ 4.0,
69+ 5.0,
70+ ));
71+ let foreskin = round2(oracle.span(
72+ Run::unborn("foreskin", "How much foreskin is there, in inches?"),
73+ 0.4,
74+ 2.4,
75+ ));
76+ let etag_salt = oracle.u64();
77+ Self {
78+ ident: Identity {
79+ id,
80+ public_id,
81+ seeded,
82+ etag_salt,
83+ version: 0,
84+ last_seen: Instant::now(),
85+ },
86+ body: Body::new(length, girth, foreskin),
87+ standing: Standing::default(),
88+ household: Household::default(),
89+ online: Online::default(),
90+ items: Items::default(),
91+ clinic: Clinic::default(),
92+ turn: 0,
93+ floor: Floor::Bedroom,
94+ here: FloorState::default(),
95+ observed: None,
96+ notices: Vec::new(),
97+ ended: None,
98+ oracle,
99+ }
100+ }
101+
102+ fn unborn(site: &'static str, ask: &'static str) -> Roll {
103+ Roll {
104+ state: "A new run is being created. No game has been played yet.".into(),
105+ site,
106+ ask,
107+ }
108+ }
109+
110+ fn etag_at(&self, version: u64) -> String {
111+ let mut hasher = DefaultHasher::new();
112+ (self.ident.etag_salt, version).hash(&mut hasher);
113+ format!("\"{:016x}\"", hasher.finish())
114+ }
115+
116+ pub fn etag(&self) -> String {
117+ self.etag_at(self.ident.version)
118+ }
119+
120+ /// The ETag the run had when it was created, which the true ending asks for.
121+ pub fn origin_etag(&self) -> String {
122+ self.etag_at(0)
123+ }
124+
125+ pub fn touch(&mut self) {
126+ self.ident.version += 1;
127+ }
128+
129+ pub(super) fn begin_turn(&mut self) {
130+ self.touch();
131+ self.turn += 1;
132+ self.here.turns += 1;
133+ self.standing.at_turn_start = self.standing.suspicion;
134+ self.realise();
135+ }
136+
137+ pub(super) fn shed(&mut self, friction: i32) {
138+ self.body.friction = (self.body.friction - friction).max(0);
139+ }
140+
141+ /// Priapism holds EQ up no matter what else moves it.
142+ pub(super) fn reset_eq(&mut self, eq: i32) {
143+ let floor = if self.body.conditions.priapism_turns > 0 {
144+ PRIAPISM_EQ
145+ } else {
146+ 0
147+ };
148+ self.body.eq = eq.max(floor);
149+ }
150+
151+ pub(super) fn end_kitchen_visit(&mut self) {
152+ self.here.kitchen.melon_offered = false;
153+ }
154+
155+ /// Going back to the jelq walks away from both the fridge and Pump-Bro.
156+ pub(super) fn step_away(&mut self) {
157+ self.end_kitchen_visit();
158+ self.here.shopping = false;
159+ }
160+
161+ pub(super) fn fresh_nonce(&mut self) -> String {
162+ format!("{:016x}", self.oracle.u64())
163+ }
164+
165+ /// One game decision. The dice ignore the state prose, so it is only written for the model.
166+ pub(super) fn decision(&self, site: &'static str, ask: &'static str) -> Roll {
167+ Roll {
168+ state: if self.oracle.listens() {
169+ self.oracle_state()
170+ } else {
171+ String::new()
172+ },
173+ site,
174+ ask,
175+ }
176+ }
177+
178+ fn oracle_state(&self) -> String {
179+ let mut s = format!(
180+ "Floor {}, {}. Turn {}.\n\neq: {}\nstamina: {}\nfriction: {}\n",
181+ self.floor.number(),
182+ self.floor.place(),
183+ self.turn,
184+ self.body.eq,
185+ self.body.stamina,
186+ self.body.friction
187+ );
188+ let conditions = &self.body.conditions;
189+ for (active, line) in [
190+ (conditions.friction_burn, "friction burn: yes, untreated\n"),
191+ (conditions.priapism_turns > 0, "priapism: yes\n"),
192+ (conditions.peyronie, "peyronie's disease: yes\n"),
193+ (conditions.pulled_muscle, "pulled muscle: yes\n"),
194+ ] {
195+ if active {
196+ s.push_str(line);
197+ }
198+ }
199+ s.push_str(&format!(
200+ "suspicion: {}\nshame: {}\nwarmed up: {}\ngrip: {}\n",
201+ self.standing.suspicion,
202+ self.standing.shame,
203+ if self.body.warmed_up { "yes" } else { "no" },
204+ self.body.grip.name(),
205+ ));
206+ let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
207+ if !held.is_empty() {
208+ s.push_str(&format!("items: {}\n", held.join(", ")));
209+ }
210+ if self.household.mum.is_some() {
211+ s.push_str("Mum is at the door\n");
212+ }
213+ s
214+ }
215+
216+ pub(super) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
217+ self.oracle.chance(roll, odds)
218+ }
219+
220+ pub fn path(&self, sub: &str) -> String {
221+ format!("/runs/{}{sub}", self.ident.id)
222+ }
223+
224+ pub fn current_length(&self) -> f64 {
225+ self.body.length + self.here.temp_length
226+ }
227+
228+ pub fn self_reported(&self) -> f64 {
229+ self.current_length() + SELF_REPORT_INFLATION
230+ }
231+
232+ pub fn shown_length(&self) -> f64 {
233+ if self.items.kit.ruler {
234+ self.current_length()
235+ } else {
236+ self.self_reported()
237+ }
238+ }
239+
240+ pub fn max_stamina(&self) -> i32 {
241+ if self.body.conditions.pulled_muscle {
242+ PULLED_STAMINA
243+ } else {
244+ MAX_STAMINA
245+ }
246+ }
247+
248+ pub fn forum_read(&self) -> bool {
249+ self.online.story == Story::ReadForum
250+ }
251+
252+ pub fn thread_reachable(&self) -> bool {
253+ self.turn >= BRANCH_TURN
254+ || self.forum_read()
255+ || self.floor.number() >= Floor::Forum.number()
256+ }
257+
258+ pub fn footage(&self) -> Option<(SystemTime, u64)> {
259+ let filmed = self.online.filmed_at?;
260+ let age = filmed.elapsed().map_or(0, |d| d.as_secs());
261+ Some((filmed, age))
262+ }
263+
264+ pub fn status(&self) -> RunStatus {
265+ if self.ended.is_some() {
266+ RunStatus::Gone
267+ } else if self.clinic.cured {
268+ RunStatus::Normal
269+ } else if self.clinic.prescribed {
270+ RunStatus::Prescribed
271+ } else {
272+ RunStatus::Alive
273+ }
274+ }
275+
276+ /// The parts of the run that its response headers do not already carry.
277+ pub fn view(&self) -> Value {
278+ let conditions: Vec<&str> = [
279+ (self.here.teapot, "short and stout"),
280+ (self.body.conditions.peyronie, "curved"),
281+ (self.items.kit.cage, "caged"),
282+ (self.body.conditions.priapism_turns > 0, "priapism"),
283+ (self.body.conditions.pulled_muscle, "pulled muscle"),
284+ (self.online.filmed_at.is_some(), "viral"),
285+ ]
286+ .into_iter()
287+ .filter_map(|(active, name)| active.then_some(name))
288+ .collect();
289+ json!({
290+ "public_id": self.ident.public_id,
291+ "status": self.status().name(),
292+ "turn": self.turn,
293+ "run_shame": self.standing.shame,
294+ "grip": self.body.grip.name(),
295+ "warmed_up": self.body.warmed_up,
296+ "ranked": !self.ident.seeded,
297+ "conditions": conditions,
298+ })
299+ }
300+
301+ pub fn end(&mut self, cause: Cause) -> Problem {
302+ self.ended = Some(Ending {
303+ cause,
304+ turn: self.turn,
305+ });
306+ cause.problem().with("turn", self.turn)
307+ }
308+
309+ /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.
310+ pub(super) fn recover(&mut self) {
311+ self.body.exhausted = false;
312+ self.body.stamina = self.max_stamina();
313+ self.reset_eq(STARTING_EQ);
314+ self.body.warmed_up = false;
315+ }
316+
317+ pub(super) fn check_exhausted(&self) -> Result<(), Problem> {
318+ if !self.body.exhausted {
319+ return Ok(());
320+ }
321+ Err(Problem::new(
322+ StatusCode::TOO_MANY_REQUESTS,
323+ "overtrained",
324+ "Stamina is exhausted. HOLD for the refractory period.",
325+ ))
326+ }
327+
328+ pub(super) fn exhaust(&mut self) {
329+ self.body.stamina = 0;
330+ self.body.exhausted = true;
331+ }
332+
333+ pub(super) fn realise(&mut self) {
334+ let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.body.pending)
335+ .into_iter()
336+ .partition(|p| p.due <= self.turn);
337+ self.body.pending = waiting;
338+ for p in due {
339+ self.gain(p.length, p.girth, p.foreskin);
340+ }
341+ }
342+
343+ pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
344+ self.body.length += length;
345+ self.body.girth += girth;
346+ if let Some(f) = &mut self.body.foreskin {
347+ *f += foreskin;
348+ }
349+ }
350+
351+ pub(super) fn rupture(&mut self) -> Problem {
352+ let cause = Cause::Ruptured {
353+ ice_unused: self.items.stores.ice_packs > 0,
354+ };
355+ self.end(cause)
356+ }
357+
358+ /// A run whose lock was poisoned by a panic mid-turn cannot be trusted to carry on.
359+ pub fn lose(&mut self) {
360+ if self.ended.is_none() {
361+ self.end(Cause::Lost);
362+ }
363+ }
364+}
Modified file: src/game/shop.rs+9 additions−7 deletions
@@ -27,10 +27,12 @@ impl Run {
2727 Some(item.price()? + markup)
2828 }
2929
30- pub fn shop_view(&self) -> Result<Value, Problem> {
30+ /// Looking at the stock opens a shopping session, which lasts until the next jelq.
31+ pub fn browse_shop(&mut self) -> Result<Value, Problem> {
3132 if !self.floor.shop_open() {
3233 return Err(pump_bro_absent());
3334 }
35+ self.here.shopping = true;
3436 let stock: Vec<Value> = SHOP
3537 .iter()
3638 .filter_map(|item| {
@@ -52,7 +54,7 @@ impl Run {
5254 Ok(view)
5355 }
5456
55- pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {
57+ pub fn buy(&mut self, item: Option<Item>) -> Result<Outcome, Problem> {
5658 if !self.floor.shop_open() {
5759 return Err(pump_bro_absent());
5860 }
@@ -63,7 +65,7 @@ impl Run {
6365 "Pump-Bro does not sell that. He knows a guy.",
6466 )
6567 };
66- let item = Item::parse(slug)
68+ let item = item
6769 .filter(|item| SHOP.contains(item))
6870 .ok_or_else(not_for_sale)?;
6971 let price = self.price(item).ok_or_else(not_for_sale)?;
@@ -76,7 +78,7 @@ impl Run {
7678 .header(header::UPGRADE, "pump-pro/2.0")
7779 .with("upgrade", self.path("/shop/pump-pro")));
7880 }
79- if self.cash < price {
81+ if self.standing.cash < price {
8082 return Err(Problem::new(
8183 StatusCode::PAYMENT_REQUIRED,
8284 "insufficient-funds",
@@ -84,7 +86,7 @@ impl Run {
8486 )
8587 .with("price", price));
8688 }
87- self.cash -= price;
89+ self.standing.cash -= price;
8890 self.items.grant(item);
8991 self.touch();
9092 if item == Item::Supplement {
@@ -106,8 +108,8 @@ impl Run {
106108
107109 pub fn drop_item(&mut self, item: Item) -> Result<Outcome, Problem> {
108110 match item {
109- Item::Extender => self.items.extender = false,
110- Item::Ring => self.items.ring = false,
111+ Item::Extender => self.items.kit.extender = false,
112+ Item::Ring => self.items.kit.ring = false,
111113 Item::Cage => {
112114 return Err(Problem::new(
113115 StatusCode::LOCKED,
Added file: src/game/standing.rs+25 additions
@@ -0,0 +1,25 @@
1+use super::balance::STARTING_CASH;
2+
3+pub struct Standing {
4+ pub(crate) suspicion: u8,
5+ /// Suspicion before this turn's noise, so Mum can tell the turn that crossed the limit.
6+ pub(crate) at_turn_start: u8,
7+ /// This run's shame. The cross-run total lives in the signed cookie.
8+ pub(crate) shame: u32,
9+ pub(crate) shame_reported: u32,
10+ pub(crate) shame_total: u32,
11+ pub(crate) cash: u32,
12+}
13+
14+impl Default for Standing {
15+ fn default() -> Self {
16+ Self {
17+ suspicion: 0,
18+ at_turn_start: 0,
19+ shame: 0,
20+ shame_reported: 0,
21+ shame_total: 0,
22+ cash: STARTING_CASH,
23+ }
24+ }
25+}
Deleted file: src/game/state.rs−294 deletions
@@ -1,294 +0,0 @@
1-use axum::http::StatusCode;
2-
3-use crate::problem::Problem;
4-
5-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
6-#[repr(u8)]
7-pub enum Floor {
8- Bedroom = 1,
9- Bathroom = 2,
10- ChangingRoom = 3,
11- Nans = 4,
12- Hotel = 5,
13- Forum = 6,
14- Clinic = 7,
15-}
16-
17-impl Floor {
18- pub fn number(self) -> u8 {
19- self as u8
20- }
21-
22- pub fn next(self) -> Option<Self> {
23- match self {
24- Self::Bedroom => Some(Self::Bathroom),
25- Self::Bathroom => Some(Self::ChangingRoom),
26- Self::ChangingRoom => Some(Self::Nans),
27- Self::Nans => Some(Self::Hotel),
28- Self::Hotel => Some(Self::Forum),
29- Self::Forum => Some(Self::Clinic),
30- Self::Clinic => None,
31- }
32- }
33-
34- pub fn name(self) -> &'static str {
35- match self {
36- Self::Bedroom => "the bedroom",
37- Self::Bathroom => "the shared bathroom",
38- Self::ChangingRoom => "the gym changing room",
39- Self::Nans => "Nan's house at Christmas",
40- Self::Hotel => "a business trip hotel",
41- Self::Forum => "the forum",
42- Self::Clinic => "the urologist's office",
43- }
44- }
45-
46- pub fn note(self) -> &'static str {
47- match self {
48- Self::Bedroom => "The door does not lock. The cat is present.",
49- Self::Bathroom => "The water is hot. Your brother showers at eight.",
50- Self::ChangingRoom => {
51- "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."
52- }
53- Self::Nans => {
54- "The walls are thin. Mum is here, and she has been reading about Digest authentication."
55- }
56- Self::Hotel => {
57- "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."
58- }
59- Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",
60- Self::Clinic => "The urologist will see you now. He is bored.",
61- }
62- }
63-
64- pub fn observer(self) -> Option<Observer> {
65- match self {
66- Self::Bedroom => Some(Observer::Cat),
67- Self::ChangingRoom => Some(Observer::Greg),
68- Self::Nans => Some(Observer::Nan),
69- _ => None,
70- }
71- }
72-
73- pub fn has_mum(self) -> bool {
74- matches!(self, Self::Bedroom | Self::Nans)
75- }
76-
77- pub fn has_window(self) -> bool {
78- matches!(self, Self::Bedroom | Self::Nans | Self::Hotel)
79- }
80-
81- pub fn shop_open(self) -> bool {
82- matches!(self, Self::ChangingRoom | Self::Hotel)
83- }
84-
85- pub fn has_kitchen(self) -> bool {
86- matches!(self, Self::Bedroom | Self::Nans)
87- }
88-
89- pub fn timed(self) -> bool {
90- !matches!(self, Self::Forum | Self::Clinic)
91- }
92-}
93-
94-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
95-pub enum Observer {
96- Cat,
97- Greg,
98- Nan,
99-}
100-
101-impl Observer {
102- pub fn name(self) -> &'static str {
103- match self {
104- Self::Cat => "cat",
105- Self::Greg => "greg",
106- Self::Nan => "nan",
107- }
108- }
109-}
110-
111-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
112-pub enum Grip {
113- Overhand,
114- Underhand,
115-}
116-
117-impl Grip {
118- pub fn name(self) -> &'static str {
119- match self {
120- Self::Overhand => "overhand",
121- Self::Underhand => "underhand",
122- }
123- }
124-
125- pub fn parse(protocol: &str) -> Option<Self> {
126- match protocol.to_ascii_lowercase().as_str() {
127- "overhand" => Some(Self::Overhand),
128- "underhand" => Some(Self::Underhand),
129- _ => None,
130- }
131- }
132-}
133-
134-/// State that belongs to the current floor and resets when you leave it.
135-#[derive(Default)]
136-pub struct FloorState {
137- pub turns: u32,
138- pub drawer_searched: bool,
139- pub drawer_jammed: bool,
140- pub kettle_boiled: bool,
141- pub thread_rolls: u8,
142- pub edges: u8,
143- pub temp_length: f64,
144- pub teapot: bool,
145- pub towel_stolen: bool,
146- pub brother_told: bool,
147- pub shopping: bool,
148- pub meditated: bool,
149- pub melon_offered: bool,
150- pub warm_arrival: bool,
151- pub wifi_accepted: bool,
152-}
153-
154-#[derive(Default)]
155-pub struct Conditions {
156- pub peyronie: bool,
157- pub pulled_muscle: bool,
158- pub priapism_turns: u8,
159- pub friction_burn: bool,
160-}
161-
162-pub struct MumVisit {
163- pub errand: &'static str,
164- pub nonce: String,
165- pub strikes: u8,
166- pub knocked: bool,
167-}
168-
169-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
170-pub enum Elder {
171- Unverified { strikes: u8 },
172- Verified,
173- Beaten,
174-}
175-
176-#[derive(Default)]
177-pub struct Clinic {
178- pub intake_attempts: usize,
179- pub measured: bool,
180- pub prescribed: bool,
181- pub cured: bool,
182-}
183-
184-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
185-pub enum Story {
186- Unopened,
187- Pending,
188- ReadForum,
189- ClosedTab,
190-}
191-
192-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
193-pub enum Cause {
194- Ruptured { ice_unused: bool },
195- Caught,
196- Opened,
197- Banned,
198- Degloved,
199- Zip,
200- Meloned,
201- Abandoned,
202-}
203-
204-impl Cause {
205- pub fn slug(self) -> &'static str {
206- match self {
207- Self::Ruptured { .. } => "vascular-event",
208- Self::Caught => "caught",
209- Self::Opened => "opened",
210- Self::Banned => "banned",
211- Self::Degloved => "degloved",
212- Self::Zip => "zip",
213- Self::Meloned => "watermelon",
214- Self::Abandoned => "abandoned",
215- }
216- }
217-
218- pub fn detail(self) -> &'static str {
219- match self {
220- Self::Ruptured { ice_unused: true } => {
221- "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
222- }
223- Self::Ruptured { ice_unused: false } => {
224- "A vessel has exceeded its rated pressure. The run has ended."
225- }
226- Self::Caught => {
227- "Mum has seen enough. The run has ended. Nobody will mention it at dinner."
228- }
229- Self::Opened => {
230- "Mum has stopped knocking and opened the door. The run has ended. Nobody will mention it at dinner."
231- }
232- Self::Banned => "Your account has been banned. The forum can tell. The run has ended.",
233- Self::Degloved => {
234- "The outer layer has been removed in one piece. It will not be returning. The run has ended."
235- }
236- Self::Zip => {
237- "The request conflicts with the current state of the zip. The run has ended."
238- }
239- Self::Meloned => {
240- "You have been caught with the watermelon. Nobody will mention it at dinner. The run has ended."
241- }
242- Self::Abandoned => "The run was abandoned.",
243- }
244- }
245-
246- fn status(self) -> StatusCode {
247- match self {
248- Self::Ruptured { .. } => StatusCode::INTERNAL_SERVER_ERROR,
249- Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned => StatusCode::GONE,
250- Self::Meloned => StatusCode::GONE,
251- Self::Banned => StatusCode::FORBIDDEN,
252- Self::Zip => StatusCode::CONFLICT,
253- }
254- }
255-
256- pub fn problem(self) -> Problem {
257- Problem::new(self.status(), self.slug(), self.detail())
258- }
259-}
260-
261-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
262-pub enum RunStatus {
263- Alive,
264- Prescribed,
265- /// The true ending, the clinic's `304`.
266- Normal,
267- Gone,
268-}
269-
270-impl RunStatus {
271- pub fn name(self) -> &'static str {
272- match self {
273- Self::Alive => "alive",
274- Self::Prescribed => "prescribed",
275- Self::Normal => "normal",
276- Self::Gone => "gone",
277- }
278- }
279-
280- pub fn finished(self) -> bool {
281- self != Self::Alive
282- }
283-}
284-
285-pub struct Ending {
286- pub cause: Cause,
287- pub turn: u32,
288-}
289-
290-pub struct Pending {
291- pub due: u32,
292- pub length: f64,
293- pub girth: f64,
294-}
Modified file: src/game/techniques.rs+7 additions−82 deletions
@@ -1,64 +1,6 @@
1-use axum::http::StatusCode;
21 use serde_json::{Value, json};
32
4-use crate::problem::Problem;
5-
6-pub const LUBRICANTS: [&str; 5] = [
7- "application/baby-oil",
8- "application/coconut-oil",
9- "application/lotion",
10- "application/vaseline",
11- "text/plain; charset=spit",
12-];
13-
14-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15-pub enum Lube {
16- BabyOil,
17- CoconutOil,
18- Lotion,
19- Vaseline,
20- Spit,
21-}
22-
23-impl Lube {
24- pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
25- let Some(raw) = content_type else {
26- return Ok(None);
27- };
28- let lowered = raw.to_ascii_lowercase();
29- let mut parts = lowered.split(';').map(str::trim);
30- let essence = parts.next().unwrap_or_default();
31- let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
32- match essence {
33- "application/baby-oil" => Ok(Some(Self::BabyOil)),
34- "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
35- "application/lotion" => Ok(Some(Self::Lotion)),
36- "application/vaseline" => Ok(Some(Self::Vaseline)),
37- "text/plain" if spit => Ok(Some(Self::Spit)),
38- e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
39- StatusCode::UNSUPPORTED_MEDIA_TYPE,
40- "no",
41- "No.",
42- )),
43- _ => Err(Problem::new(
44- StatusCode::UNSUPPORTED_MEDIA_TYPE,
45- "unsupported-lubricant",
46- format!("{essence} is not a lubricant."),
47- )
48- .with("lubricants", json!(LUBRICANTS))),
49- }
50- }
51-
52- pub fn modifier(self) -> i32 {
53- match self {
54- Self::BabyOil => 1,
55- Self::CoconutOil => 3,
56- Self::Lotion => 0,
57- Self::Vaseline => 2,
58- Self::Spit => -2,
59- }
60- }
61-}
3+use super::lube::LubeRule;
624
635 /// Techniques that roll for gains.
646 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -87,31 +29,14 @@ pub enum Technique {
8729 Utility(Utility),
8830 }
8931
90-#[derive(Clone, Copy)]
91-pub enum LubeRule {
92- Required,
93- Forbidden,
94- Optional,
95-}
96-
97-impl LubeRule {
98- fn name(self) -> &'static str {
99- match self {
100- Self::Required => "required",
101- Self::Forbidden => "forbidden",
102- Self::Optional => "optional",
103- }
104- }
105-}
106-
10732 pub struct Profile {
108- pub dc: i32,
109- pub length: f64,
110- pub girth: f64,
33+ pub(crate) dc: i32,
34+ pub(crate) length: f64,
35+ pub(crate) girth: f64,
11136 /// Minimum and maximum foreskin the technique tolerates. A minimum above zero means it is used.
112- pub foreskin: Option<(f64, f64)>,
113- pub foreskin_growth: f64,
114- pub lube: LubeRule,
37+ pub(crate) foreskin: Option<(f64, f64)>,
38+ pub(crate) foreskin_growth: f64,
39+ pub(crate) lube: LubeRule,
11540 }
11641
11742 impl Training {
Added file: src/game/travel.rs+152 additions
@@ -0,0 +1,152 @@
1+use super::{
2+ Cause, Elder, Floor, FloorState, Notice, Outcome, Run,
3+ balance::{
4+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, LOCKER_ROOM_BAR, LOCKER_ROOM_OTHERS,
5+ LOCKER_ROOM_SHAME, STARTING_EQ, ZIP,
6+ },
7+ measure::round2,
8+};
9+use crate::problem::Problem;
10+use axum::http::{StatusCode, header};
11+use serde_json::{Value, json};
12+
13+/// How a floor was left. Only a proper exit is paid.
14+#[derive(Clone, Copy, PartialEq, Eq)]
15+pub(super) enum Exit {
16+ Proper,
17+ Premature,
18+}
19+
20+impl Run {
21+ pub(super) fn next_floor(&mut self, exit: Exit) {
22+ let Some(next) = self.floor.next() else {
23+ return;
24+ };
25+ let warm = EQ_BAND.contains(&self.body.eq);
26+ self.floor = next;
27+ self.here = FloorState {
28+ warm_arrival: warm,
29+ ..FloorState::default()
30+ };
31+ self.body.warmed_up = false;
32+ self.reset_eq(STARTING_EQ);
33+ self.standing.suspicion = 0;
34+ self.shed(FRICTION_FLOOR);
35+ self.household.mum = None;
36+ if self.floor == Floor::ChangingRoom && !self.holds_own_in_the_locker_room() {
37+ self.standing.shame += LOCKER_ROOM_SHAME;
38+ self.notices.push(Notice::KevIsTheBar);
39+ }
40+ if exit == Exit::Proper {
41+ self.standing.cash += FLOOR_ALLOWANCE;
42+ }
43+ self.touch();
44+ }
45+
46+ pub fn advance(&mut self) -> Result<Outcome, Problem> {
47+ match self.floor {
48+ Floor::Clinic => {
49+ return Err(Problem::new(
50+ StatusCode::CONFLICT,
51+ "no-further-floor",
52+ "There is no further floor. There is only the diagnosis.",
53+ ));
54+ }
55+ Floor::Forum if self.online.elder != Elder::Beaten => {
56+ return Err(Problem::new(
57+ StatusCode::CONFLICT,
58+ "elder-unaddressed",
59+ "The Elder has not been addressed.",
60+ )
61+ .with("elder", self.path("/elder")));
62+ }
63+ floor if floor.timed() && self.here.turns < FLOOR_TURNS => {
64+ return Err(Problem::new(
65+ StatusCode::TOO_EARLY,
66+ "floor-not-over",
67+ format!(
68+ "The floor is not over. {} turns remain.",
69+ FLOOR_TURNS - self.here.turns
70+ ),
71+ ));
72+ }
73+ _ => {}
74+ }
75+ if self.chance(
76+ self.decision(
77+ "zip",
78+ "As the floor ends and you put yourself away, does the zip catch?",
79+ ),
80+ ZIP,
81+ ) {
82+ return Err(self.end(Cause::Zip));
83+ }
84+ let left = self.floor.name();
85+ let reverted = self.here.temp_length;
86+ self.next_floor(Exit::Proper);
87+ let warm = if self.here.warm_arrival {
88+ ", and the warmth came with you"
89+ } else {
90+ ""
91+ };
92+ let mut body = json!({
93+ "detail": format!("You leave {left}. You have arrived at {}{warm}.", self.floor.name()),
94+ "note": self.floor.note(),
95+ });
96+ if reverted > 0.0 {
97+ body["reverted"] = json!(round2(reverted));
98+ }
99+ Ok(
100+ Outcome::json(StatusCode::SEE_OTHER, body)
101+ .header(header::LOCATION, self.path("/floor")),
102+ )
103+ }
104+
105+ pub fn floor_view(&self) -> Value {
106+ let mut view = json!({
107+ "name": self.floor.name(),
108+ "note": self.floor.note(),
109+ "turns": self.here.turns,
110+ "turns_required": self.floor.timed().then_some(FLOOR_TURNS),
111+ "exit": { "href": self.path("/floor"), "method": "POST" },
112+ });
113+ match self.floor {
114+ Floor::ChangingRoom => {
115+ let others: Vec<Value> = LOCKER_ROOM_OTHERS
116+ .into_iter()
117+ .chain([("Kev", LOCKER_ROOM_BAR)])
118+ .map(|(name, length)| json!({ "name": name, "self_reported_length": length }))
119+ .collect();
120+ view["others"] = json!(others);
121+ }
122+ Floor::Hotel => view["portal"] = json!(self.path("/portal")),
123+ Floor::Forum => {
124+ view["thread"] = json!(super::FORUM_THREAD);
125+ view["elder"] = json!(self.path("/elder"));
126+ }
127+ Floor::Clinic => view["diagnosis"] = json!(self.path("/diagnosis")),
128+ _ => {}
129+ }
130+ if self.floor.shop_open() {
131+ view["shop"] = json!(self.path("/shop"));
132+ }
133+ view
134+ }
135+
136+ pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {
137+ if self.floor != Floor::Hotel {
138+ return Err(Problem::new(
139+ StatusCode::NOT_FOUND,
140+ "no-portal",
141+ "There is no captive portal here. There is only the regular kind of captivity.",
142+ ));
143+ }
144+ self.here.wifi_accepted = true;
145+ self.touch();
146+ Ok(Outcome::json(
147+ StatusCode::SEE_OTHER,
148+ json!({ "detail": "Connected. The hotel does not want to know." }),
149+ )
150+ .header(header::LOCATION, self.path("")))
151+ }
152+}
Deleted file: src/game/turn.rs−785 deletions
@@ -1,785 +0,0 @@
1-use std::{fmt::Write as _, ops::ControlFlow};
2-
3-use axum::http::{StatusCode, header};
4-use serde_json::json;
5-
6-use super::{
7- Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
8- balance::{
9- ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ,
10- EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FORESKIN_GLIDE, FRICTION_BURN, FRICTION_BURN_AT,
11- FRICTION_DRY, FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT,
12- GIRTH_THICK, GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ,
13- PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM,
14- RING_RUPTURE_LINE, RING_TIGHT_PRIAPISM, RUPTURE_HOLD, RUPTURE_LINE, STORAGE_BULK,
15- SUSPICION_LIMIT,
16- },
17- not_extended, round3,
18- state::{MumVisit, Observer, Pending},
19- techniques::{LubeRule, Profile, Training, Utility},
20-};
21-use crate::{headers::SERVER_TIMING, problem::Problem};
22-
23-impl Run {
24- pub fn jelq(
25- &mut self,
26- techniques: &[Technique],
27- intensity: i32,
28- lube: Option<Lube>,
29- ) -> Result<Outcome, Problem> {
30- self.check_exhausted()?;
31- if self.story == Story::Pending {
32- return Ok(self.branch_offer());
33- }
34- if self.floor == Floor::Forum {
35- return Ok(Outcome::json(
36- StatusCode::SEE_OTHER,
37- json!({ "detail": "Refer to the forum." }),
38- )
39- .header(header::LOCATION, super::FORUM_THREAD));
40- }
41- if self.items.cage {
42- return Err(Problem::new(
43- StatusCode::LOCKED,
44- "caged",
45- "The cage is locked. The key is not in the box.",
46- ));
47- }
48- match *techniques {
49- [Technique::Utility(utility)] => self.utility(utility, lube),
50- [Technique::Training(training)] => self.single(training, intensity, lube),
51- [Technique::Training(a), Technique::Training(b)] => self.combo(a, b, intensity, lube),
52- [_, _] => Err(Problem::new(
53- StatusCode::BAD_REQUEST,
54- "combo-invalid",
55- "Only training techniques can be combined.",
56- )),
57- _ => Err(Problem::new(
58- StatusCode::BAD_REQUEST,
59- "too-many-techniques",
60- "Two techniques at most. The server has limits even if you do not.",
61- )),
62- }
63- }
64-
65- fn utility(&mut self, utility: Utility, lube: Option<Lube>) -> Result<Outcome, Problem> {
66- match utility {
67- Utility::Warmup => return self.warm_up(),
68- Utility::Ice => return self.ice(),
69- Utility::Supplement => return self.supplement(),
70- Utility::Meditate => return self.meditate(),
71- Utility::Energy => return self.energy(),
72- Utility::Edge | Utility::Pump => {}
73- }
74- self.ready(Technique::Utility(utility), lube)?;
75- self.check_storage()?;
76- self.floor_enemy()?;
77- match utility {
78- Utility::Edge => self.edge(),
79- _ => self.pump(),
80- }
81- }
82-
83- fn single(
84- &mut self,
85- training: Training,
86- intensity: i32,
87- lube: Option<Lube>,
88- ) -> Result<Outcome, Problem> {
89- self.ready(Technique::Training(training), lube)?;
90- self.check_storage()?;
91- self.lube_available(lube)?;
92- self.floor_enemy()?;
93- self.take_lube(lube)?;
94- self.chafe(lube, intensity);
95- self.commit(intensity, lube, 1);
96- if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
97- return Ok(interrupted);
98- }
99- if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
100- return Ok(interrupted);
101- }
102- let outcome = self.roll(training, intensity, lube)?;
103- Ok(self.gossip(outcome))
104- }
105-
106- fn combo(
107- &mut self,
108- a: Training,
109- b: Training,
110- intensity: i32,
111- lube: Option<Lube>,
112- ) -> Result<Outcome, Problem> {
113- if a == b {
114- return Err(Problem::new(
115- StatusCode::BAD_REQUEST,
116- "combo-invalid",
117- "A technique cannot be combined with itself. The server has checked.",
118- ));
119- }
120- self.ready(Technique::Training(a), lube)?;
121- self.ready(Technique::Training(b), lube)?;
122- self.check_storage()?;
123- self.lube_available(lube)?;
124- self.floor_enemy()?;
125- self.take_lube(lube)?;
126- self.chafe(lube, intensity);
127- self.chafe(lube, intensity);
128- self.commit(intensity, lube, 2);
129- for training in [a, b] {
130- if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
131- return Ok(interrupted);
132- }
133- }
134- if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
135- return Ok(interrupted);
136- }
137-
138- let mut statuses = Vec::new();
139- for training in [a, b] {
140- let result = self.roll(training, intensity, lube);
141- if self.ended.is_some() {
142- return result;
143- }
144- let status = match result {
145- Ok(outcome) => self.gossip(outcome).status,
146- Err(problem) => problem.status(),
147- };
148- statuses.push((training, status));
149- }
150-
151- let mut xml = String::from(
152- "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
153- );
154- for (training, status) in statuses {
155- let _ = write!(
156- xml,
157- " <D:response>\n <D:href>{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",
158- self.path(""),
159- training.name(),
160- status.as_u16(),
161- crate::problem::reason(status),
162- );
163- }
164- xml.push_str(
165- " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
166- );
167- Ok(Outcome::raw(
168- StatusCode::MULTI_STATUS,
169- "application/xml; charset=utf-8",
170- xml,
171- ))
172- }
173-
174- fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
175- self.access(technique)?;
176- match technique {
177- Technique::Training(training) => Self::check_lube(training, lube),
178- Technique::Utility(_) => Ok(()),
179- }
180- }
181-
182- /// Whether the run could attempt the technique now, ignoring the lubricant.
183- fn access(&self, technique: Technique) -> Result<(), Problem> {
184- if technique == Technique::Training(Training::Uli) && !self.uli_taught {
185- return Err(Problem::new(
186- StatusCode::FORBIDDEN,
187- "technique-locked",
188- "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not found the reply.",
189- ));
190- }
191- if !self.warmed_up {
192- return Err(Problem::new(
193- StatusCode::PRECONDITION_FAILED,
194- "cold-start",
195- "Warm-up was skipped. The server has noted this.",
196- )
197- .with("technique", Utility::Warmup.name()));
198- }
199- if matches!(technique, Technique::Training(_)) {
200- self.grip_closes(self.grip)?;
201- }
202- match technique {
203- Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(
204- StatusCode::FAILED_DEPENDENCY,
205- "no-pump",
206- "The pump technique depends on a pump.",
207- )),
208- Technique::Utility(_) => Ok(()),
209- Technique::Training(training) => match training.profile().foreskin {
210- Some((min, max)) => self.check_foreskin(min, max),
211- None => Ok(()),
212- },
213- }
214- }
215-
216- fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {
217- match (training.profile().lube, lube) {
218- (LubeRule::Required, None) => Err(Problem::new(
219- StatusCode::UNSUPPORTED_MEDIA_TYPE,
220- "lubricant-required",
221- format!(
222- "The {} technique requires a lubricant. None was declared.",
223- training.name()
224- ),
225- )
226- .with("lubricants", json!(LUBRICANTS))),
227- (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
228- StatusCode::UNSUPPORTED_MEDIA_TYPE,
229- "lubricant-declared",
230- format!(
231- "The {} technique was requested. A lubricant was declared.",
232- training.name()
233- ),
234- )),
235- _ => Ok(()),
236- }
237- }
238-
239- /// One unit of whatever was declared. Spit is free and the tap is always on.
240- fn lube_available(&self, lube: Option<Lube>) -> Result<(), Problem> {
241- let (held, name) = match lube {
242- None | Some(Lube::Spit) => return Ok(()),
243- Some(Lube::BabyOil) => (&self.items.baby_oil, "baby-oil"),
244- Some(Lube::CoconutOil) => (&self.items.coconut_oil, "coconut-oil"),
245- Some(Lube::Lotion) => (&self.items.lotion, "lotion"),
246- Some(Lube::Vaseline) => (&self.items.vaseline, "vaseline"),
247- };
248- if *held == 0 {
249- return Err(Problem::new(
250- StatusCode::UNSUPPORTED_MEDIA_TYPE,
251- "lubricant-exhausted",
252- format!("{name} was declared, but the bottle is empty."),
253- ));
254- }
255- Ok(())
256- }
257-
258- fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
259- self.lube_available(lube)?;
260- let held = match lube {
261- None | Some(Lube::Spit) => return Ok(()),
262- Some(Lube::BabyOil) => &mut self.items.baby_oil,
263- Some(Lube::CoconutOil) => &mut self.items.coconut_oil,
264- Some(Lube::Lotion) => &mut self.items.lotion,
265- Some(Lube::Vaseline) => &mut self.items.vaseline,
266- };
267- *held -= 1;
268- Ok(())
269- }
270-
271- /// The techniques a JELQ could use right now without being refused before the roll.
272- pub fn techniques_now(&self) -> Vec<Technique> {
273- let blocked = !self.floor.timed() || self.items.cage || self.exhausted;
274- if blocked {
275- return Vec::new();
276- }
277- Technique::ALL
278- .into_iter()
279- .filter(|&technique| match technique {
280- Technique::Utility(Utility::Warmup) => true,
281- Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
282- Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
283- Technique::Utility(Utility::Meditate) => !self.here.meditated,
284- Technique::Utility(Utility::Energy) => self.items.energy_drinks > 0,
285- _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
286- })
287- .collect()
288- }
289-
290- fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
291- let Some(f) = self.foreskin else {
292- if min <= 0.0 {
293- return Ok(());
294- }
295- return Err(Problem::new(
296- StatusCode::GONE,
297- "foreskin-gone",
298- "This technique requires a foreskin. The foreskin is gone.",
299- )
300- .header(header::CONTENT_LOCATION, self.path("/foreskin")));
301- };
302- if f < min {
303- return Err(Problem::new(
304- StatusCode::LENGTH_REQUIRED,
305- "foreskin-insufficient",
306- format!(
307- "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
308- ),
309- )
310- .with("required", min));
311- }
312- if f > max {
313- return Err(Problem::new(
314- StatusCode::PAYLOAD_TOO_LARGE,
315- "foreskin-excessive",
316- format!(
317- "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
318- ),
319- )
320- .with("maximum", max));
321- }
322- Ok(())
323- }
324-
325- fn check_storage(&self) -> Result<(), Problem> {
326- if self.items.extender && self.girth + self.current_length() / 4.0 > STORAGE_BULK {
327- return Err(Problem::new(
328- StatusCode::INSUFFICIENT_STORAGE,
329- "jeans-full",
330- "The jeans are full. The extender is occupying the remaining storage.",
331- )
332- .with("extender", self.path("/inventory/extender")));
333- }
334- Ok(())
335- }
336-
337- pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32) {
338- let (base, slip) = match lube {
339- None => (FRICTION_DRY, 0),
340- Some(Lube::Spit) => (FRICTION_SPIT, 0),
341- Some(Lube::Lotion) => (0, 0),
342- Some(Lube::BabyOil | Lube::CoconutOil) => (0, 1),
343- Some(Lube::Vaseline) => (0, 2),
344- };
345- let vigour = match intensity {
346- ..=5 => 0,
347- 6..=8 => 1,
348- _ => 2,
349- };
350- let unlubed = matches!(lube, None | Some(Lube::Spit));
351- let thick = i32::from(unlubed && self.girth > GIRTH_THICK);
352- let glide = i32::from(unlubed && self.foreskin.is_some_and(|f| f >= FORESKIN_GLIDE));
353- let mut friction = (base + vigour + thick - slip - glide).max(0);
354- if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
355- friction -= FRICTION_GRIP;
356- }
357- self.jelq_grip = Some(self.grip);
358- self.friction = (self.friction + friction).min(FRICTION_MAX);
359- if self.friction >= FRICTION_BURN_AT
360- && !self.conditions.friction_burn
361- && self.chance(
362- self.decision(
363- "friction-burn",
364- "With the skin this raw, has it finally burned?",
365- ),
366- FRICTION_BURN,
367- )
368- {
369- self.conditions.friction_burn = true;
370- }
371- }
372-
373- pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {
374- self.touch();
375- self.turn += 1;
376- self.here.turns += 1;
377- self.step_away();
378- self.realise();
379- if self.items.extender {
380- self.length += EXTENDER_GAIN;
381- }
382- self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
383- if self.stamina <= 0 {
384- self.exhaust();
385- }
386- let chill = if self.floor == Floor::ChangingRoom {
387- CHANGING_ROOM_CHILL
388- } else {
389- 0
390- };
391- let wobble = self.oracle.span_int(
392- self.decision(
393- "wobble",
394- "How does the count drift on this stroke, up or down?",
395- ),
396- -EQ_WOBBLE,
397- EQ_WOBBLE,
398- );
399- self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
400-
401- let was_below = self.suspicion < SUSPICION_LIMIT;
402- self.make_noise(intensity / 5 + i32::from(lube == Some(Lube::Vaseline)));
403- if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
404- self.add_suspicion(2);
405- }
406-
407- if intensity >= PULLED_MUSCLE_INTENSITY
408- && !self.conditions.pulled_muscle
409- && self.oracle.chance_not(
410- self.decision("pulled-muscle", "Do the muscles hold?"),
411- PULLED_MUSCLE,
412- )
413- {
414- self.conditions.pulled_muscle = true;
415- self.stamina = self.stamina.min(self.max_stamina());
416- }
417- self.settle(was_below);
418- }
419-
420- pub(super) fn make_noise(&mut self, noise: i32) {
421- let doubled = match self.floor {
422- Floor::Nans => true,
423- Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),
424- _ => false,
425- };
426- self.add_suspicion(if doubled { noise * 2 } else { noise });
427- }
428-
429- fn add_suspicion(&mut self, amount: i32) {
430- self.suspicion =
431- u8::try_from((i32::from(self.suspicion) + amount).clamp(0, i32::from(SUSPICION_LIMIT)))
432- .unwrap_or(SUSPICION_LIMIT);
433- }
434-
435- /// The part of a turn the house takes part in, whatever the action was.
436- pub(super) fn settle(&mut self, was_below: bool) {
437- self.maybe_summon_mum(was_below);
438- if let Some(observer) = self.floor.observer()
439- && self.chance(
440- self.decision("observed", "Has someone quietly noticed what is going on?"),
441- OBSERVED,
442- )
443- {
444- self.observed = Some(observer);
445- self.shame += 1;
446- if observer == Observer::Cat
447- && self.chance(
448- self.decision("cat-takes-towel", "Has the cat taken the towel?"),
449- CAT_TAKES_TOWEL,
450- )
451- {
452- self.here.towel_stolen = true;
453- }
454- }
455- if self.items.ring
456- && self.eq > HOT_EQ
457- && self.chance(
458- self.decision(
459- "ring-priapism",
460- "With the ring still on and everything this warm, will it not go down?",
461- ),
462- if self.girth > GIRTH_THICK {
463- RING_TIGHT_PRIAPISM
464- } else {
465- RING_PRIAPISM
466- },
467- )
468- {
469- self.conditions.priapism_turns = PRIAPISM_TURNS;
470- }
471- if self.conditions.priapism_turns > 0 {
472- self.conditions.priapism_turns -= 1;
473- self.eq = self.eq.max(PRIAPISM_EQ);
474- }
475- if self.turn == BRANCH_TURN && self.story == Story::Unopened {
476- self.story = Story::Pending;
477- self.notices
478- .push("Somewhere, a forum thread from 2009 still has replies.");
479- }
480- }
481-
482- /// HOLD. A pause between strokes, offered when the band has been left or the stamina is gone.
483- pub fn can_hold(&self) -> bool {
484- self.eq > DANGER_EQ || self.stamina <= 2
485- }
486-
487- pub fn hold(&mut self) -> Result<Outcome, Problem> {
488- if self.story == Story::Pending {
489- return Ok(self.branch_offer());
490- }
491- if self.items.cage {
492- return Err(Problem::new(
493- StatusCode::LOCKED,
494- "caged",
495- "The cage is locked. The key is not in the box.",
496- ));
497- }
498- self.touch();
499- self.turn += 1;
500- self.here.turns += 1;
501- self.realise();
502- if self.oracle.chance_not(
503- self.decision("rupture", "The pause is uneventful"),
504- RUPTURE_HOLD,
505- ) {
506- return Err(self.rupture());
507- }
508- let was_below = self.suspicion < SUSPICION_LIMIT;
509- if self.exhausted {
510- self.recover();
511- self.shed(FRICTION_HOLD);
512- self.settle(was_below);
513- let detail = if self.conditions.priapism_turns > 0 {
514- "The count went well past ten. Stamina is back. It went down a little. Maybe. You hope."
515- } else {
516- "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again."
517- };
518- return Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })));
519- }
520- if self.chance(
521- self.decision(
522- "reflection",
523- "In the quiet afterwards, have you caught your own eye in the mirror?",
524- ),
525- REFLECTION,
526- ) {
527- self.eq = 0;
528- self.shame += 1;
529- self.notices
530- .push("the count reached ten, and then the reflection started");
531- } else {
532- self.eq = (self.eq - HOLD_EQ).clamp(0, 100);
533- }
534- self.stamina = (self.stamina + 1).min(self.max_stamina());
535- self.shed(FRICTION_HOLD);
536- self.settle(was_below);
537- Ok(Outcome::empty(StatusCode::NO_CONTENT))
538- }
539-
540- /// The breathing technique from the forum's replies. It spends no stamina and the turn is
541- /// not the floor's, but each floor allows it once.
542- fn meditate(&mut self) -> Result<Outcome, Problem> {
543- if self.here.meditated {
544- return Err(Problem::new(
545- StatusCode::CONFLICT,
546- "already-meditated",
547- "This floor has had its meditation.",
548- ));
549- }
550- self.here.meditated = true;
551- self.step_away();
552- self.reset_eq(self.eq - MEDITATION_EQ);
553- self.shed(FRICTION_MEDITATE);
554- self.touch();
555- Ok(Outcome::json(
556- StatusCode::OK,
557- json!({
558- "detail": "In through the nose, out through the mouth. EQ falls by ten.",
559- }),
560- ))
561- }
562-
563- pub(super) fn maybe_summon_mum(&mut self, was_below: bool) {
564- if !self.floor.has_mum() || self.mum.is_some() {
565- return;
566- }
567- let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
568- let mut visits = u32::from(self.suspicion) + 1;
569- if self.here.brother_told {
570- visits *= 2;
571- }
572- if summoned
573- || self.oracle.ratio(
574- self.decision(
575- "mum-summon",
576- "With this much noise in the house, is Mum on her way up?",
577- ),
578- visits,
579- super::balance::MUM_VISIT,
580- )
581- {
582- let errand = self.errand(summoned);
583- let nonce = self.fresh_nonce();
584- self.mum = Some(MumVisit {
585- errand,
586- nonce,
587- strikes: 0,
588- knocked: false,
589- });
590- }
591- }
592-
593- fn roll(
594- &mut self,
595- training: Training,
596- intensity: i32,
597- lube: Option<Lube>,
598- ) -> Result<Outcome, Problem> {
599- let profile = training.profile();
600- let natural: i32 = self.oracle.span_int(
601- self.decision(
602- "natural",
603- "What does the twenty-sided die show for this technique?",
604- ),
605- 1,
606- 20,
607- );
608- let band = if EQ_BAND.contains(&self.eq) {
609- BAND_BONUS
610- } else {
611- 0
612- };
613- let grip = match (self.grip, training) {
614- (Grip::Underhand, Training::Stretch) => 2,
615- (Grip::Underhand, Training::Wet) => -1,
616- _ => 0,
617- };
618- let arrival = if self.here.warm_arrival {
619- ARRIVAL_BONUS
620- } else {
621- 0
622- };
623- let modifier = lube.map_or(0, Lube::modifier) + band + grip + arrival;
624- let dc = profile.dc + intensity / 3;
625- let total = natural + modifier;
626- let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
627- let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
628-
629- let (length, girth) = self.scaled_gains(&profile, intensity);
630- let burn = self.burn();
631- let rupture_line = if self.items.ring {
632- RING_RUPTURE_LINE
633- } else {
634- RUPTURE_LINE
635- };
636-
637- if natural == 1 {
638- return Err(timed(self.critical_failure(training, &profile)));
639- }
640- self.here.warm_arrival = false;
641- if natural == 2 && self.eq > DANGER_EQ - burn {
642- return Err(timed(self.rupture()));
643- }
644- if self.eq > rupture_line - burn && total < dc {
645- return Err(timed(self.rupture()));
646- }
647- if natural == 20 {
648- self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
649- return Ok(Outcome::json(
650- StatusCode::PERMANENT_REDIRECT,
651- json!({
652- "detail": "Your length has moved permanently.",
653- "length": round3(length * 2.0),
654- "girth": round3(girth * 2.0),
655- }),
656- )
657- .header(header::LOCATION, self.path("/length"))
658- .header(SERVER_TIMING, timing));
659- }
660- if total < dc - 3 {
661- return Err(timed(not_extended()));
662- }
663- if total < dc {
664- return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
665- }
666-
667- let outcome = if self.eq < *EQ_BAND.start() {
668- self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
669- Outcome::json(
670- StatusCode::PARTIAL_CONTENT,
671- json!({
672- "detail": "Partial gains were applied. EQ was below the band.",
673- "length": round3(length / 2.0),
674- "girth": round3(girth / 2.0),
675- }),
676- )
677- } else if total >= dc + 5 {
678- self.gain(length, girth, profile.foreskin_growth);
679- Outcome::json(
680- StatusCode::OK,
681- json!({
682- "detail": "Gains were applied.",
683- "length": round3(length),
684- "girth": round3(girth),
685- }),
686- )
687- } else {
688- let eta = self.oracle.span_int(
689- self.decision("eta", "In how many turns will the queued gains arrive?"),
690- 1,
691- 4,
692- ) as u32;
693- self.pending.push(Pending {
694- due: self.turn + eta,
695- length,
696- girth,
697- });
698- self.gain(0.0, 0.0, profile.foreskin_growth);
699- Outcome::json(
700- StatusCode::ACCEPTED,
701- json!({
702- "detail": "Gains have been queued. They may arrive.",
703- "eta_turns": eta,
704- }),
705- )
706- };
707- Ok(outcome.header(SERVER_TIMING, timing))
708- }
709-
710- fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {
711- let scale = f64::from(intensity);
712- let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
713- let greed = if self.eq > HOT_EQ {
714- GREED_HOT
715- } else if self.eq > DANGER_EQ {
716- GREED_WARM
717- } else {
718- 1.0
719- };
720- length *= greed;
721- girth *= greed;
722- if self.here.teapot {
723- length *= 0.5;
724- girth *= 1.5;
725- }
726- if self.items.ring {
727- girth *= 2.0;
728- }
729- if self.conditions.friction_burn {
730- length *= 0.5;
731- girth *= 0.5;
732- }
733- (length, girth)
734- }
735-
736- fn critical_failure(&mut self, training: Training, profile: &Profile) -> Problem {
737- let uses_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
738- if uses_foreskin && self.foreskin.is_some() {
739- self.foreskin = None;
740- self.shame += 25;
741- return Problem::new(
742- StatusCode::GONE,
743- "foreskin-lost",
744- "The technique has removed the foreskin. It will not be returning.",
745- )
746- .header(header::CONTENT_LOCATION, self.path("/foreskin"));
747- }
748- if self.eq > DANGER_EQ - self.burn() {
749- return self.rupture();
750- }
751- if training == Training::Stretch && !self.here.teapot {
752- self.here.teapot = true;
753- self.items.teapot_spout = true;
754- self.shame += 5;
755- return Problem::new(
756- StatusCode::IM_A_TEAPOT,
757- "teapot",
758- "The subject is now short and stout. Length gains are halved for the rest of the floor.",
759- );
760- }
761- if training == Training::Uli && !self.conditions.peyronie {
762- self.conditions.peyronie = true;
763- self.shame += 5;
764- return Problem::new(
765- StatusCode::MISDIRECTED_REQUEST,
766- "curvature",
767- "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
768- );
769- }
770- not_extended()
771- }
772-
773- fn gossip(&mut self, mut outcome: Outcome) -> Outcome {
774- let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206 | 308);
775- if !self.here.brother_told || !reportable {
776- return outcome;
777- }
778- self.here.brother_told = false;
779- outcome.status = StatusCode::ALREADY_REPORTED;
780- if let Payload::Json(body) = &mut outcome.payload {
781- body["detail"] = json!("Your brother has already told Mum.");
782- }
783- outcome
784- }
785-}
Modified file: src/game/utility.rs+21 additions−21 deletions
@@ -19,7 +19,7 @@ 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.warmed_up && !self.here.kettle_boiled {
22+ if self.body.warmed_up && !self.here.kitchen.kettle_boiled {
2323 return Err(Problem::new(
2424 StatusCode::CONFLICT,
2525 "towel-cold",
@@ -57,16 +57,16 @@ impl Run {
5757 };
5858 self.touch();
5959 self.step_away();
60- if self.warmed_up {
61- self.here.kettle_boiled = false;
60+ if self.body.warmed_up {
61+ self.here.kitchen.kettle_boiled = false;
6262 }
63- self.warmed_up = true;
64- self.eq = (self.eq + boost).min(100);
63+ self.body.warmed_up = true;
64+ self.body.eq = (self.body.eq + boost).min(100);
6565 Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
6666 }
6767
6868 pub(super) fn ice(&mut self) -> Result<Outcome, Problem> {
69- if self.items.ice_packs == 0 {
69+ if self.items.stores.ice_packs == 0 {
7070 return Err(Problem::new(
7171 StatusCode::FAILED_DEPENDENCY,
7272 "no-ice",
@@ -75,16 +75,16 @@ impl Run {
7575 }
7676 self.touch();
7777 self.step_away();
78- self.items.ice_packs -= 1;
79- if self.conditions.priapism_turns > 0 {
78+ self.items.stores.ice_packs -= 1;
79+ if self.body.conditions.priapism_turns > 0 {
8080 return Ok(Outcome::json(
8181 StatusCode::OK,
8282 json!({ "detail": "The ice has no effect. The condition persists. The ice pack is gone." }),
8383 ));
8484 }
85- self.eq = (self.eq - ICE_EQ).max(0);
86- let detail = if self.conditions.friction_burn {
87- self.conditions.friction_burn = false;
85+ self.body.eq = (self.body.eq - ICE_EQ).max(0);
86+ let detail = if self.body.conditions.friction_burn {
87+ self.body.conditions.friction_burn = false;
8888 "The friction burn is treated. EQ has been reduced. The ice pack is gone."
8989 } else {
9090 "EQ has been reduced. The ice pack is gone."
@@ -93,16 +93,16 @@ impl Run {
9393 }
9494
9595 pub(super) fn energy(&mut self) -> Result<Outcome, Problem> {
96- if self.items.energy_drinks == 0 {
96+ if self.items.stores.energy_drinks == 0 {
9797 return Err(Problem::new(
9898 StatusCode::FAILED_DEPENDENCY,
9999 "no-energy-drink",
100100 "The energy technique depends on an energy drink.",
101101 ));
102102 }
103- self.items.energy_drinks -= 1;
103+ self.items.stores.energy_drinks -= 1;
104104 self.step_away();
105- self.stamina = (self.stamina + ENERGY_STAMINA).min(self.max_stamina());
105+ self.body.stamina = (self.body.stamina + ENERGY_STAMINA).min(self.max_stamina());
106106 self.touch();
107107 Ok(Outcome::json(
108108 StatusCode::OK,
@@ -111,14 +111,14 @@ impl Run {
111111 }
112112
113113 pub(super) fn supplement(&mut self) -> Result<Outcome, Problem> {
114- if self.items.supplements == 0 {
114+ if self.items.stores.supplements == 0 {
115115 return Err(Problem::new(
116116 StatusCode::FAILED_DEPENDENCY,
117117 "no-supplement",
118118 "The supplement technique depends on a supplement.",
119119 ));
120120 }
121- self.items.supplements -= 1;
121+ self.items.stores.supplements -= 1;
122122 self.step_away();
123123 self.touch();
124124 if self.chance(
@@ -128,9 +128,9 @@ impl Run {
128128 ),
129129 SUPPLEMENT_WORKS,
130130 ) {
131- self.stamina = self.max_stamina();
132- self.conditions.priapism_turns = PRIAPISM_TURNS;
133- self.eq = self.eq.max(PRIAPISM_EQ);
131+ self.body.stamina = self.max_stamina();
132+ self.body.conditions.priapism_turns = PRIAPISM_TURNS;
133+ self.body.eq = self.body.eq.max(PRIAPISM_EQ);
134134 return Ok(Outcome::json(
135135 StatusCode::OK,
136136 json!({ "detail": "The upstream returned a valid response. It is still returning it. Stamina is fully restored." }),
@@ -151,8 +151,8 @@ impl Run {
151151 return Ok(interrupted);
152152 }
153153 self.here.edges += 1;
154- self.eq = (self.eq + EDGE_EQ).min(100);
155- self.girth += EDGE_GIRTH;
154+ self.body.eq = (self.body.eq + EDGE_EQ).min(100);
155+ self.body.girth += EDGE_GIRTH;
156156 if self.here.edges >= EDGE_LOOP {
157157 self.here.edges = 0;
158158 self.exhaust();
Modified file: src/http/ctx.rs+41 additions−11 deletions
@@ -1,4 +1,4 @@
1-use std::{collections::HashMap, time::Instant};
1+use std::{collections::HashMap, fmt::Write as _, time::Instant};
22
33 use axum::{
44 body::Bytes,
@@ -10,19 +10,19 @@ use hyper::upgrade::OnUpgrade;
1010
1111 use crate::problem::Problem;
1212
13-const BODY_LIMIT: usize = 64 * 1024;
13+pub const BODY_LIMIT: usize = 64 * 1024;
1414
1515 /// Everything a handler may look at, gathered once per request.
1616 pub struct Ctx {
17- pub method: Method,
18- pub uri: Uri,
19- pub version: Version,
20- pub headers: HeaderMap,
21- pub query: HashMap<String, String>,
22- pub params: HashMap<String, String>,
23- pub body: Bytes,
24- pub now: Instant,
25- pub upgrade: Option<OnUpgrade>,
17+ method: Method,
18+ uri: Uri,
19+ version: Version,
20+ headers: HeaderMap,
21+ query: HashMap<String, String>,
22+ params: HashMap<String, String>,
23+ body: Bytes,
24+ now: Instant,
25+ upgrade: Option<OnUpgrade>,
2626 }
2727
2828 impl Ctx {
@@ -48,6 +48,36 @@ impl Ctx {
4848 pub fn query(&self, name: &str) -> Option<&str> {
4949 self.query.get(name).map(String::as_str)
5050 }
51+
52+ pub fn method(&self) -> &Method {
53+ &self.method
54+ }
55+
56+ pub fn body(&self) -> &[u8] {
57+ &self.body
58+ }
59+
60+ pub fn now(&self) -> Instant {
61+ self.now
62+ }
63+
64+ pub fn take_upgrade(&mut self) -> Option<OnUpgrade> {
65+ self.upgrade.take()
66+ }
67+
68+ /// The request as it arrived, which is what TRACE sends back.
69+ pub fn echo(&self) -> String {
70+ let mut message = format!("{} {} {:?}\r\n", self.method, self.uri, self.version);
71+ for (name, value) in &self.headers {
72+ let _ = write!(
73+ message,
74+ "{name}: {}\r\n",
75+ value.to_str().unwrap_or("<binary>")
76+ );
77+ }
78+ message.push_str("\r\n");
79+ message
80+ }
5181 }
5282
5383 impl<S: Send + Sync> FromRequest<S> for Ctx {
Added file: src/http/inventory.rs+44 additions
@@ -0,0 +1,44 @@
1+use axum::{
2+ Json,
3+ http::StatusCode,
4+ response::{IntoResponse, Response},
5+};
6+use serde_json::json;
7+
8+use super::{ctx::Ctx, response::respond};
9+use crate::{
10+ game::{Item, Run},
11+ problem::Problem,
12+};
13+
14+pub(super) fn get_inventory(run: &mut Run, _: &Ctx) -> Response {
15+ Json(run.inventory_view()).into_response()
16+}
17+
18+pub(super) fn owned_item(run: &Run, ctx: &Ctx) -> Result<Item, Problem> {
19+ Item::parse(ctx.param("item"))
20+ .filter(|&item| run.holds(item))
21+ .ok_or_else(|| {
22+ Problem::new(
23+ StatusCode::NOT_FOUND,
24+ "no-such-item",
25+ "You do not have that.",
26+ )
27+ })
28+}
29+
30+pub(super) fn get_item(run: &mut Run, ctx: &Ctx) -> Response {
31+ match owned_item(run, ctx) {
32+ Ok(item) => {
33+ Json(json!({ "name": item.slug(), "description": item.description() })).into_response()
34+ }
35+ Err(problem) => problem.into_response(),
36+ }
37+}
38+
39+pub(super) fn delete_item(run: &mut Run, ctx: &Ctx) -> Response {
40+ match owned_item(run, ctx) {
41+ Ok(item) => respond(run.drop_item(item)),
42+ Err(problem) => problem.into_response(),
43+ }
44+}
Added file: src/http/leaderboard.rs+183 additions
@@ -0,0 +1,183 @@
1+//! The public listings, paged fifty at a time with RFC 8288 `next` and `prev` links.
2+
3+use axum::{
4+ Json,
5+ http::{StatusCode, header},
6+ response::{IntoResponse, Response},
7+};
8+use serde_json::{Value, json};
9+
10+use super::{Shared, ctx::Ctx, run::records_unavailable};
11+use crate::{
12+ game::{balance::MM_PER_INCH, volume},
13+ problem::{PROBLEM_BASE, Problem},
14+ records::{Ranking, Record},
15+};
16+
17+const PAGE: usize = 50;
18+
19+fn page(ctx: &Ctx) -> Result<usize, Problem> {
20+ match ctx.query("page").map(str::parse::<usize>) {
21+ None => Ok(1),
22+ Some(Ok(page)) if page >= 1 => Ok(page),
23+ Some(_) => Err(Problem::new(
24+ StatusCode::BAD_REQUEST,
25+ "bad-page",
26+ "Pages are counted from 1.",
27+ )),
28+ }
29+}
30+
31+/// `query` is everything but the page, already encoded, so the links keep the caller's sort.
32+fn paged(body: Value, path: &str, query: &str, page: usize, full: bool) -> Response {
33+ let link = |page: usize, rel: &str| {
34+ let separator = if query.is_empty() { "" } else { "&" };
35+ format!("<{path}?{query}{separator}page={page}>; rel=\"{rel}\"")
36+ };
37+ let mut links = Vec::new();
38+ if page > 1 {
39+ links.push(link(page - 1, "prev"));
40+ }
41+ if full {
42+ links.push(link(page + 1, "next"));
43+ }
44+ let mut response = Json(body).into_response();
45+ if links.is_empty() {
46+ return response;
47+ }
48+ if let Ok(value) = links.join(", ").parse() {
49+ response.headers_mut().insert(header::LINK, value);
50+ }
51+ response
52+}
53+
54+pub fn graveyard(state: &mut Shared, ctx: &Ctx) -> Response {
55+ let page = match page(ctx) {
56+ Ok(page) => page,
57+ Err(problem) => return problem.into_response(),
58+ };
59+ let dead = match state.records.dead((page - 1) * PAGE, PAGE) {
60+ Ok(dead) => dead,
61+ Err(error) => return records_unavailable(&error),
62+ };
63+ let full = dead.len() == PAGE;
64+ let runs: Vec<Value> = dead
65+ .into_iter()
66+ .filter_map(|record| {
67+ let cause = record.cause?;
68+ Some(json!({
69+ "public_id": record.public_id,
70+ "cause": format!("{PROBLEM_BASE}{}", cause.slug()),
71+ "detail": cause.detail(),
72+ "turn": record.turn,
73+ }))
74+ })
75+ .collect();
76+ paged(json!({ "runs": runs }), "/graveyard", "", page, full)
77+}
78+
79+fn ranking(state: &Shared, ctx: &Ctx) -> Result<Ranking, Problem> {
80+ match ctx.query("sort") {
81+ None | Some("self-reported-length") => Ok(Ranking::SelfReported),
82+ Some("volume") => Ok(Ranking::Volume),
83+ Some("content-length") => match ctx.cookie("ruler") {
84+ Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => {
85+ Ok(Ranking::ContentLength)
86+ }
87+ Some(_) => Err(Problem::new(
88+ StatusCode::BAD_REQUEST,
89+ "exaggeration",
90+ "The server has measured. Lying is not a technique.",
91+ )
92+ .with("cookie", "ruler")),
93+ None => Err(Problem::new(
94+ StatusCode::FORBIDDEN,
95+ "ruler-required",
96+ "Sorting by the truth requires a Ruler.",
97+ )),
98+ },
99+ Some(_) => Err(Problem::new(
100+ StatusCode::BAD_REQUEST,
101+ "unknown-sort",
102+ "The leaderboard sorts by self-reported-length, volume or content-length.",
103+ )),
104+ }
105+}
106+
107+fn score(ranking: Ranking, record: &Record) -> f64 {
108+ match ranking {
109+ Ranking::SelfReported => record.self_reported,
110+ Ranking::ContentLength => record.length,
111+ Ranking::Volume => volume(record.self_reported, record.girth),
112+ }
113+}
114+
115+fn entry(ranking: Ranking, record: &Record) -> Value {
116+ let mut entry = json!({
117+ "public_id": record.public_id,
118+ "status": record.status.name(),
119+ "floor": record.floor,
120+ "self_reported_length": (record.self_reported * 100.0).round() / 100.0,
121+ "ranked": !record.seeded,
122+ "viral": record.viral,
123+ });
124+ match ranking {
125+ Ranking::Volume => {
126+ entry["volume"] = json!((score(ranking, record) * 100.0).round() / 100.0);
127+ }
128+ Ranking::ContentLength => {
129+ entry["content_length"] = json!((record.length * MM_PER_INCH).round());
130+ }
131+ Ranking::SelfReported => {}
132+ }
133+ entry
134+}
135+
136+/// Live runs are ranked alongside the recorded ones, and a live run outranks its own stale record.
137+pub fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
138+ let (ranking, page) = match ranking(state, ctx).and_then(|r| Ok((r, page(ctx)?))) {
139+ Ok(parsed) => parsed,
140+ Err(problem) => return problem.into_response(),
141+ };
142+ let seeded = ctx.query("seeded") == Some("include");
143+ let mut entries: Vec<Record> = state
144+ .runs
145+ .each(Record::of)
146+ .into_iter()
147+ .filter(|record| seeded || !record.seeded)
148+ .collect();
149+ let wanted = page * PAGE;
150+ match state
151+ .records
152+ .top(ranking, seeded, 0, wanted + entries.len())
153+ {
154+ Ok(recorded) => entries.extend(
155+ recorded
156+ .into_iter()
157+ .filter(|record| !state.runs.contains(&record.id)),
158+ ),
159+ Err(error) => return records_unavailable(&error),
160+ }
161+ entries.sort_by(|a, b| score(ranking, b).total_cmp(&score(ranking, a)));
162+ let full = entries.len() > wanted;
163+ let listed: Vec<Value> = entries
164+ .iter()
165+ .skip(wanted - PAGE)
166+ .take(PAGE)
167+ .map(|record| entry(ranking, record))
168+ .collect();
169+ let mut query = Vec::new();
170+ if let Some(sort) = ctx.query("sort") {
171+ query.push(format!("sort={sort}"));
172+ }
173+ if seeded {
174+ query.push("seeded=include".to_owned());
175+ }
176+ paged(
177+ json!({ "entries": listed }),
178+ "/leaderboard",
179+ &query.join("&"),
180+ page,
181+ full,
182+ )
183+}
Added file: src/http/links.rs+122 additions
@@ -0,0 +1,122 @@
1+use std::fmt::Write as _;
2+
3+use super::{meta, run::RunResource, runs};
4+use crate::game::{Branch, FORUM_THREAD, Run, SHOP};
5+
6+pub const REL_BASE: &str = "https://jelq.dev/rels/";
7+
8+/// One RFC 8288 link. `allow` and `lubricant` are extension attributes (§3), so a client never
9+/// has to guess what a target accepts.
10+struct Link<'a> {
11+ href: &'a str,
12+ rel: &'a str,
13+ allow: &'a str,
14+ title: Option<&'a str>,
15+ lubricant: Option<&'a str>,
16+}
17+
18+impl Link<'_> {
19+ fn write(&self, out: &mut String) {
20+ if !out.is_empty() {
21+ out.push_str(", ");
22+ }
23+ let _ = write!(out, "<{}>; rel=\"{REL_BASE}{}\"", self.href, self.rel);
24+ if let Some(title) = self.title {
25+ let _ = write!(out, "; title=\"{title}\"");
26+ }
27+ let _ = write!(out, "; allow=\"{}\"", self.allow);
28+ if let Some(rule) = self.lubricant {
29+ let _ = write!(out, "; lubricant=\"{rule}\"");
30+ }
31+ }
32+}
33+
34+pub fn header(run: &Run) -> String {
35+ let reach = run.reach();
36+ let mut out = String::new();
37+ let resources: [(&str, &RunResource, bool); 15] = [
38+ ("", &runs::RUN, true),
39+ ("floor", &runs::FLOOR, true),
40+ ("length", &runs::LENGTH, true),
41+ ("foreskin", &runs::FORESKIN, reach.foreskin),
42+ ("inventory", &runs::INVENTORY, true),
43+ ("techniques", &runs::TECHNIQUES, true),
44+ ("drawer", &runs::DRAWER, reach.drawer),
45+ ("kitchen", &runs::KITCHEN, reach.kitchen),
46+ ("re-read", &runs::REREAD, reach.thread),
47+ ("grip", &runs::GRIP, true),
48+ ("shop", &runs::SHOP, reach.shop),
49+ ("portal", &runs::PORTAL, reach.portal),
50+ ("elder", &runs::ELDER, reach.elder),
51+ ("footage", &runs::FOOTAGE, reach.footage),
52+ ("diagnosis", &runs::DIAGNOSIS, reach.diagnosis),
53+ ];
54+ for (rel, target, _) in resources.iter().filter(|(_, _, relevant)| *relevant) {
55+ let href = if rel.is_empty() {
56+ run.path("")
57+ } else {
58+ run.path(&format!("/{rel}"))
59+ };
60+ Link {
61+ href: &href,
62+ rel: if rel.is_empty() { "run" } else { rel },
63+ allow: &target.resource.allow(run),
64+ title: None,
65+ lubricant: None,
66+ }
67+ .write(&mut out);
68+ }
69+ if reach.thread {
70+ Link {
71+ href: FORUM_THREAD,
72+ rel: "thread",
73+ allow: &meta::THREAD.allow_any(),
74+ title: None,
75+ lubricant: None,
76+ }
77+ .write(&mut out);
78+ }
79+ if reach.branch_pending {
80+ for (branch, title) in [
81+ (Branch::Forum, "Read the forum"),
82+ (Branch::CloseTab, "Close the tab"),
83+ ] {
84+ Link {
85+ href: &run.path(&format!("/branches/{}", branch.slug())),
86+ rel: "branch",
87+ allow: "POST",
88+ title: Some(title),
89+ lubricant: None,
90+ }
91+ .write(&mut out);
92+ }
93+ }
94+ let run_path = run.path("");
95+ for technique in run.techniques_now() {
96+ let name = technique.name();
97+ Link {
98+ href: &format!("{run_path}?technique={name}"),
99+ rel: "technique",
100+ allow: "JELQ",
101+ title: Some(name),
102+ lubricant: technique.lubricant(),
103+ }
104+ .write(&mut out);
105+ }
106+ if reach.shopping {
107+ for item in SHOP {
108+ let Some(price) = run.price(item) else {
109+ continue;
110+ };
111+ Link {
112+ href: &run.path(&format!("/shop/{}", item.slug())),
113+ rel: "shop-item",
114+ allow: "POST",
115+ title: Some(&format!("{} for {price}", item.slug())),
116+ lubricant: None,
117+ }
118+ .write(&mut out);
119+ }
120+ }
121+ out
122+}
Modified file: src/http/meta.rs+22 additions−125 deletions
@@ -6,23 +6,21 @@ use axum::{
66 response::{IntoResponse, Response},
77 };
88 use rand::Rng;
9-use serde_json::{Value, json};
9+use serde_json::json;
1010
1111 use super::{
1212 Shared,
1313 ctx::Ctx,
14+ leaderboard::{graveyard, leaderboard},
1415 resource::{Resource, Verb},
15- run,
16+ run::{self, records_unavailable},
1617 };
1718 use crate::{
18- game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread, volume},
19+ game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, forum_thread},
1920 headers::X_SEED,
20- problem::{PROBLEM_BASE, Problem},
21- records::Record,
21+ problem::Problem,
2222 };
2323
24-use super::registry::hold;
25-
2624 pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);
2725 pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);
2826 pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);
@@ -69,9 +67,6 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
6967 }
7068 };
7169 let config = &state.config;
72- for slot in state.runs.evict_idle(ctx.now, config.idle_ttl) {
73- state.records.save(&Record::of(&hold(&slot)));
74- }
7570 if state.runs.len() >= config.max_runs {
7671 return Problem::new(
7772 StatusCode::SERVICE_UNAVAILABLE,
@@ -81,21 +76,21 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
8176 .header(header::RETRY_AFTER, "600")
8277 .into_response();
8378 }
84- let id = loop {
79+ let mut run = loop {
8580 let id = format!("{:032x}", rand::rng().random::<u128>());
86- if !state.runs.contains(&id) && state.records.find(&id).is_none() {
87- break id;
81+ match state.records.find(&id) {
82+ Ok(None) => {}
83+ Ok(Some(_)) => continue,
84+ Err(error) => return records_unavailable(&error),
8885 }
86+ break Run::with_oracle(
87+ id,
88+ format!("{:08x}", rand::rng().random::<u32>()),
89+ config.secret.mix_seed(seed),
90+ seeded,
91+ &config.oracle,
92+ );
8993 };
90- let public_id = format!("{:08x}", rand::rng().random::<u32>());
91- let mut run = Run::with_oracle(
92- id.clone(),
93- public_id,
94- config.secret.mix_seed(seed),
95- &config.oracle,
96- );
97- run.seeded = seeded;
98- run.last_seen = ctx.now;
9994 let response = (
10095 StatusCode::CREATED,
10196 [
@@ -107,8 +102,11 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
107102 )
108103 .into_response();
109104 let response = run::finish(&mut run, ctx, &config.secret, response);
110- state.runs.insert(run);
111- response
105+ if state.runs.insert_new(run) {
106+ response
107+ } else {
108+ create_run(state, ctx)
109+ }
112110 }
113111
114112 fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
@@ -135,107 +133,6 @@ fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
135133 }
136134 }
137135
138-fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
139- let dead: Vec<Value> = state
140- .records
141- .dead()
142- .into_iter()
143- .filter_map(|record| {
144- let ending = record.ending?;
145- Some(json!({
146- "public_id": record.public_id,
147- "cause": format!("{PROBLEM_BASE}{}", ending.cause),
148- "detail": ending.detail,
149- "turn": record.turn,
150- }))
151- })
152- .collect();
153- Json(json!({ "runs": dead })).into_response()
154-}
155-
156-#[derive(Clone, Copy, PartialEq, Eq)]
157-enum Sort {
158- SelfReported,
159- ContentLength,
160- /// Self-reported length against real girth, the only metric the forum respects.
161- Volume,
162-}
163-
164-fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
165- let sort = match ctx.query("sort") {
166- None | Some("self-reported-length") => Sort::SelfReported,
167- Some("volume") => Sort::Volume,
168- Some("content-length") => match ctx.cookie("ruler") {
169- Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => {
170- Sort::ContentLength
171- }
172- Some(_) => {
173- return Problem::new(
174- StatusCode::BAD_REQUEST,
175- "exaggeration",
176- "The server has measured. Lying is not a technique.",
177- )
178- .with("cookie", "ruler")
179- .into_response();
180- }
181- None => {
182- return Problem::new(
183- StatusCode::FORBIDDEN,
184- "ruler-required",
185- "Sorting by the truth requires a Ruler.",
186- )
187- .into_response();
188- }
189- },
190- Some(_) => {
191- return Problem::new(
192- StatusCode::BAD_REQUEST,
193- "unknown-sort",
194- "The leaderboard sorts by self-reported-length, volume or content-length.",
195- )
196- .into_response();
197- }
198- };
199- let include_seeded = ctx.query("seeded") == Some("include");
200- let mut entries: Vec<Record> = state.runs.each(Record::of);
201- entries.extend(
202- state
203- .records
204- .all()
205- .into_iter()
206- .filter(|record| !state.runs.contains(&record.id)),
207- );
208- entries.retain(|record| include_seeded || !record.seeded);
209- let key = |record: &Record| match sort {
210- Sort::SelfReported => record.self_reported,
211- Sort::ContentLength => record.length,
212- Sort::Volume => volume(record.self_reported, record.girth),
213- };
214- entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
215- let entries: Vec<Value> = entries
216- .into_iter()
217- .map(|record| {
218- let mut entry = json!({
219- "public_id": record.public_id,
220- "status": record.status,
221- "floor": record.floor,
222- "self_reported_length": (record.self_reported * 100.0).round() / 100.0,
223- "ranked": !record.seeded,
224- "viral": record.viral,
225- });
226- if sort == Sort::Volume {
227- entry["volume"] =
228- json!((volume(record.self_reported, record.girth) * 100.0).round() / 100.0);
229- }
230- if sort == Sort::ContentLength {
231- entry["content_length"] = json!((record.length * MM_PER_INCH).round());
232- }
233- entry
234- })
235- .collect();
236- Json(json!({ "entries": entries })).into_response()
237-}
238-
239136 pub async fn not_found() -> Problem {
240137 Problem::new(
241138 StatusCode::NOT_FOUND,
Modified file: src/http/middleware.rs+10 additions−3 deletions
@@ -19,6 +19,9 @@ use crate::{
1919
2020 const MAX_URI: usize = 512;
2121 const MAX_HEADER: usize = 1024;
22+const MAX_HEADERS: usize = 64;
23+/// Responses are re-read to be translated or gzipped. The listings are paged, so none comes close.
24+const BUFFER_LIMIT: usize = 1 << 20;
2225
2326 /// Marks a response for gzip, which the cock ring asks for.
2427 #[derive(Clone)]
@@ -39,7 +42,11 @@ fn reject(request: &Request) -> Option<Problem> {
3942 "The URI is longer than anything else here.",
4043 ));
4144 }
42- if request.headers().values().any(|v| v.len() > MAX_HEADER) {
45+ let headers = request.headers();
46+ let oversized = headers
47+ .iter()
48+ .any(|(name, value)| name.as_str().len() + value.len() > MAX_HEADER);
49+ if oversized || headers.len() > MAX_HEADERS {
4350 return Some(Problem::new(
4451 StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
4552 "head-too-large",
@@ -74,7 +81,7 @@ async fn compress(response: Response) -> Response {
7481 return response;
7582 }
7683 let (mut parts, body) = response.into_parts();
77- let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
84+ let Ok(bytes) = axum::body::to_bytes(body, BUFFER_LIMIT).await else {
7885 return Response::from_parts(parts, Body::empty());
7986 };
8087 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
@@ -122,7 +129,7 @@ async fn localise(response: Response, locale: Locale) -> Response {
122129 if !json || parts.status == StatusCode::PARTIAL_CONTENT {
123130 return Response::from_parts(parts, body);
124131 }
125- let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
132+ let Ok(bytes) = axum::body::to_bytes(body, BUFFER_LIMIT).await else {
126133 return Response::from_parts(parts, Body::empty());
127134 };
128135 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
Modified file: src/http/mod.rs+48 additions−17 deletions
@@ -1,14 +1,23 @@
11 mod ctx;
2+mod inventory;
3+mod leaderboard;
4+mod links;
25 mod meta;
36 mod middleware;
7+mod parse;
48 mod registry;
59 mod resource;
610 mod response;
711 mod run;
812 mod runs;
13+mod shaft;
14+mod shame;
915 mod ui;
1016
11-use std::sync::Arc;
17+use std::{
18+ sync::Arc,
19+ time::{Duration, Instant},
20+};
1221
1322 use axum::{
1423 Router,
@@ -19,9 +28,12 @@ use axum::{
1928
2029 use tower_http::catch_panic::CatchPanicLayer;
2130
22-use crate::{config::Config, records::Records};
31+use crate::{
32+ config::Config,
33+ records::{Record, Records},
34+};
2335 use ctx::Ctx;
24-use registry::Registry;
36+use registry::{Lock, Registry};
2537 use resource::Resource;
2638 use run::RunResource;
2739
@@ -32,36 +44,58 @@ pub struct AppState {
3244 }
3345
3446 impl AppState {
35- pub fn new(config: Config) -> Self {
47+ pub fn new(config: Config) -> rusqlite::Result<Self> {
3648 let records = match &config.db {
37- Some(path) => Records::open(path).unwrap_or_else(|e| {
38- panic!("the records at {} could not be opened: {e}", path.display())
39- }),
49+ Some(path) => Records::open(path)?,
4050 None => Records::in_memory(),
4151 };
42- Self {
52+ Ok(Self {
4353 runs: Registry::default(),
4454 records,
4555 config,
56+ })
57+ }
58+
59+ /// Records and forgets the runs that have gone idle. The server calls it on a timer.
60+ pub fn sweep(&self, now: Instant) -> rusqlite::Result<()> {
61+ for slot in self.runs.evict_idle(now, self.config.idle_ttl) {
62+ let record = match Lock::take(&slot) {
63+ Lock::Held(run) | Lock::Poisoned(run) => Record::of(&run),
64+ };
65+ self.records.save(&record)?;
4666 }
67+ Ok(())
4768 }
4869
4970 #[cfg(test)]
5071 pub(crate) fn insert(&self, run: crate::game::Run) {
51- self.runs.insert(run);
72+ self.runs.insert_new(run);
5273 }
5374 }
5475
5576 impl Default for AppState {
5677 fn default() -> Self {
57- Self::new(Config::default())
78+ Self::new(Config::default()).expect("an in-memory database always opens")
5879 }
5980 }
6081
6182 type Shared = Arc<AppState>;
6283
63-pub fn app(config: Config) -> Router {
64- router(Arc::new(AppState::new(config)))
84+pub fn app(config: Config) -> rusqlite::Result<Router> {
85+ Ok(router(Arc::new(AppState::new(config)?)))
86+}
87+
88+/// Sweeps idle runs into the records every minute, so a quiet server still keeps its graveyard.
89+pub async fn sweep_forever(state: Arc<AppState>) {
90+ let mut ticks = tokio::time::interval(Duration::from_secs(60));
91+ loop {
92+ ticks.tick().await;
93+ let swept = Arc::clone(&state);
94+ let result = tokio::task::spawn_blocking(move || swept.sweep(Instant::now())).await;
95+ if let Ok(Err(error)) = result {
96+ eprintln!("jelqd could not sweep idle runs into the records: {error}");
97+ }
98+ }
6599 }
66100
67101 pub fn router(state: Shared) -> Router {
@@ -87,15 +121,12 @@ fn scoped(resource: &'static RunResource) -> MethodRouter<Shared> {
87121 })
88122 }
89123
90-/// The model oracle blocks on HTTP for every roll, so its turns run on the blocking pool and
91-/// leave the async workers free. Dice are quick enough to roll in place.
124+/// Game turns block, on SQLite always and on HTTP for every roll under the model oracle, so they
125+/// run on the blocking pool and leave the async workers free.
92126 async fn offload<F>(state: Shared, work: F) -> Response
93127 where
94128 F: FnOnce(Shared) -> Response + Send + 'static,
95129 {
96- if !state.config.oracle.blocks() {
97- return work(state);
98- }
99130 tokio::task::spawn_blocking(move || work(state))
100131 .await
101132 .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))
Added file: src/http/parse.rs+49 additions
@@ -0,0 +1,49 @@
1+use axum::http::StatusCode;
2+
3+use super::ctx::Ctx;
4+use crate::{
5+ game::{Run, Technique},
6+ problem::Problem,
7+};
8+
9+pub(super) fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {
10+ let required = || {
11+ Problem::new(
12+ StatusCode::BAD_REQUEST,
13+ "technique-required",
14+ "No technique was specified.",
15+ )
16+ .with("techniques", run.path("/techniques"))
17+ };
18+ let names = ctx
19+ .query("technique")
20+ .filter(|n| !n.trim().is_empty())
21+ .ok_or_else(required)?;
22+ names
23+ .split(',')
24+ .map(str::trim)
25+ .filter(|n| !n.is_empty())
26+ .map(|name| {
27+ Technique::parse(name).ok_or_else(|| {
28+ Problem::new(
29+ StatusCode::BAD_REQUEST,
30+ "unknown-technique",
31+ format!("{name} is not a technique."),
32+ )
33+ .with("techniques", run.path("/techniques"))
34+ })
35+ })
36+ .collect()
37+}
38+
39+pub(super) fn parse_intensity(ctx: &Ctx) -> Result<i32, Problem> {
40+ match ctx.query("intensity").map(str::parse::<i32>) {
41+ None => Ok(5),
42+ Some(Ok(i)) if (1..=10).contains(&i) => Ok(i),
43+ Some(_) => Err(Problem::new(
44+ StatusCode::BAD_REQUEST,
45+ "intensity-out-of-range",
46+ "Intensity is measured from 1 to 10.",
47+ )),
48+ }
49+}
Modified file: src/http/registry.rs+52 additions−26 deletions
@@ -1,9 +1,13 @@
11 //! The live runs. The table is only locked long enough to find a run, and each run has its own
2-//! lock, so one slow turn never holds up anyone else's.
2+//! lock, so one slow turn never holds up anyone else's. Nothing ever waits on a run's lock while
3+//! holding the table's, which is what keeps the two from deadlocking.
34
45 use std::{
5- collections::HashMap,
6- sync::{Arc, Mutex, MutexGuard, PoisonError, RwLock, TryLockError},
6+ collections::{HashMap, hash_map::Entry},
7+ sync::{
8+ Arc, Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard,
9+ TryLockError,
10+ },
711 time::{Duration, Instant},
812 };
913
@@ -16,9 +20,27 @@ pub struct Registry {
1620 runs: RwLock<HashMap<String, Slot>>,
1721 }
1822
19-/// A panic mid-request must not take the run down with it, so a poisoned lock is recovered.
20-pub fn hold(slot: &Slot) -> MutexGuard<'_, Run> {
21- slot.lock().unwrap_or_else(PoisonError::into_inner)
23+/// A run's lock as it was found. A panic mid-turn poisons it, and that run cannot be trusted.
24+pub enum Lock<'a> {
25+ Held(MutexGuard<'a, Run>),
26+ Poisoned(MutexGuard<'a, Run>),
27+}
28+
29+impl<'a> Lock<'a> {
30+ pub fn take(slot: &'a Slot) -> Self {
31+ match slot.lock() {
32+ Ok(run) => Self::Held(run),
33+ Err(poisoned) => Self::Poisoned(poisoned.into_inner()),
34+ }
35+ }
36+}
37+
38+fn peek(slot: &Slot) -> Option<MutexGuard<'_, Run>> {
39+ match slot.try_lock() {
40+ Ok(run) => Some(run),
41+ Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
42+ Err(TryLockError::WouldBlock) => None,
43+ }
2244 }
2345
2446 impl Registry {
@@ -30,48 +52,52 @@ impl Registry {
3052 self.read().len()
3153 }
3254
33- pub fn insert(&self, run: Run) {
34- let id = run.id.clone();
35- self.write().insert(id, Arc::new(Mutex::new(run)));
55+ pub fn contains(&self, id: &str) -> bool {
56+ self.read().contains_key(id)
3657 }
3758
38- pub fn remove(&self, id: &str) {
39- self.write().remove(id);
59+ /// Refuses to replace a live run, so a colliding id can never evict one.
60+ pub fn insert_new(&self, run: Run) -> bool {
61+ match self.write().entry(run.id().to_owned()) {
62+ Entry::Occupied(_) => false,
63+ Entry::Vacant(slot) => {
64+ slot.insert(Arc::new(Mutex::new(run)));
65+ true
66+ }
67+ }
4068 }
4169
42- pub fn contains(&self, id: &str) -> bool {
43- self.read().contains_key(id)
70+ pub fn remove(&self, id: &str) {
71+ self.write().remove(id);
4472 }
4573
46- /// Every live run, each visited under its own lock.
74+ /// Every live run not mid-turn. A busy run is skipped rather than waited for.
4775 pub fn each<T>(&self, mut visit: impl FnMut(&Run) -> T) -> Vec<T> {
4876 let slots: Vec<Slot> = self.read().values().cloned().collect();
49- slots.iter().map(|slot| visit(&hold(slot))).collect()
77+ slots
78+ .iter()
79+ .filter_map(|slot| peek(slot).map(|run| visit(&run)))
80+ .collect()
5081 }
5182
52- /// Takes out the runs nobody has touched for `ttl`, and hands them back for the record.
5383 pub fn evict_idle(&self, now: Instant, ttl: Duration) -> Vec<Slot> {
54- let mut runs = self.write();
55- let idle: Vec<String> = runs
84+ let idle: Vec<String> = self
85+ .read()
5686 .iter()
5787 .filter(|(_, slot)| {
58- let idle = |run: &Run| now.duration_since(run.last_seen) >= ttl;
59- match slot.try_lock() {
60- Ok(run) => idle(&run),
61- Err(TryLockError::Poisoned(poisoned)) => idle(&poisoned.into_inner()),
62- Err(TryLockError::WouldBlock) => false,
63- }
88+ peek(slot).is_some_and(|run| now.duration_since(run.last_seen()) >= ttl)
6489 })
6590 .map(|(id, _)| id.clone())
6691 .collect();
92+ let mut runs = self.write();
6793 idle.iter().filter_map(|id| runs.remove(id)).collect()
6894 }
6995
70- fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<String, Slot>> {
96+ fn read(&self) -> RwLockReadGuard<'_, HashMap<String, Slot>> {
7197 self.runs.read().unwrap_or_else(PoisonError::into_inner)
7298 }
7399
74- fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<String, Slot>> {
100+ fn write(&self) -> RwLockWriteGuard<'_, HashMap<String, Slot>> {
75101 self.runs.write().unwrap_or_else(PoisonError::into_inner)
76102 }
77103 }
Modified file: src/http/resource.rs+28 additions−5 deletions
@@ -15,6 +15,14 @@ pub type Handler<T> = fn(&mut T, &Ctx) -> Response;
1515 /// A predicate for when a method is available, and the refusal when it is not.
1616 type Guard<T> = (fn(&T) -> bool, &'static str);
1717
18+fn never<T>(_: &T) -> bool {
19+ false
20+}
21+
22+fn unanswered<T>(_: &mut T, _: &Ctx) -> Response {
23+ StatusCode::METHOD_NOT_ALLOWED.into_response()
24+}
25+
1826 pub struct Verb<T: 'static> {
1927 method: &'static str,
2028 handle: Handler<T>,
@@ -30,7 +38,15 @@ impl<T> Verb<T> {
3038 }
3139 }
3240
33- /// Makes the method available only while `available` holds, refusing with `refusal` otherwise.
41+ /// A method named only to be refused, so that its `405` can say why.
42+ pub const fn refused(method: &'static str, refusal: &'static str) -> Self {
43+ Self {
44+ method,
45+ handle: unanswered::<T>,
46+ guard: Some((never::<T>, refusal)),
47+ }
48+ }
49+
3450 pub const fn only_when(self, available: fn(&T) -> bool, refusal: &'static str) -> Self {
3551 Self {
3652 guard: Some((available, refusal)),
@@ -45,8 +61,8 @@ impl<T> Verb<T> {
4561
4662 /// The methods a resource supports. `Allow`, `OPTIONS` and `405` are all derived from this list.
4763 pub struct Resource<T: 'static> {
48- pub verbs: &'static [Verb<T>],
49- pub accept_patch: bool,
64+ verbs: &'static [Verb<T>],
65+ accept_patch: bool,
5066 }
5167
5268 impl<T> Resource<T> {
@@ -57,6 +73,13 @@ impl<T> Resource<T> {
5773 }
5874 }
5975
76+ pub const fn patchable(verbs: &'static [Verb<T>]) -> Self {
77+ Self {
78+ verbs,
79+ accept_patch: true,
80+ }
81+ }
82+
6083 pub fn allow(&self, target: &T) -> String {
6184 Self::listed(self.verbs.iter().filter(|verb| verb.available(target)))
6285 }
@@ -79,7 +102,7 @@ impl<T> Resource<T> {
79102 }
80103
81104 pub fn dispatch(&self, target: &mut T, ctx: &Ctx) -> Response {
82- let requested = ctx.method.as_str();
105+ let requested = ctx.method().as_str();
83106 let lookup = if requested == "HEAD" {
84107 "GET"
85108 } else {
@@ -92,7 +115,7 @@ impl<T> Resource<T> {
92115 method_not_allowed(refusal.to_owned(), &self.allow(target))
93116 }
94117 None if requested == "OPTIONS" => options(&self.allow(target), self.accept_patch),
95- None => unsupported(&ctx.method, &self.allow(target)),
118+ None => unsupported(ctx.method(), &self.allow(target)),
96119 }
97120 }
98121 }
Modified file: src/http/response.rs+3 additions−2 deletions
@@ -8,14 +8,15 @@ use crate::game::{Outcome, Payload};
88
99 impl IntoResponse for Outcome {
1010 fn into_response(self) -> Response {
11- let mut response = match self.payload {
11+ let (status, payload, extra) = self.into_parts();
12+ let mut response = match payload {
1213 // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
13- Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {
14+ Payload::Json(body) if status == StatusCode::PARTIAL_CONTENT => {
1415 let full = body.to_string().into_bytes();
1516 let half = full.len() / 2;
1617 let ra

show full diff (262144 bytes truncated)