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+8 additions−7 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 range = format!("bytes 0-{}/{}", half.saturating_sub(1), full.len());
1718 (
18- self.status,
19+ status,
1920 [
2021 (header::CONTENT_TYPE, "application/json".to_owned()),
2122 (header::CONTENT_RANGE, range),
@@ -24,14 +25,14 @@ impl IntoResponse for Outcome {
2425 )
2526 .into_response()
2627 }
27- Payload::Json(body) => (self.status, Json(body)).into_response(),
28+ Payload::Json(body) => (status, Json(body)).into_response(),
2829 Payload::Raw(content_type, body) => {
29- (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()
30+ (status, [(header::CONTENT_TYPE, content_type)], body).into_response()
3031 }
31- Payload::Empty => self.status.into_response(),
32+ Payload::Empty => status.into_response(),
3233 };
3334 let headers = response.headers_mut();
34- for (name, value) in self.headers {
35+ for (name, value) in extra {
3536 if let Ok(value) = HeaderValue::from_str(&value) {
3637 headers.append(name, value);
3738 }
Modified file: src/http/run.rs+89 additions−175 deletions
@@ -8,12 +8,12 @@ use hyper_util::rt::TokioIo;
88 use tokio::io::AsyncWriteExt;
99
1010 use super::{
11- AppState, ctx::Ctx, meta, middleware::Compress, registry::hold, resource::Resource, runs,
11+ AppState, ctx::Ctx, links, middleware::Compress, registry::Lock, resource::Resource, shame,
1212 };
1313 use crate::{
1414 game::{
15- Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
16- balance::{FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},
15+ Cause, Gauges, Run, Shown,
16+ balance::{FRICTION_MAX, SUSPICION_LIMIT},
1717 },
1818 headers::{
1919 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
@@ -25,8 +25,6 @@ use crate::{
2525 secret::Secret,
2626 };
2727
28-pub const REL_BASE: &str = "https://jelq.dev/rels/";
29-const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
3028 /// Which household hazards stand between a request and the run.
3129 #[derive(Clone, Copy, PartialEq, Eq)]
3230 pub enum Scope {
@@ -43,40 +41,59 @@ pub struct RunResource {
4341 #[derive(Clone)]
4442 pub struct Greeting(pub String);
4543
44+pub fn records_unavailable(error: &rusqlite::Error) -> Response {
45+ Problem::new(
46+ StatusCode::SERVICE_UNAVAILABLE,
47+ "records-unavailable",
48+ "The records are unavailable. Nothing has been forgotten, it is only out of reach.",
49+ )
50+ .with("error", error.to_string())
51+ .into_response()
52+}
53+
4654 pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
4755 let id = ctx.param("id").to_owned();
4856 let Some(slot) = state.runs.get(&id) else {
4957 return match state.records.find(&id) {
50- Some(Record {
51- ending: Some(ending),
58+ Ok(Some(Record {
59+ cause: Some(cause),
5260 turn,
5361 ..
54- }) => gone(&ending.cause, turn),
55- _ => Problem::new(
62+ })) => gone(cause.slug(), turn),
63+ Ok(_) => Problem::new(
5664 StatusCode::NOT_FOUND,
5765 "no-such-run",
5866 "There is no such run. There may never have been.",
5967 )
6068 .into_response(),
69+ Err(error) => records_unavailable(&error),
6170 };
6271 };
63- let mut guard = hold(&slot);
64- let run = &mut *guard;
65- if let Some(ending) = &run.ended {
66- return gone(ending.cause.slug(), ending.turn);
67- }
68- run.last_seen = ctx.now;
69- run.observed = None;
70- let response =
71- gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
72- let response = finish(run, &ctx, &state.config.secret, response);
73- if run.status().finished() {
74- state.records.save(&Record::of(run));
75- }
76- let ended = run.ended.is_some();
77- drop(guard);
78- if ended {
79- state.runs.remove(&id);
72+ let (response, record) = match Lock::take(&slot) {
73+ Lock::Poisoned(mut run) => {
74+ run.lose();
75+ let response = gone(Cause::Lost.slug(), run.turn);
76+ (response, Some(Record::of(&run)))
77+ }
78+ Lock::Held(mut run) => {
79+ if let Some(ending) = &run.ended {
80+ return gone(ending.cause.slug(), ending.turn);
81+ }
82+ run.seen(ctx.now());
83+ let response = gate(&mut run, &ctx, target.scope)
84+ .unwrap_or_else(|| target.resource.dispatch(&mut run, &ctx));
85+ let response = finish(&mut run, &ctx, &state.config.secret, response);
86+ let record = run.status().finished().then(|| Record::of(&run));
87+ (response, record)
88+ }
89+ };
90+ if let Some(record) = record {
91+ if let Err(error) = state.records.save(&record) {
92+ return records_unavailable(&error);
93+ }
94+ if record.cause.is_some() {
95+ state.runs.remove(&id);
96+ }
8097 }
8198 greet(response, &mut ctx)
8299 }
@@ -93,7 +110,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
93110 if scope == Scope::Pockets {
94111 return None;
95112 }
96- if run.floor == Floor::Hotel && !run.here.wifi_accepted {
113+ if run.needs_portal() {
97114 let portal = run.path("/portal");
98115 return Some(
99116 Problem::new(
@@ -102,7 +119,10 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
102119 "The network requires authentication. The hotel would like you to agree to some terms.",
103120 )
104121 .with("portal", portal.as_str())
105- .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\""))
122+ .header(
123+ header::LINK,
124+ format!("<{portal}>; rel=\"{}portal\"", links::REL_BASE),
125+ )
106126 .into_response(),
107127 );
108128 }
@@ -114,174 +134,68 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
114134 .map(IntoResponse::into_response)
115135 }
116136
117-/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
118-/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
119-fn links(run: &Run) -> String {
120- let thread_reachable = run.thread_reachable();
121- let linked: [(&str, &RunResource, bool); 15] = [
122- ("", &runs::RUN, true),
123- ("floor", &runs::FLOOR, true),
124- ("length", &runs::LENGTH, true),
125- ("foreskin", &runs::FORESKIN, run.foreskin.is_some()),
126- ("inventory", &runs::INVENTORY, true),
127- ("techniques", &runs::TECHNIQUES, true),
128- ("drawer", &runs::DRAWER, run.drawer_worth_searching()),
129- ("kitchen", &runs::KITCHEN, {
130- run.floor.has_kitchen() && run.kitchen_tellings < KITCHEN_TELLINGS
131- }),
132- ("re-read", &runs::REREAD, thread_reachable),
133- ("grip", &runs::GRIP, true),
134- ("shop", &runs::SHOP, run.floor.shop_open()),
137+fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
138+ let mut stats = vec![
139+ match gauges.length {
140+ Shown::Measured(length) => (X_LENGTH, format!("{length:.2}")),
141+ Shown::SelfReported(length) => (X_SELF_REPORTED_LENGTH, format!("{length:.2}")),
142+ },
143+ (X_GIRTH, format!("{:.2}", gauges.girth)),
135144 (
136- "portal",
137- &runs::PORTAL,
138- run.floor == Floor::Hotel && !run.here.wifi_accepted,
145+ X_FORESKIN,
146+ gauges.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
139147 ),
148+ (X_FRICTION, format!("{}/{FRICTION_MAX}", gauges.friction)),
149+ (X_EQ, gauges.eq.to_string()),
140150 (
141- "elder",
142- &runs::ELDER,
143- run.floor == Floor::Forum && run.elder != Elder::Beaten,
151+ X_STAMINA,
152+ format!("{}/{}", gauges.stamina, gauges.max_stamina),
144153 ),
145- ("footage", &runs::FOOTAGE, run.filmed_at.is_some()),
146- ("diagnosis", &runs::DIAGNOSIS, run.floor == Floor::Clinic),
147- ];
148- linked
149- .into_iter()
150- .filter(|(_, _, relevant)| *relevant)
151- .map(|(rel, target, _)| {
152- let (path, name) = if rel.is_empty() {
153- (run.path(""), "run")
154- } else {
155- (run.path(&format!("/{rel}")), rel)
156- };
157- let allow = target.resource.allow(run);
158- format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
159- })
160- .chain(thread_reachable.then(|| {
161- format!(
162- "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
163- meta::THREAD.allow_any()
164- )
165- }),)
166- .chain((run.story == Story::Pending).then(|| {
167- let choice = |slug: &str, title: &str| {
168- format!(
169- "<{}>; rel=\"{REL_BASE}branch\"; title=\"{title}\"; allow=\"POST\"",
170- run.path(&format!("/branches/{slug}"))
171- )
172- };
173- [choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ")
174- }))
175- .chain(run.techniques_now().into_iter().map(|technique| {
176- let name = technique.name();
177- let lubricant = technique
178- .lubricant()
179- .map_or(String::new(), |rule| format!("; lubricant=\"{rule}\""));
180- format!(
181- "<{}?technique={name}>; rel=\"{REL_BASE}technique\"; title=\"{name}\"; allow=\"JELQ\"{lubricant}",
182- run.path("")
183- )
184- }))
185- .chain(run.here.shopping.then(|| {
186- SHOP.iter()
187- .filter_map(|item| {
188- let price = run.price(*item)?;
189- Some(format!(
190- "<{}>; rel=\"{REL_BASE}shop-item\"; title=\"{} for {price}\"; allow=\"POST\"",
191- run.path(&format!("/shop/{}", item.slug())),
192- item.slug()
193- ))
194- })
195- .collect::<Vec<_>>()
196- .join(", ")
197- }))
198- .collect::<Vec<_>>()
199- .join(", ")
200-}
201-
202-/// Shame lives in a signed cookie so it follows the player across runs. Each response adds this
203-/// run's new shame to whatever total the client presents, so two runs at once both count.
204-fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 {
205- let presented = ctx
206- .cookie("shame")
207- .and_then(|signed| secret.verify("shame", signed))
208- .and_then(|total| total.parse::<u32>().ok());
209- let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported);
210- run.shame_reported = run.shame;
211- run.shame_total = total;
212- total
213-}
214-
215-pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
216- let status = response.status();
217- if status == StatusCode::NOT_MODIFIED {
218- return response;
219- }
220- let shame = settle_shame(run, ctx, secret);
221- let mut stats: Vec<(HeaderName, String)> = vec![
222- if run.items.ruler {
223- (X_LENGTH, format!("{:.2}", run.current_length()))
224- } else {
225- (
226- X_SELF_REPORTED_LENGTH,
227- format!("{:.2}", run.self_reported()),
228- )
229- },
230- (X_GIRTH, format!("{:.2}", run.girth)),
231154 (
232- X_FORESKIN,
233- run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
155+ X_SUSPICION,
156+ format!("{}/{SUSPICION_LIMIT}", gauges.suspicion),
234157 ),
235- (X_FRICTION, format!("{}/{}", run.friction, FRICTION_MAX)),
236- (X_EQ, run.eq.to_string()),
237- (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),
238- (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),
239158 (X_SHAME, shame.to_string()),
240- (X_CASH, run.cash.to_string()),
241- (X_FLOOR, run.floor.number().to_string()),
242- (header::LINK, links(run)),
159+ (X_CASH, gauges.cash.to_string()),
160+ (X_FLOOR, gauges.floor.to_string()),
243161 ];
244- if let Some(observer) = run.observed {
162+ if let Some(observer) = gauges.observed {
245163 stats.push((X_OBSERVED_BY, observer.name().into()));
246164 }
247- if run.mum.is_some() {
165+ if gauges.mum_at_door {
248166 stats.push((header::VIA, "1.1 mum".into()));
249167 stats.push((header::VARY, "Mum".into()));
250168 }
251169 // RFC 9111 obsoleted Warning, which is why nobody heeds it.
252- if run.conditions.pulled_muscle {
253- stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));
254- }
255- if run.conditions.friction_burn {
256- stats.push((header::WARNING, "199 - \"friction burn\"".into()));
257- }
258- for notice in run.notices.drain(..) {
259- stats.push((header::WARNING, format!("199 - \"{notice}\"")));
260- }
261- if run.eq > 70 && run.ended.is_none() {
262- stats.push((header::WARNING, "199 - \"it is turning purple\"".into()));
170+ for warning in &gauges.warnings {
171+ stats.push((header::WARNING, format!("199 - \"{warning}\"")));
263172 }
264- stats.push((
265- header::SET_COOKIE,
266- format!(
267- "shame={}; {COOKIE_ATTRIBUTES}",
268- secret.sign("shame", &shame.to_string())
269- ),
270- ));
271- if run.items.ruler {
272- stats.push((
273- header::SET_COOKIE,
274- format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")),
275- ));
173+ stats
174+}
175+
176+pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
177+ let status = response.status();
178+ if status == StatusCode::NOT_MODIFIED {
179+ return response;
276180 }
181+ let (shame, shame_cookie) = shame::settle(run, ctx, secret);
182+ let gauges = run.gauges();
183+ let link = links::header(run);
277184 let headers = response.headers_mut();
278- for (name, value) in stats {
185+ for (name, value) in stats(&gauges, shame) {
279186 if let Ok(value) = HeaderValue::from_str(&value) {
280187 headers.append(name, value);
281188 }
282189 }
190+ if let Ok(link) = HeaderValue::from_str(&link) {
191+ headers.append(header::LINK, link);
192+ }
193+ headers.append(header::SET_COOKIE, shame_cookie);
194+ if gauges.ruler {
195+ headers.append(header::SET_COOKIE, shame::ruler_cookie(secret));
196+ }
283197 let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
284- if run.items.ring && !bodiless {
198+ if gauges.ring && !bodiless {
285199 response.extensions_mut().insert(Compress);
286200 }
287201 response
@@ -293,7 +207,7 @@ fn greet(response: Response, ctx: &mut Ctx) -> Response {
293207 }
294208 let (Some(Greeting(text)), Some(upgrade)) = (
295209 response.extensions().get::<Greeting>().cloned(),
296- ctx.upgrade.take(),
210+ ctx.take_upgrade(),
297211 ) else {
298212 return response;
299213 };
Modified file: src/http/runs.rs+37 additions−286 deletions
@@ -1,10 +1,7 @@
11 //! The resources under `/runs/{id}`.
22
3-use std::{convert::Infallible, fmt::Write as _};
4-
53 use axum::{
64 Json,
7- body::{Body, Bytes},
85 http::{StatusCode, header},
96 response::{IntoResponse, Response},
107 };
@@ -12,15 +9,17 @@ use serde_json::{Value, json};
129
1310 use super::{
1411 ctx::Ctx,
12+ inventory::{delete_item, get_inventory, get_item},
13+ parse::{parse_intensity, parse_techniques},
1514 resource::{Resource, Verb},
1615 response::respond,
1716 run::{Greeting, RunResource, Scope},
17+ shaft::{get_foreskin, get_length},
1818 };
1919 use crate::{
2020 game::{
21- Cause, Floor, Grip, Item, LUBRICANTS, Lube, PatchOp, Run, Technique,
22- balance::{EQ_BAND, MM_PER_INCH},
23- shaft,
21+ Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, PatchOp, Run, Technique,
22+ balance::{EQ_BAND, VIEWS_PER_SECOND},
2423 },
2524 headers::ACCEPT_PATCH,
2625 problem::Problem,
@@ -44,32 +43,20 @@ fn not_in_clinic(run: &Run) -> bool {
4443 run.floor != Floor::Clinic
4544 }
4645
47-fn never(_: &Run) -> bool {
48- false
49-}
50-
51-fn unreachable_handler(_: &mut Run, _: &Ctx) -> Response {
52- StatusCode::METHOD_NOT_ALLOWED.into_response()
53-}
54-
5546 pub static RUN: RunResource = RunResource {
5647 scope: Scope::Home,
57- resource: Resource {
58- verbs: &[
59- Verb::new("GET", get_run),
60- Verb::new("JELQ", jelq)
61- .only_when(not_in_clinic, "JELQ is not performed in the clinic."),
62- Verb::new("HOLD", hold_run).only_when(
63- Run::can_hold,
64- "HOLD is only available above 70 EQ or at 2 stamina or less.",
65- ),
66- Verb::new("PUT", put_run),
67- Verb::new("PATCH", patch_run),
68- Verb::new("DELETE", delete_run),
69- Verb::new("TRACE", trace_run),
70- ],
71- accept_patch: true,
72- },
48+ resource: Resource::patchable(&[
49+ Verb::new("GET", get_run),
50+ Verb::new("JELQ", jelq).only_when(not_in_clinic, "JELQ is not performed in the clinic."),
51+ Verb::new("HOLD", hold_run).only_when(
52+ Run::can_hold,
53+ "HOLD is only available above 70 EQ or at 2 stamina or less.",
54+ ),
55+ Verb::new("PUT", put_run),
56+ Verb::new("PATCH", patch_run),
57+ Verb::new("DELETE", delete_run),
58+ Verb::new("TRACE", trace_run),
59+ ]),
7360 };
7461 pub static LENGTH: RunResource = home(&[Verb::new("GET", get_length)]);
7562 pub static FORESKIN: RunResource = home(&[
@@ -98,8 +85,8 @@ pub static GRIP: RunResource = home(&[Verb::new("GET", get_grip)]);
9885 pub static DIAGNOSIS: RunResource = pockets(&[Verb::new("GET", get_diagnosis)]);
9986 pub static FOOTAGE: RunResource = pockets(&[
10087 Verb::new("GET", get_footage),
101- Verb::new("DELETE", unreachable_handler).only_when(
102- never,
88+ Verb::refused(
89+ "DELETE",
10390 "The footage has been cached by intermediaries. It cannot be deleted.",
10491 ),
10592 ]);
@@ -124,48 +111,6 @@ fn get_run(run: &mut Run, ctx: &Ctx) -> Response {
124111 .into_response()
125112 }
126113
127-fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {
128- let required = || {
129- Problem::new(
130- StatusCode::BAD_REQUEST,
131- "technique-required",
132- "No technique was specified.",
133- )
134- .with("techniques", run.path("/techniques"))
135- };
136- let names = ctx
137- .query("technique")
138- .filter(|n| !n.trim().is_empty())
139- .ok_or_else(required)?;
140- names
141- .split(',')
142- .map(str::trim)
143- .filter(|n| !n.is_empty())
144- .map(|name| {
145- Technique::parse(name).ok_or_else(|| {
146- Problem::new(
147- StatusCode::BAD_REQUEST,
148- "unknown-technique",
149- format!("{name} is not a technique."),
150- )
151- .with("techniques", run.path("/techniques"))
152- })
153- })
154- .collect()
155-}
156-
157-fn parse_intensity(ctx: &Ctx) -> Result<i32, Problem> {
158- match ctx.query("intensity").map(str::parse::<i32>) {
159- None => Ok(5),
160- Some(Ok(i)) if (1..=10).contains(&i) => Ok(i),
161- Some(_) => Err(Problem::new(
162- StatusCode::BAD_REQUEST,
163- "intensity-out-of-range",
164- "Intensity is measured from 1 to 10.",
165- )),
166- }
167-}
168-
169114 fn jelq(run: &mut Run, ctx: &Ctx) -> Response {
170115 let parsed = parse_techniques(run, ctx).and_then(|techniques| {
171116 let intensity = parse_intensity(ctx)?;
@@ -199,7 +144,7 @@ fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {
199144 .header(ACCEPT_PATCH, "application/json-patch+json")
200145 .into_response();
201146 }
202- match serde_json::from_slice::<Vec<PatchOp>>(&ctx.body) {
147+ match serde_json::from_slice::<Vec<PatchOp>>(ctx.body()) {
203148 Ok(ops) => respond(run.patch(&ops)),
204149 Err(_) => Problem::new(
205150 StatusCode::BAD_REQUEST,
@@ -216,160 +161,11 @@ fn delete_run(run: &mut Run, _: &Ctx) -> Response {
216161 }
217162
218163 fn trace_run(run: &mut Run, ctx: &Ctx) -> Response {
219- run.shame += 1;
220- let mut message = format!("{} {} {:?}\r\n", ctx.method, ctx.uri, ctx.version);
221- for (name, value) in &ctx.headers {
222- let _ = write!(
223- message,
224- "{name}: {}\r\n",
225- value.to_str().unwrap_or("<binary>")
226- );
227- }
228- message.push_str("\r\n");
164+ run.trace();
229165 (
230166 StatusCode::OK,
231167 [(header::CONTENT_TYPE, "message/http")],
232- message,
233- )
234- .into_response()
235-}
236-
237-fn millimetres(inches: f64) -> usize {
238- (inches * MM_PER_INCH).round() as usize
239-}
240-
241-enum InchRange {
242- Satisfiable(f64, f64),
243- Unsatisfiable,
244-}
245-
246-/// `None` means the header is not a valid range, which RFC 9110 §14.2 says to ignore rather than refuse.
247-fn parse_inch_range(range: &str, total: f64) -> Option<InchRange> {
248- let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
249- let number = |text: &str| {
250- text.parse::<f64>()
251- .ok()
252- .filter(|n| n.is_finite() && *n >= 0.0)
253- };
254- let (start, end) = match (start.trim(), end.trim()) {
255- ("", "") => return None,
256- ("", suffix) => ((total - number(suffix)?).max(0.0), total),
257- (start, "") => (number(start)?, total),
258- (start, end) => {
259- let (start, end) = (number(start)?, number(end)?);
260- if start > end {
261- return None;
262- }
263- (start, end)
264- }
265- };
266- Some(if start < total {
267- InchRange::Satisfiable(start, end.min(total))
268- } else {
269- InchRange::Unsatisfiable
270- })
271-}
272-
273-fn unsatisfied_range(inches: f64) -> Response {
274- Problem::new(
275- StatusCode::RANGE_NOT_SATISFIABLE,
276- "range-not-satisfiable",
277- format!("The requested range is not available. {inches:.2} inches are."),
278- )
279- .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))
280- .into_response()
281-}
282-
283-fn partial_inches(body: &str, offset: usize, start: f64, end: f64, total: f64) -> Response {
284- let from = (millimetres(start) + offset).min(body.len() - 1);
285- let to = (millimetres(end) + offset).clamp(from + 1, body.len());
286- (
287- StatusCode::PARTIAL_CONTENT,
288- [
289- (
290- header::CONTENT_TYPE,
291- "text/plain; charset=us-ascii".to_owned(),
292- ),
293- (header::ACCEPT_RANGES, "inches".to_owned()),
294- (
295- header::CONTENT_RANGE,
296- format!("inches {start:.2}-{end:.2}/{total:.2}"),
297- ),
298- ],
299- body[from..to].to_owned(),
300- )
301- .into_response()
302-}
303-
304-fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
305- let inches = if run.items.ruler {
306- run.current_length()
307- } else {
308- run.self_reported()
309- };
310- let body = shaft(millimetres(inches));
311- let content_type = (
312- header::CONTENT_TYPE,
313- "text/plain; charset=us-ascii".to_owned(),
314- );
315- let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
316-
317- if let Some(range) = ctx
318- .header(header::RANGE)
319- .and_then(|range| parse_inch_range(range, inches))
320- {
321- match range {
322- InchRange::Satisfiable(start, end) => {
323- return partial_inches(&body, 0, start, end, inches);
324- }
325- InchRange::Unsatisfiable => return unsatisfied_range(inches),
326- }
327- }
328-
329- if run.items.ruler {
330- return (StatusCode::OK, [content_type, accept_ranges], body).into_response();
331- }
332- let chunks: Vec<Result<Bytes, Infallible>> = body
333- .into_bytes()
334- .chunks(16)
335- .map(|c| Ok(Bytes::copy_from_slice(c)))
336- .collect();
337- let body = Body::from_stream(futures_util::stream::iter(chunks));
338- (StatusCode::OK, [content_type, accept_ranges], body).into_response()
339-}
340-
341-fn get_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
342- let Some(inches) = run.foreskin else {
343- return Problem::new(
344- StatusCode::GONE,
345- "foreskin-gone",
346- "The foreskin is gone. It will not be returning.",
347- )
348- .into_response();
349- };
350- let body = format!("8({})D", "~".repeat(millimetres(inches)));
351- if let Some(range) = ctx
352- .header(header::RANGE)
353- .and_then(|range| parse_inch_range(range, inches))
354- {
355- match range {
356- InchRange::Satisfiable(start, end) => {
357- return partial_inches(&body, 2, start, end, inches);
358- }
359- InchRange::Unsatisfiable => return unsatisfied_range(inches),
360- }
361- }
362- (
363- StatusCode::OK,
364- [
365- (header::ETAG, run.etag()),
366- (
367- header::CONTENT_TYPE,
368- "text/plain; charset=us-ascii".to_owned(),
369- ),
370- (header::ACCEPT_RANGES, "inches".to_owned()),
371- ],
372- body,
168+ ctx.echo(),
373169 )
374170 .into_response()
375171 }
@@ -378,59 +174,10 @@ fn delete_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
378174 respond(run.delete_foreskin(ctx.header(header::IF_MATCH)))
379175 }
380176
381-fn get_inventory(run: &mut Run, _: &Ctx) -> Response {
382- let items: Vec<Value> = run
383- .items
384- .owned()
385- .into_iter()
386- .map(|item| {
387- json!({
388- "name": item.slug(),
389- "href": run.path(&format!("/inventory/{}", item.slug())),
390- })
391- })
392- .collect();
393- Json(json!({
394- "items": items,
395- "ice_packs": run.items.ice_packs,
396- "supplements": run.items.supplements,
397- "excuses": run.excuses(),
398- }))
399- .into_response()
400-}
401-
402-fn owned_item(run: &Run, ctx: &Ctx) -> Result<Item, Problem> {
403- Item::parse(ctx.param("item"))
404- .filter(|&item| run.items.has(item))
405- .ok_or_else(|| {
406- Problem::new(
407- StatusCode::NOT_FOUND,
408- "no-such-item",
409- "You do not have that.",
410- )
411- })
412-}
413-
414-fn get_item(run: &mut Run, ctx: &Ctx) -> Response {
415- match owned_item(run, ctx) {
416- Ok(item) => {
417- Json(json!({ "name": item.slug(), "description": item.description() })).into_response()
418- }
419- Err(problem) => problem.into_response(),
420- }
421-}
422-
423-fn delete_item(run: &mut Run, ctx: &Ctx) -> Response {
424- match owned_item(run, ctx) {
425- Ok(item) => respond(run.drop_item(item)),
426- Err(problem) => problem.into_response(),
427- }
428-}
429-
430177 fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
431178 let listed: Vec<Value> = Technique::ALL
432179 .into_iter()
433- .map(|t| t.describe(run.uli_taught))
180+ .map(|t| t.describe(run.knows_uli()))
434181 .collect();
435182 Json(json!({
436183 "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
@@ -447,7 +194,15 @@ fn search_drawer(run: &mut Run, _: &Ctx) -> Response {
447194 }
448195
449196 fn choose_branch(run: &mut Run, ctx: &Ctx) -> Response {
450- respond(run.choose(ctx.param("choice")))
197+ match ctx.param("choice").parse::<Branch>() {
198+ Ok(choice) => respond(run.choose(choice)),
199+ Err(()) => Problem::new(
200+ StatusCode::NOT_FOUND,
201+ "no-such-choice",
202+ "That was not one of the choices.",
203+ )
204+ .into_response(),
205+ }
451206 }
452207
453208 fn get_floor(run: &mut Run, _: &Ctx) -> Response {
@@ -459,15 +214,11 @@ fn leave_floor(run: &mut Run, _: &Ctx) -> Response {
459214 }
460215
461216 fn get_shop(run: &mut Run, _: &Ctx) -> Response {
462- let view = run.shop_view();
463- if view.is_ok() {
464- run.here.shopping = true;
465- }
466- respond(view.map(Json))
217+ respond(run.browse_shop().map(Json))
467218 }
468219
469220 fn buy(run: &mut Run, ctx: &Ctx) -> Response {
470- respond(run.buy(ctx.param("item")))
221+ respond(run.buy(Item::parse(ctx.param("item"))))
471222 }
472223
473224 fn raid_kitchen(run: &mut Run, _: &Ctx) -> Response {
@@ -508,7 +259,7 @@ fn requested_grip(ctx: &Ctx) -> Option<Grip> {
508259 }
509260 ctx.header(header::UPGRADE)?
510261 .split(',')
511- .find_map(|protocol| Grip::parse(protocol.trim()))
262+ .find_map(|protocol| protocol.trim().parse::<Grip>().ok())
512263 }
513264
514265 fn get_grip(run: &mut Run, ctx: &Ctx) -> Response {
@@ -539,7 +290,7 @@ fn get_grip(run: &mut Run, ctx: &Ctx) -> Response {
539290 (
540291 StatusCode::OK,
541292 [(header::UPGRADE, "overhand, underhand")],
542- Json(json!({ "grip": run.grip.name(), "detail": detail })),
293+ Json(json!({ "grip": run.grip().name(), "detail": detail })),
543294 )
544295 .into_response()
545296 }
@@ -571,7 +322,7 @@ fn get_footage(run: &mut Run, _: &Ctx) -> Response {
571322 ],
572323 Json(json!({
573324 "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",
574- "views": 1 + age * 37,
325+ "views": 1 + age * VIEWS_PER_SECOND,
575326 })),
576327 )
577328 .into_response()
Added file: src/http/shaft.rs+157 additions
@@ -0,0 +1,157 @@
1+use std::convert::Infallible;
2+
3+use axum::{
4+ body::{Body, Bytes},
5+ http::{StatusCode, header},
6+ response::{IntoResponse, Response},
7+};
8+
9+use super::ctx::Ctx;
10+use crate::{
11+ game::{Run, balance::MM_PER_INCH, shaft},
12+ problem::Problem,
13+};
14+
15+const SHAFT_CHUNK: usize = 16;
16+
17+pub(super) fn millimetres(inches: f64) -> usize {
18+ (inches * MM_PER_INCH).round() as usize
19+}
20+
21+pub(super) enum InchRange {
22+ Satisfiable(f64, f64),
23+ Unsatisfiable,
24+}
25+
26+/// `None` means the header is not a valid range, which RFC 9110 §14.2 says to ignore rather than refuse.
27+pub(super) fn parse_inch_range(range: &str, total: f64) -> Option<InchRange> {
28+ let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
29+ let number = |text: &str| {
30+ text.parse::<f64>()
31+ .ok()
32+ .filter(|n| n.is_finite() && *n >= 0.0)
33+ };
34+ let (start, end) = match (start.trim(), end.trim()) {
35+ ("", "") => return None,
36+ ("", suffix) => ((total - number(suffix)?).max(0.0), total),
37+ (start, "") => (number(start)?, total),
38+ (start, end) => {
39+ let (start, end) = (number(start)?, number(end)?);
40+ if start > end {
41+ return None;
42+ }
43+ (start, end)
44+ }
45+ };
46+ Some(if start < total {
47+ InchRange::Satisfiable(start, end.min(total))
48+ } else {
49+ InchRange::Unsatisfiable
50+ })
51+}
52+
53+pub(super) fn unsatisfied_range(inches: f64) -> Response {
54+ Problem::new(
55+ StatusCode::RANGE_NOT_SATISFIABLE,
56+ "range-not-satisfiable",
57+ format!("The requested range is not available. {inches:.2} inches are."),
58+ )
59+ .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))
60+ .into_response()
61+}
62+
63+pub(super) fn partial_inches(
64+ body: &str,
65+ offset: usize,
66+ start: f64,
67+ end: f64,
68+ total: f64,
69+) -> Response {
70+ let from = (millimetres(start) + offset).min(body.len() - 1);
71+ let to = (millimetres(end) + offset).clamp(from + 1, body.len());
72+ (
73+ StatusCode::PARTIAL_CONTENT,
74+ [
75+ (
76+ header::CONTENT_TYPE,
77+ "text/plain; charset=us-ascii".to_owned(),
78+ ),
79+ (header::ACCEPT_RANGES, "inches".to_owned()),
80+ (
81+ header::CONTENT_RANGE,
82+ format!("inches {start:.2}-{end:.2}/{total:.2}"),
83+ ),
84+ ],
85+ body[from..to].to_owned(),
86+ )
87+ .into_response()
88+}
89+
90+pub(super) fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
91+ let inches = run.shown_length();
92+ let body = shaft(millimetres(inches));
93+ let content_type = (
94+ header::CONTENT_TYPE,
95+ "text/plain; charset=us-ascii".to_owned(),
96+ );
97+ let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
98+
99+ if let Some(range) = ctx
100+ .header(header::RANGE)
101+ .and_then(|range| parse_inch_range(range, inches))
102+ {
103+ match range {
104+ InchRange::Satisfiable(start, end) => {
105+ return partial_inches(&body, 0, start, end, inches);
106+ }
107+ InchRange::Unsatisfiable => return unsatisfied_range(inches),
108+ }
109+ }
110+
111+ if run.measured() {
112+ return (StatusCode::OK, [content_type, accept_ranges], body).into_response();
113+ }
114+ let chunks: Vec<Result<Bytes, Infallible>> = body
115+ .into_bytes()
116+ .chunks(SHAFT_CHUNK)
117+ .map(|c| Ok(Bytes::copy_from_slice(c)))
118+ .collect();
119+ let body = Body::from_stream(futures_util::stream::iter(chunks));
120+ (StatusCode::OK, [content_type, accept_ranges], body).into_response()
121+}
122+
123+pub(super) fn get_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
124+ let Some(inches) = run.foreskin() else {
125+ return Problem::new(
126+ StatusCode::GONE,
127+ "foreskin-gone",
128+ "The foreskin is gone. It will not be returning.",
129+ )
130+ .into_response();
131+ };
132+ let body = format!("8({})D", "~".repeat(millimetres(inches)));
133+ if let Some(range) = ctx
134+ .header(header::RANGE)
135+ .and_then(|range| parse_inch_range(range, inches))
136+ {
137+ match range {
138+ InchRange::Satisfiable(start, end) => {
139+ return partial_inches(&body, 2, start, end, inches);
140+ }
141+ InchRange::Unsatisfiable => return unsatisfied_range(inches),
142+ }
143+ }
144+ (
145+ StatusCode::OK,
146+ [
147+ (header::ETAG, run.etag()),
148+ (
149+ header::CONTENT_TYPE,
150+ "text/plain; charset=us-ascii".to_owned(),
151+ ),
152+ (header::ACCEPT_RANGES, "inches".to_owned()),
153+ ],
154+ body,
155+ )
156+ .into_response()
157+}
Added file: src/http/shame.rs+30 additions
@@ -0,0 +1,30 @@
1+use axum::http::HeaderValue;
2+
3+use super::ctx::Ctx;
4+use crate::{game::Run, secret::Secret};
5+
6+pub const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
7+
8+pub fn settle(run: &mut Run, ctx: &Ctx, secret: &Secret) -> (u32, HeaderValue) {
9+ let presented = ctx
10+ .cookie("shame")
11+ .and_then(|signed| secret.verify("shame", signed))
12+ .and_then(|total| total.parse::<u32>().ok());
13+ let total = run.settle_shame(presented);
14+ let cookie = format!(
15+ "shame={}; {COOKIE_ATTRIBUTES}",
16+ secret.sign("shame", &total.to_string())
17+ );
18+ (
19+ total,
20+ HeaderValue::from_str(&cookie).expect("a signed number is a valid header value"),
21+ )
22+}
23+
24+pub fn ruler_cookie(secret: &Secret) -> HeaderValue {
25+ HeaderValue::from_str(&format!(
26+ "ruler={}; {COOKIE_ATTRIBUTES}",
27+ secret.sign("ruler", "1")
28+ ))
29+ .expect("a signed constant is a valid header value")
30+}
Modified file: src/http/ui.rs+4 additions−1 deletions
@@ -12,7 +12,7 @@ use serde::Deserialize;
1212 use serde_json::json;
1313 use tower::ServiceExt;
1414
15-use super::resource::unsupported;
15+use super::{ctx::BODY_LIMIT, resource::unsupported};
1616 use crate::problem::{Problem, reason};
1717
1818 const RESPONSE_LIMIT: usize = 1 << 20;
@@ -54,6 +54,9 @@ fn decode(headers: &HeaderMap, bytes: Bytes) -> Vec<u8> {
5454 // Browsers hide 3xx responses from fetch and refuse TRACE, Upgrade, and Expect, so the page
5555 // replays requests in-process to show the player exactly what the API said.
5656 pub async fn send(State(api): State<Router>, headers: HeaderMap, body: Bytes) -> Response {
57+ if body.len() > BODY_LIMIT {
58+ return bad_envelope("The envelope is larger than any request the server would take.");
59+ }
5760 let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
5861 return bad_envelope("The envelope could not be parsed.");
5962 };
Modified file: src/lib.rs+1 additions−1 deletions
@@ -11,4 +11,4 @@ mod secret;
1111 mod tests;
1212
1313 pub use config::Config;
14-pub use http::{AppState, app, router};
14+pub use http::{AppState, app, router, sweep_forever};
Modified file: src/main.rs+10 additions−4 deletions
@@ -1,7 +1,13 @@
1+use std::sync::Arc;
2+
13 #[tokio::main]
2-async fn main() -> std::io::Result<()> {
4+async fn main() -> Result<(), Box<dyn std::error::Error>> {
35 let config = jelqd::Config::from_env();
4- let listener = tokio::net::TcpListener::bind(&config.addr).await?;
5- eprintln!("jelqd listening on http://{}", config.addr);
6- axum::serve(listener, jelqd::app(config)).await
6+ let addr = config.addr.clone();
7+ let state = Arc::new(jelqd::AppState::new(config)?);
8+ tokio::spawn(jelqd::sweep_forever(Arc::clone(&state)));
9+ let listener = tokio::net::TcpListener::bind(&addr).await?;
10+ eprintln!("jelqd listening on http://{addr}");
11+ axum::serve(listener, jelqd::router(state)).await?;
12+ Ok(())
713 }
Modified file: src/records.rs+144 additions−88 deletions
@@ -8,34 +8,37 @@ use std::{
88
99 use rusqlite::{Connection, OptionalExtension, Row, params};
1010
11-use crate::game::Run;
12-
13-const SCHEMA: &str = "
14-CREATE TABLE IF NOT EXISTS runs (
15- id TEXT PRIMARY KEY,
16- public_id TEXT NOT NULL,
17- seeded INTEGER NOT NULL,
18- status TEXT NOT NULL,
19- cause TEXT,
20- detail TEXT,
21- turn INTEGER NOT NULL,
22- floor INTEGER NOT NULL,
23- length REAL NOT NULL,
24- self_reported REAL NOT NULL,
25- girth REAL NOT NULL,
26- viral INTEGER NOT NULL,
27- recorded_at INTEGER NOT NULL
28-);
29-CREATE INDEX IF NOT EXISTS runs_by_cause ON runs (cause);
30-";
31-
32-/// What the graveyard and the leaderboard need to know about a run, live or long gone.
11+use crate::game::{Cause, Run, RunStatus};
12+
13+/// Each entry moves the schema up one `user_version`. Never edit one that has shipped.
14+const MIGRATIONS: [&str; 2] = [
15+ "CREATE TABLE IF NOT EXISTS runs (
16+ id TEXT PRIMARY KEY,
17+ public_id TEXT NOT NULL,
18+ seeded INTEGER NOT NULL,
19+ status TEXT NOT NULL,
20+ cause TEXT,
21+ detail TEXT,
22+ turn INTEGER NOT NULL,
23+ floor INTEGER NOT NULL,
24+ length REAL NOT NULL,
25+ self_reported REAL NOT NULL,
26+ girth REAL NOT NULL,
27+ viral INTEGER NOT NULL,
28+ recorded_at INTEGER NOT NULL
29+ );
30+ CREATE INDEX IF NOT EXISTS runs_by_cause ON runs (cause);",
31+ "ALTER TABLE runs ADD COLUMN ice_unused INTEGER NOT NULL DEFAULT 0;
32+ ALTER TABLE runs DROP COLUMN detail;
33+ CREATE INDEX runs_by_recorded ON runs (recorded_at);",
34+];
35+
3336 pub struct Record {
3437 pub id: String,
3538 pub public_id: String,
3639 pub seeded: bool,
37- pub status: String,
38- pub ending: Option<Ending>,
40+ pub status: RunStatus,
41+ pub cause: Option<Cause>,
3942 pub turn: u32,
4043 pub floor: u8,
4144 pub length: f64,
@@ -44,42 +47,44 @@ pub struct Record {
4447 pub viral: bool,
4548 }
4649
47-pub struct Ending {
48- pub cause: String,
49- pub detail: String,
50-}
51-
5250 impl Record {
5351 pub fn of(run: &Run) -> Self {
5452 Self {
55- id: run.id.clone(),
56- public_id: run.public_id.clone(),
57- seeded: run.seeded,
58- status: run.status().name().to_owned(),
59- ending: run.ended.as_ref().map(|ending| Ending {
60- cause: ending.cause.slug().to_owned(),
61- detail: ending.cause.detail().to_owned(),
62- }),
53+ id: run.ident.id.clone(),
54+ public_id: run.ident.public_id.clone(),
55+ seeded: run.ident.seeded,
56+ status: run.status(),
57+ cause: run.ended.as_ref().map(|ending| ending.cause),
6358 turn: run.ended.as_ref().map_or(run.turn, |ending| ending.turn),
6459 floor: run.floor.number(),
6560 length: run.current_length(),
6661 self_reported: run.self_reported(),
67- girth: run.girth,
68- viral: run.filmed_at.is_some(),
62+ girth: run.body.girth,
63+ viral: run.online.filmed_at.is_some(),
6964 }
7065 }
7166
7267 fn from_row(row: &Row) -> rusqlite::Result<Self> {
73- let cause: Option<String> = row.get("cause")?;
74- let detail: Option<String> = row.get("detail")?;
68+ let unknown = |column: &str, value: String| {
69+ rusqlite::Error::FromSqlConversionFailure(
70+ 0,
71+ rusqlite::types::Type::Text,
72+ format!("unknown {column} {value:?}").into(),
73+ )
74+ };
75+ let status: String = row.get("status")?;
76+ let cause = row
77+ .get::<_, Option<String>>("cause")?
78+ .map(|slug| {
79+ Cause::parse(&slug, row.get("ice_unused")?).ok_or_else(|| unknown("cause", slug))
80+ })
81+ .transpose()?;
7582 Ok(Self {
7683 id: row.get("id")?,
7784 public_id: row.get("public_id")?,
7885 seeded: row.get("seeded")?,
79- status: row.get("status")?,
80- ending: cause
81- .zip(detail)
82- .map(|(cause, detail)| Ending { cause, detail }),
86+ status: RunStatus::parse(&status).ok_or_else(|| unknown("status", status))?,
87+ cause,
8388 turn: row.get("turn")?,
8489 floor: row.get("floor")?,
8590 length: row.get("length")?,
@@ -90,6 +95,24 @@ impl Record {
9095 }
9196 }
9297
98+#[derive(Clone, Copy, PartialEq, Eq)]
99+pub enum Ranking {
100+ SelfReported,
101+ ContentLength,
102+ /// Self-reported length against real girth, the only metric the forum respects.
103+ Volume,
104+}
105+
106+impl Ranking {
107+ fn order(self) -> &'static str {
108+ match self {
109+ Self::SelfReported => "self_reported",
110+ Self::ContentLength => "length",
111+ Self::Volume => "girth * girth * self_reported",
112+ }
113+ }
114+}
115+
93116 pub struct Records {
94117 db: Mutex<Connection>,
95118 }
@@ -97,19 +120,31 @@ pub struct Records {
97120 impl Records {
98121 /// WAL lets the leaderboard read while a run is being buried.
99122 pub fn open(path: &Path) -> rusqlite::Result<Self> {
123+ if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
124+ std::fs::create_dir_all(dir)
125+ .map_err(|_| rusqlite::Error::InvalidPath(path.to_owned()))?;
126+ }
100127 let db = Connection::open(path)?;
101128 db.pragma_update(None, "journal_mode", "WAL")?;
102129 db.pragma_update(None, "synchronous", "NORMAL")?;
103- Self::with(db)
130+ Self::migrated(db)
104131 }
105132
106133 pub fn in_memory() -> Self {
107- Self::with(Connection::open_in_memory().expect("an in-memory database always opens"))
108- .expect("the schema applies to an empty database")
134+ Connection::open_in_memory()
135+ .and_then(Self::migrated)
136+ .expect("an empty in-memory database always takes the schema")
109137 }
110138
111- fn with(db: Connection) -> rusqlite::Result<Self> {
112- db.execute_batch(SCHEMA)?;
139+ fn migrated(mut db: Connection) -> rusqlite::Result<Self> {
140+ let version: i64 = db.pragma_query_value(None, "user_version", |row| row.get(0))?;
141+ let done = usize::try_from(version).unwrap_or(0);
142+ for (step, migration) in (1_i64..).zip(MIGRATIONS).skip(done) {
143+ let tx = db.transaction()?;
144+ tx.execute_batch(migration)?;
145+ tx.pragma_update(None, "user_version", step)?;
146+ tx.commit()?;
147+ }
113148 Ok(Self { db: Mutex::new(db) })
114149 }
115150
@@ -117,60 +152,81 @@ impl Records {
117152 self.db.lock().unwrap_or_else(PoisonError::into_inner)
118153 }
119154
120- /// A record that cannot be written is a 500, the same as any other broken promise to persist.
121- pub fn save(&self, record: &Record) {
122- let recorded_at = SystemTime::now()
155+ /// The first save stamps the record. Later saves update it in place and keep its place in line.
156+ pub fn save(&self, record: &Record) -> rusqlite::Result<()> {
157+ let now = SystemTime::now()
123158 .duration_since(UNIX_EPOCH)
124159 .map_or(0, |since| {
125160 i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
126161 });
127- self.db()
128- .execute(
129- "INSERT OR REPLACE INTO runs (id, public_id, seeded, status, cause, detail, turn,
130- floor, length, self_reported, girth, viral, recorded_at)
131- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
132- params![
133- record.id,
134- record.public_id,
135- record.seeded,
136- record.status,
137- record.ending.as_ref().map(|e| &e.cause),
138- record.ending.as_ref().map(|e| &e.detail),
139- record.turn,
140- record.floor,
141- record.length,
142- record.self_reported,
143- record.girth,
144- record.viral,
145- recorded_at,
146- ],
147- )
148- .unwrap_or_else(|e| panic!("run {} could not be recorded: {e}", record.id));
162+ self.db().execute(
163+ "INSERT INTO runs (id, public_id, seeded, status, cause, ice_unused, turn, floor,
164+ length, self_reported, girth, viral, recorded_at)
165+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
166+ ON CONFLICT (id) DO UPDATE SET
167+ status = excluded.status, cause = excluded.cause,
168+ ice_unused = excluded.ice_unused, turn = excluded.turn, floor = excluded.floor,
169+ length = excluded.length, self_reported = excluded.self_reported,
170+ girth = excluded.girth, viral = excluded.viral",
171+ params![
172+ record.id,
173+ record.public_id,
174+ record.seeded,
175+ record.status.name(),
176+ record.cause.map(Cause::slug),
177+ matches!(record.cause, Some(Cause::Ruptured { ice_unused: true })),
178+ record.turn,
179+ record.floor,
180+ record.length,
181+ record.self_reported,
182+ record.girth,
183+ record.viral,
184+ now,
185+ ],
186+ )?;
187+ Ok(())
149188 }
150189
151- pub fn find(&self, id: &str) -> Option<Record> {
190+ pub fn find(&self, id: &str) -> rusqlite::Result<Option<Record>> {
152191 self.db()
153192 .query_row("SELECT * FROM runs WHERE id = ?1", [id], Record::from_row)
154193 .optional()
155- .unwrap_or_else(|e| panic!("the records could not be read: {e}"))
156194 }
157195
158- pub fn dead(&self) -> Vec<Record> {
159- self.query("SELECT * FROM runs WHERE cause IS NOT NULL ORDER BY recorded_at")
196+ pub fn dead(&self, offset: usize, limit: usize) -> rusqlite::Result<Vec<Record>> {
197+ self.query(
198+ "SELECT * FROM runs WHERE cause IS NOT NULL
199+ ORDER BY recorded_at DESC, id LIMIT ?1 OFFSET ?2",
200+ offset,
201+ limit,
202+ )
160203 }
161204
162- pub fn all(&self) -> Vec<Record> {
163- self.query("SELECT * FROM runs")
205+ pub fn top(
206+ &self,
207+ ranking: Ranking,
208+ seeded: bool,
209+ offset: usize,
210+ limit: usize,
211+ ) -> rusqlite::Result<Vec<Record>> {
212+ let filter = if seeded { "" } else { "WHERE seeded = 0" };
213+ self.query(
214+ &format!(
215+ "SELECT * FROM runs {filter} ORDER BY {} DESC, id LIMIT ?1 OFFSET ?2",
216+ ranking.order()
217+ ),
218+ offset,
219+ limit,
220+ )
164221 }
165222
166- fn query(&self, sql: &str) -> Vec<Record> {
223+ fn query(&self, sql: &str, offset: usize, limit: usize) -> rusqlite::Result<Vec<Record>> {
167224 let db = self.db();
168- let mut statement = db
169- .prepare_cached(sql)
170- .unwrap_or_else(|e| panic!("the records could not be read: {e}"));
225+ let mut statement = db.prepare_cached(sql)?;
226+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
227+ let offset = i64::try_from(offset).unwrap_or(i64::MAX);
171228 statement
172- .query_map([], Record::from_row)
173- .and_then(Iterator::collect)
174- .unwrap_or_else(|e| panic!("the records could not be read: {e}"))
229+ .query_map(params![limit, offset], Record::from_row)?
230+ .collect()
175231 }
176232 }
Deleted file: src/tests.rs−2117 deletions
@@ -1,2117 +0,0 @@
1-use std::{sync::Arc, time::Duration};
2-
3-use axum::{
4- Router,
5- body::{Body, to_bytes},
6- http::{Method, Request, StatusCode, Version, header},
7- response::Response,
8-};
9-use serde_json::Value;
10-use tower::ServiceExt;
11-
12-use crate::{
13- AppState, Config,
14- game::{
15- Floor, MumVisit, Run, Story,
16- balance::{BRANCH_TURN, SUSPICION_LIMIT},
17- },
18- router,
19-};
20-
21-async fn send(
22- app: &Router,
23- method: &str,
24- uri: &str,
25- headers: &[(&str, &str)],
26- body: &str,
27-) -> Response {
28- let mut request = Request::builder()
29- .method(Method::from_bytes(method.as_bytes()).unwrap())
30- .uri(uri);
31- for (name, value) in headers {
32- request = request.header(*name, *value);
33- }
34- app.clone()
35- .oneshot(request.body(Body::from(body.to_owned())).unwrap())
36- .await
37- .unwrap()
38-}
39-
40-async fn json(response: Response) -> Value {
41- serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
42-}
43-
44-async fn text(response: Response) -> String {
45- String::from_utf8(
46- to_bytes(response.into_body(), usize::MAX)
47- .await
48- .unwrap()
49- .to_vec(),
50- )
51- .unwrap()
52-}
53-
54-fn header<'a>(response: &'a Response, name: &str) -> &'a str {
55- response.headers()[name].to_str().unwrap()
56-}
57-
58-fn fixture() -> Run {
59- Run::new("cafebabe".into(), "cafe".into(), 69)
60-}
61-
62-fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
63- let state = Arc::new(AppState::default());
64- let mut run = fixture();
65- run.floor = floor;
66- setup(&mut run);
67- state.insert(run);
68- router(state)
69-}
70-
71-fn seeded(floor: Floor) -> (Router, String) {
72- let state = Arc::new(AppState::default());
73- let mut run = fixture();
74- run.floor = floor;
75- let etag = run.origin_etag();
76- state.insert(run);
77- (router(state), etag)
78-}
79-
80-fn app() -> Router {
81- crate::app(Config::default())
82-}
83-
84-fn mum_at_the_door(run: &mut Run) {
85- run.mum = Some(MumVisit {
86- errand: "She heard a noise.",
87- nonce: "abc".into(),
88- strikes: 0,
89- knocked: false,
90- });
91-}
92-
93-#[tokio::test]
94-async fn creating_a_run_returns_location_and_etag() {
95- let response = send(&app(), "POST", "/runs?seed=1", &[], "").await;
96- assert_eq!(response.status(), StatusCode::CREATED);
97- assert!(header(&response, "location").starts_with("/runs/"));
98- assert!(response.headers().contains_key(header::ETAG));
99- assert_eq!(header(&response, "server"), "jelqd/0.6.9");
100-}
101-
102-#[tokio::test]
103-async fn bodies_do_not_repeat_the_headers() {
104- let (app, _) = seeded(Floor::Bedroom);
105- let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
106- assert!(response.headers().contains_key("x-eq"));
107- let body = json(response).await;
108- for stat in [
109- "eq",
110- "girth",
111- "foreskin",
112- "stamina",
113- "cash",
114- "self_reported_length",
115- ] {
116- assert!(body.get(stat).is_none(), "{stat} is already a header");
117- }
118-}
119-
120-#[tokio::test]
121-async fn jelqing_cold_fails_the_precondition() {
122- let (app, _) = seeded(Floor::Bedroom);
123- let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
124- assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
125- assert_eq!(
126- header(&response, "content-type"),
127- "application/problem+json"
128- );
129-}
130-
131-#[tokio::test]
132-async fn wet_without_lube_is_unsupported_media() {
133- let (app, _) = seeded(Floor::Bedroom);
134- send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
135- let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
136- assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
137- let images = send(
138- &app,
139- "JELQ",
140- "/runs/cafebabe?technique=wet",
141- &[("content-type", "image/jpeg")],
142- "",
143- )
144- .await;
145- assert_eq!(json(images).await["detail"], "No.");
146-}
147-
148-#[tokio::test]
149-async fn unknown_methods_are_not_implemented() {
150- let (app, _) = seeded(Floor::Bedroom);
151- let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
152- assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
153- let teapot = send(&app, "BREW", "/runs/cafebabe", &[], "").await;
154- assert_eq!(teapot.status(), StatusCode::IM_A_TEAPOT);
155-}
156-
157-#[tokio::test]
158-async fn the_clinic_does_not_advertise_jelq() {
159- let (app, _) = seeded(Floor::Clinic);
160- let options = send(&app, "OPTIONS", "/runs/cafebabe", &[], "").await;
161- assert!(!header(&options, "allow").contains("JELQ"));
162- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
163- assert_eq!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
164- assert!(!header(&jelq, "allow").contains("JELQ"));
165- let (bedroom, _) = seeded(Floor::Bedroom);
166- let options = send(&bedroom, "OPTIONS", "/runs/cafebabe", &[], "").await;
167- assert!(header(&options, "allow").contains("JELQ"));
168-}
169-
170-#[tokio::test]
171-async fn patch_only_accepts_the_truth() {
172- let (app, _) = seeded(Floor::Bedroom);
173- let patch = [("content-type", "application/json-patch+json")];
174- let replace = send(
175- &app,
176- "PATCH",
177- "/runs/cafebabe",
178- &patch,
179- r#"[{"op":"replace","path":"/length","value":12}]"#,
180- )
181- .await;
182- assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
183- let lie = send(
184- &app,
185- "PATCH",
186- "/runs/cafebabe",
187- &patch,
188- r#"[{"op":"test","path":"/length","value":40}]"#,
189- )
190- .await;
191- assert_eq!(
192- json(lie).await["type"],
193- "https://jelq.dev/problems/exaggeration"
194- );
195-}
196-
197-#[tokio::test]
198-async fn deleting_the_foreskin_requires_certainty() {
199- let (app, _) = seeded(Floor::Bedroom);
200- let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
201- assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
202- let look = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
203- assert_eq!(look.status(), StatusCode::OK);
204- let etag = header(&look, "etag").to_owned();
205- assert!(text(look).await.contains('~'));
206- let response = send(
207- &app,
208- "DELETE",
209- "/runs/cafebabe/foreskin",
210- &[("if-match", &etag)],
211- "",
212- )
213- .await;
214- assert_eq!(response.status(), StatusCode::NO_CONTENT);
215- let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
216- assert_eq!(response.status(), StatusCode::GONE);
217-}
218-
219-#[tokio::test]
220-async fn length_is_chunked_until_the_ruler() {
221- let (app, _) = seeded(Floor::Bedroom);
222- let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
223- assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
224- let app = seeded_with(Floor::Bedroom, |run| run.items.ruler = true);
225- let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
226- let declared: usize = header(&truth, "content-length").parse().unwrap();
227- let body = text(truth).await;
228- assert_eq!(body.len(), declared);
229- assert!(body.starts_with("8=") && body.ends_with('D'));
230-}
231-
232-#[tokio::test]
233-async fn length_supports_inch_ranges() {
234- let (app, _) = seeded(Floor::Bedroom);
235- let response = send(
236- &app,
237- "GET",
238- "/runs/cafebabe/length",
239- &[("range", "inches=0-1")],
240- "",
241- )
242- .await;
243- assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
244- assert!(header(&response, "content-range").starts_with("inches 0.00-1.00/"));
245- assert_eq!(text(response).await.len(), 25);
246- let absurd = send(
247- &app,
248- "GET",
249- "/runs/cafebabe/length",
250- &[("range", "inches=40-50")],
251- "",
252- )
253- .await;
254- assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
255- let malformed = send(
256- &app,
257- "GET",
258- "/runs/cafebabe/length",
259- &[("range", "inches=6.17")],
260- "",
261- )
262- .await;
263- assert_eq!(malformed.status(), StatusCode::OK);
264- let suffix = send(
265- &app,
266- "GET",
267- "/runs/cafebabe/length",
268- &[("range", "inches=-1")],
269- "",
270- )
271- .await;
272- assert_eq!(suffix.status(), StatusCode::PARTIAL_CONTENT);
273- assert!(text(suffix).await.ends_with('D'));
274- for (range, status) in [
275- ("inches=1-", StatusCode::PARTIAL_CONTENT),
276- ("inches=40-", StatusCode::RANGE_NOT_SATISFIABLE),
277- ("inches=-0", StatusCode::RANGE_NOT_SATISFIABLE),
278- ("inches=2-1", StatusCode::OK),
279- ("inches=NaN-2", StatusCode::OK),
280- ("inches=0-inf", StatusCode::OK),
281- ] {
282- let response = send(
283- &app,
284- "GET",
285- "/runs/cafebabe/length",
286- &[("range", range)],
287- "",
288- )
289- .await;
290- assert_eq!(response.status(), status, "{range}");
291- }
292-}
293-
294-#[tokio::test]
295-async fn the_clinic_escalates_toward_honesty() {
296- let (app, _) = seeded(Floor::Clinic);
297- let mut hints = Vec::new();
298- for _ in 0..3 {
299- let response = send(
300- &app,
301- "GET",
302- "/runs/cafebabe/diagnosis",
303- &[("authorization", "Excuse \"changing\"")],
304- "",
305- )
306- .await;
307- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
308- hints.push(header(&response, "www-authenticate").to_owned());
309- }
310- assert!(hints[2].contains("Be honest."));
311-}
312-
313-#[tokio::test]
314-async fn the_true_ending_is_not_modified() {
315- let (early, _) = seeded(Floor::Bedroom);
316- let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
317- assert_eq!(response.status(), StatusCode::TOO_EARLY);
318-
319- let (app, etag) = seeded(Floor::Clinic);
320- assert!(
321- !etag.contains("cafebabe"),
322- "the origin ETag must not be derivable from the id"
323- );
324- let response = send(
325- &app,
326- "GET",
327- "/runs/cafebabe/diagnosis",
328- &[
329- ("authorization", "Bearer honesty"),
330- ("if-none-match", &etag),
331- ],
332- "",
333- )
334- .await;
335- assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
336- assert_eq!(header(&response, "x-diagnosis"), "normal");
337-}
338-
339-#[tokio::test]
340-async fn mum_accepts_a_known_excuse() {
341- let app = seeded_with(Floor::Bedroom, mum_at_the_door);
342- let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
343- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
344- assert_eq!(header(&response, "via"), "1.1 mum");
345- let response = send(
346- &app,
347- "GET",
348- "/runs/cafebabe",
349- &[("authorization", "Excuse \"homework\"")],
350- "",
351- )
352- .await;
353- assert_eq!(response.status(), StatusCode::OK);
354-}
355-
356-#[tokio::test]
357-async fn mum_runs_out_of_patience() {
358- let app = seeded_with(Floor::Bedroom, mum_at_the_door);
359- let lie = [("authorization", "Excuse \"it is for science\"")];
360- for _ in 0..2 {
361- let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
362- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
363- }
364- let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
365- assert_eq!(caught.status(), StatusCode::GONE);
366- assert_eq!(
367- json(caught).await["type"],
368- "https://jelq.dev/problems/caught"
369- );
370-}
371-
372-#[tokio::test]
373-async fn mum_remembers_excuses_by_christmas() {
374- let app = seeded_with(Floor::Nans, |run| {
375- mum_at_the_door(run);
376- run.heard.push("homework".into());
377- });
378- let basic = send(
379- &app,
380- "GET",
381- "/runs/cafebabe",
382- &[("authorization", "Excuse \"changing\"")],
383- "",
384- )
385- .await;
386- assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
387- assert!(header(&basic, "www-authenticate").starts_with("Digest"));
388- let replay = send(
389- &app,
390- "GET",
391- "/runs/cafebabe",
392- &[(
393- "authorization",
394- "Digest username=\"homework\", nonce=\"abc\"",
395- )],
396- "",
397- )
398- .await;
399- assert_eq!(
400- json(replay).await["type"],
401- "https://jelq.dev/problems/excuse-replayed"
402- );
403- let fresh = send(
404- &app,
405- "GET",
406- "/runs/cafebabe",
407- &[(
408- "authorization",
409- "Digest username=\"wrapping presents\", nonce=\"abc\"",
410- )],
411- "",
412- )
413- .await;
414- assert_eq!(fresh.status(), StatusCode::OK);
415-}
416-
417-#[tokio::test]
418-async fn a_pulled_muscle_is_a_true_excuse() {
419- let app = seeded_with(Floor::Nans, |run| {
420- mum_at_the_door(run);
421- run.conditions.pulled_muscle = true;
422- run.heard.push("pulled a muscle".into());
423- });
424- let response = send(
425- &app,
426- "GET",
427- "/runs/cafebabe",
428- &[(
429- "authorization",
430- "Digest username=\"pulled a muscle\", nonce=\"abc\"",
431- )],
432- "",
433- )
434- .await;
435- assert_eq!(response.status(), StatusCode::OK);
436- assert!(header(&response, "x-stamina").ends_with("/7"));
437-}
438-
439-#[tokio::test]
440-async fn floors_end_when_they_end() {
441- let app = seeded_with(Floor::Bedroom, |_| {});
442- let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
443- assert_eq!(early.status(), StatusCode::TOO_EARLY);
444- let app = seeded_with(Floor::Bedroom, |run| run.here.turns = 6);
445- let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
446- assert!(
447- next.status() == StatusCode::SEE_OTHER || next.status() == StatusCode::CONFLICT,
448- "either the floor changed or the zip did"
449- );
450-}
451-
452-#[tokio::test]
453-async fn pump_bro_upsells() {
454- let closed = seeded_with(Floor::Bedroom, |_| {});
455- let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
456- assert_eq!(response.status(), StatusCode::NOT_FOUND);
457-
458- let app = seeded_with(Floor::ChangingRoom, |_| {});
459- let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
460- assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
461- assert_eq!(header(&pump, "upgrade"), "pump-pro/2.0");
462- let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
463- assert_eq!(pro.status(), StatusCode::CREATED);
464- let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
465- assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
466-}
467-
468-#[tokio::test]
469-async fn only_the_key_opens_the_cage() {
470- let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
471- send(&app, "POST", "/runs/cafebabe/shop/ice-pack", &[], "").await;
472- let still = send(
473- &app,
474- "GET",
475- "/runs/cafebabe/inventory/chastity-cage",
476- &[],
477- "",
478- )
479- .await;
480- assert_eq!(still.status(), StatusCode::OK);
481- send(&app, "POST", "/runs/cafebabe/shop/cage-key", &[], "").await;
482- let gone = send(
483- &app,
484- "GET",
485- "/runs/cafebabe/inventory/chastity-cage",
486- &[],
487- "",
488- )
489- .await;
490- assert_eq!(gone.status(), StatusCode::NOT_FOUND);
491-}
492-
493-#[tokio::test]
494-async fn combos_are_multi_status_xml() {
495- let app = seeded_with(Floor::Bathroom, |run| {
496- run.foreskin = Some(1.0);
497- run.items.blinds = true;
498- });
499- send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
500- let response = send(
501- &app,
502- "JELQ",
503- "/runs/cafebabe?technique=wet,stretch",
504- &[("content-type", "application/baby-oil")],
505- "",
506- )
507- .await;
508- assert_eq!(response.status(), StatusCode::MULTI_STATUS);
509- assert!(text(response).await.contains("<D:multistatus"));
510-}
511-
512-#[tokio::test]
513-async fn the_elder_is_stale() {
514- let app = seeded_with(Floor::Forum, |run| run.length = 5.0);
515- let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
516- assert_eq!(
517- unverified.status(),
518- StatusCode::PROXY_AUTHENTICATION_REQUIRED
519- );
520- let lie = send(
521- &app,
522- "GET",
523- "/runs/cafebabe/elder",
524- &[("proxy-authorization", "Ruler 9")],
525- "",
526- )
527- .await;
528- assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
529- let csv = send(
530- &app,
531- "GET",
532- "/runs/cafebabe/elder",
533- &[("proxy-authorization", "Ruler 5.0")],
534- "",
535- )
536- .await;
537- assert_eq!(csv.status(), StatusCode::OK);
538- let stale = send(
539- &app,
540- "GET",
541- "/runs/cafebabe/elder",
542- &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
543- "",
544- )
545- .await;
546- assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
547-}
548-
549-#[tokio::test]
550-async fn the_displayed_length_is_accepted_as_honest() {
551- let app = seeded_with(Floor::Forum, |run| run.length = 5.2496);
552- let response = send(
553- &app,
554- "GET",
555- "/runs/cafebabe/elder",
556- &[("proxy-authorization", "Ruler 5.25")],
557- "",
558- )
559- .await;
560- assert_eq!(response.status(), StatusCode::OK);
561-}
562-
563-#[tokio::test]
564-async fn the_hotel_has_a_captive_portal() {
565- let app = seeded_with(Floor::Hotel, |_| {});
566- let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
567- assert_eq!(
568- blocked.status(),
569- StatusCode::NETWORK_AUTHENTICATION_REQUIRED
570- );
571- let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
572- assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
573- let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
574- assert_eq!(online.status(), StatusCode::OK);
575-}
576-
577-#[tokio::test]
578-async fn the_internet_never_forgets() {
579- let app = seeded_with(Floor::Bedroom, |run| {
580- run.filmed_at = Some(std::time::SystemTime::now());
581- });
582- let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
583- assert_eq!(footage.status(), StatusCode::OK);
584- assert_eq!(
585- header(&footage, "cache-control"),
586- "public, max-age=31536000, immutable"
587- );
588- assert!(footage.headers().contains_key(header::AGE));
589- let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
590- assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
591- assert!(!header(&delete, "allow").contains("DELETE"));
592-}
593-
594-#[tokio::test]
595-async fn forged_ruler_cookies_are_lies() {
596- let forged = send(
597- &app(),
598- "GET",
599- "/leaderboard?sort=content-length",
600- &[("cookie", "ruler=1")],
601- "",
602- )
603- .await;
604- assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
605-}
606-
607-#[tokio::test]
608-async fn listings_never_reveal_the_private_id() {
609- let app = app();
610- let created = send(&app, "POST", "/runs", &[], "").await;
611- let location = header(&created, "location").to_owned();
612- let board = text(send(&app, "GET", "/leaderboard", &[], "").await).await;
613- let private = location.trim_start_matches("/runs/");
614- assert!(!board.contains(private));
615- let entry: Value = serde_json::from_str(&board).unwrap();
616- let public = entry["entries"][0]["public_id"].as_str().unwrap();
617- let guess = send(&app, "DELETE", &format!("/runs/{public}"), &[], "").await;
618- assert_eq!(guess.status(), StatusCode::NOT_FOUND);
619-}
620-
621-#[tokio::test]
622-async fn seeded_runs_are_unranked() {
623- let app = app();
624- send(&app, "POST", "/runs?seed=7", &[], "").await;
625- let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
626- assert_eq!(board["entries"].as_array().unwrap().len(), 0);
627- let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
628- assert_eq!(all["entries"][0]["ranked"], false);
629-}
630-
631-#[tokio::test]
632-async fn idle_runs_are_evicted_and_the_server_has_a_limit() {
633- let forgetful = crate::app(Config {
634- idle_ttl: Duration::ZERO,
635- ..Config::default()
636- });
637- let first = send(&forgetful, "POST", "/runs", &[], "").await;
638- let location = header(&first, "location").to_owned();
639- send(&forgetful, "POST", "/runs", &[], "").await;
640- let gone = send(&forgetful, "GET", &location, &[], "").await;
641- assert_eq!(gone.status(), StatusCode::NOT_FOUND);
642-
643- let tiny = crate::app(Config {
644- max_runs: 1,
645- ..Config::default()
646- });
647- send(&tiny, "POST", "/runs", &[], "").await;
648- let full = send(&tiny, "POST", "/runs", &[], "").await;
649- assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
650-}
651-
652-#[tokio::test]
653-async fn shame_from_two_runs_adds_up() {
654- let state = Arc::new(AppState::default());
655- state.insert(fixture());
656- state.insert(Run::new("deadbeef".into(), "dead".into(), 7));
657- let app = router(state);
658- let first = send(&app, "TRACE", "/runs/cafebabe", &[], "").await;
659- assert_eq!(header(&first, "x-shame"), "1");
660- let cookie = header(&first, "set-cookie")
661- .split(';')
662- .next()
663- .unwrap()
664- .to_owned();
665- let second = send(&app, "TRACE", "/runs/deadbeef", &[("cookie", &cookie)], "").await;
666- assert_eq!(header(&second, "x-shame"), "2");
667-}
668-
669-#[tokio::test]
670-async fn the_root_negotiates() {
671- let app = app();
672- let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
673- assert_eq!(browser.status(), StatusCode::SEE_OTHER);
674- assert_eq!(header(&browser, "location"), "/ui/");
675- let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
676- assert_eq!(json(curl).await["ui"], "/ui/");
677- let page = send(&app, "GET", "/ui/", &[], "").await;
678- assert_eq!(page.status(), StatusCode::OK);
679-}
680-
681-#[tokio::test]
682-async fn the_ui_replays_what_browsers_cannot_send() {
683- let app = app();
684- let envelope =
685- r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
686- let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
687- assert_eq!(traced["status"], 405);
688- let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
689- let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
690- assert_eq!(redirected["status"], 303);
691- assert_eq!(redirected["reason"], "See Other");
692-}
693-
694-#[tokio::test]
695-async fn the_ui_decodes_gzip_for_display() {
696- let app = seeded_with(Floor::Bedroom, |run| run.items.ring = true);
697- let envelope =
698- r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
699- let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
700- assert!(
701- shown["body"]
702- .as_str()
703- .unwrap()
704- .contains("\"public_id\":\"cafe\"")
705- );
706- let encoding = shown["headers"]
707- .as_array()
708- .unwrap()
709- .iter()
710- .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
711- assert!(encoding);
712-}
713-
714-#[tokio::test]
715-async fn http_1_0_gets_the_pdf_treatment() {
716- let request = Request::builder()
717- .uri("/graveyard")
718- .version(Version::HTTP_10)
719- .body(Body::empty())
720- .unwrap();
721- let response = app().oneshot(request).await.unwrap();
722- assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
723-}
724-
725-#[tokio::test]
726-async fn mom_speaks_american() {
727- let app = seeded_with(Floor::Nans, mum_at_the_door);
728- let us = [("accept-language", "en-US")];
729- let knock = send(&app, "GET", "/runs/cafebabe", &us, "").await;
730- assert_eq!(header(&knock, "content-language"), "en-US");
731- assert_eq!(header(&knock, "via"), "1.1 mom");
732- assert!(
733- json(knock).await["detail"]
734- .as_str()
735- .unwrap()
736- .contains("Someone is at the door.")
737- );
738- let excuse = [
739- ("accept-language", "en-US"),
740- (
741- "authorization",
742- "Digest username=\"carving the turkey\", nonce=\"abc\"",
743- ),
744- ];
745- let answered = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
746- assert_eq!(answered.status(), StatusCode::OK);
747- let floor = json(send(&app, "GET", "/runs/cafebabe/floor", &us, "").await).await;
748- assert_eq!(floor["name"], "Grandma's house for Thanksgiving");
749-}
750-
751-#[tokio::test]
752-async fn the_default_locale_is_british() {
753- let (app, _) = seeded(Floor::Nans);
754- let floor = send(&app, "GET", "/runs/cafebabe/floor", &[], "").await;
755- assert_eq!(header(&floor, "content-language"), "en-GB");
756- assert!(header(&floor, "vary").contains("Accept-Language"));
757- assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
758-}
759-
760-fn advertised(response: &Response) -> Vec<String> {
761- header(response, "link")
762- .split(", <")
763- .filter(|entry| entry.contains("rels/technique"))
764- .filter_map(|entry| {
765- entry
766- .split("title=\"")
767- .nth(1)?
768- .split('"')
769- .next()
770- .map(str::to_owned)
771- })
772- .collect()
773-}
774-
775-#[tokio::test]
776-async fn links_offer_only_usable_techniques() {
777- let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
778- let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
779- assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);
780- let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
781- let usable = advertised(&warm);
782- assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
783- assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
784- assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
785- assert!(
786- header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
787- );
788-
789- let (forum, _) = seeded(Floor::Forum);
790- let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
791- assert!(advertised(&response).is_empty());
792-}
793-
794-fn linked_rels(response: &Response) -> Vec<String> {
795- header(response, "link")
796- .split(", <")
797- .filter_map(|entry| {
798- entry
799- .split("rels/")
800- .nth(1)?
801- .split('"')
802- .next()
803- .map(str::to_owned)
804- })
805- .filter(|rel| rel != "technique")
806- .collect()
807-}
808-
809-#[tokio::test]
810-async fn links_hide_resources_that_would_do_nothing() {
811- let (bedroom, _) = seeded(Floor::Bedroom);
812- let before = linked_rels(&send(&bedroom, "GET", "/runs/cafebabe", &[], "").await);
813- assert!(before.contains(&"drawer".to_owned()));
814- assert!(!before.contains(&"diagnosis".to_owned()));
815- assert!(!before.contains(&"thread".to_owned()));
816- let searched = send(&bedroom, "POST", "/runs/cafebabe/drawer", &[], "").await;
817- assert!(!linked_rels(&searched).contains(&"drawer".to_owned()));
818-
819- let (clinic, _) = seeded(Floor::Clinic);
820- let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
821- assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
822-}
823-
824-#[tokio::test]
825-async fn ignoring_mum_has_consequences() {
826- let app = seeded_with(Floor::Bedroom, |run| {
827- mum_at_the_door(run);
828- run.here.turns = 6;
829- });
830- let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;
831- assert_eq!(
832- json(knock).await["type"],
833- "https://jelq.dev/problems/someone-at-the-door"
834- );
835- let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
836- assert_eq!(
837- leave.status(),
838- StatusCode::UNAUTHORIZED,
839- "no leaving with Mum at the door"
840- );
841- send(&app, "GET", "/runs/cafebabe", &[], "").await;
842- let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;
843- assert_eq!(opened.status(), StatusCode::GONE);
844- assert_eq!(
845- json(opened).await["type"],
846- "https://jelq.dev/problems/opened"
847- );
848-}
849-
850-#[tokio::test]
851-async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {
852- let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
853- let mut statuses = Vec::new();
854- for _ in 0..12 {
855- let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
856- statuses.push(response.status());
857- if response.status() == StatusCode::OK {
858- break;
859- }
860- }
861- assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");
862- assert!(
863- statuses[..statuses.len() - 1]
864- .iter()
865- .all(|s| *s == StatusCode::NO_CONTENT)
866- );
867-}
868-
869-#[tokio::test]
870-async fn the_ruler_replaces_the_self_report() {
871- let app = seeded_with(Floor::Bedroom, |_| {});
872- let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
873- assert!(before.headers().contains_key("x-self-reported-length"));
874- assert!(!before.headers().contains_key("x-length"));
875-
876- let app = seeded_with(Floor::Bedroom, |run| run.items.ruler = true);
877- let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
878- assert!(!after.headers().contains_key("x-self-reported-length"));
879- assert!(after.headers().contains_key("x-length"));
880-}
881-
882-#[tokio::test]
883-async fn an_open_tab_is_linked_and_jelq_still_answers_with_choices() {
884- let app = seeded_with(Floor::Bathroom, |run| {
885- run.story = Story::Pending;
886- run.warmed_up = true;
887- });
888- let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
889- let links = header(&response, "link");
890- assert!(links.contains("/branches/forum>; rel=\"https://jelq.dev/rels/branch\""));
891- assert!(links.contains("/branches/close-tab>; rel=\"https://jelq.dev/rels/branch\""));
892- assert!(links.contains("?technique=wet>"));
893- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
894- assert_eq!(jelq.status(), StatusCode::MULTIPLE_CHOICES);
895-}
896-
897-#[tokio::test]
898-async fn either_story_choice_is_reachable_during_cooldown() {
899- for choice in ["forum", "close-tab"] {
900- let app = seeded_with(Floor::Bathroom, |run| {
901- run.turn = 9;
902- run.warmed_up = true;
903- run.eq = 45;
904- run.stamina = 1;
905- run.items.door_locks = 1;
906- });
907- let tenth = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
908- assert_eq!(tenth.status(), StatusCode::OK);
909- let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
910- assert!(!header(&response, "link").contains("?technique="));
911- let path = format!("/runs/cafebabe/branches/{choice}");
912- assert!(
913- header(&response, "link")
914- .contains(&format!("<{path}>; rel=\"https://jelq.dev/rels/branch\""))
915- );
916- let chosen = send(&app, "POST", &path, &[], "").await;
917- assert_eq!(chosen.status(), StatusCode::SEE_OTHER);
918- assert!(!header(&chosen, "link").contains("/branches/"));
919- let repeated = send(&app, "POST", &path, &[], "").await;
920- assert_eq!(repeated.status(), StatusCode::CONFLICT);
921- }
922-}
923-
924-#[tokio::test]
925-async fn a_door_lock_says_when_it_was_used() {
926- let app = seeded_with(Floor::Bathroom, |run| {
927- run.items.door_locks = 1;
928- run.suspicion = SUSPICION_LIMIT;
929- run.warmed_up = true;
930- });
931- let response = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
932- assert_ne!(response.status(), StatusCode::CONFLICT);
933- assert!(
934- response
935- .headers()
936- .get_all("warning")
937- .iter()
938- .any(|w| w.to_str().unwrap().contains("your brother tried the door"))
939- );
940-}
941-
942-#[tokio::test]
943-async fn shopping_survives_browsing_but_not_jelqing() {
944- let app = seeded_with(Floor::ChangingRoom, |run| run.cash = 50);
945- send(&app, "GET", "/runs/cafebabe/shop", &[], "").await;
946- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
947- assert!(header(&view, "link").contains("shop-item"));
948- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
949- assert_ne!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
950- let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
951- assert!(!header(&after, "link").contains("shop-item"));
952-}
953-
954-#[tokio::test]
955-async fn the_thread_announces_itself_at_the_branch() {
956- let app = seeded_with(Floor::Bathroom, |run| {
957- run.turn = 8;
958- run.warmed_up = true;
959- run.items.baby_oil = 9;
960- });
961- send(
962- &app,
963- "JELQ",
964- "/runs/cafebabe?technique=wet",
965- &[("content-type", "application/baby-oil")],
966- "",
967- )
968- .await;
969- let ninth = send(&app, "GET", "/runs/cafebabe", &[], "").await;
970- assert!(!header(&ninth, "link").contains("/forum/threads/2009"));
971- let tenth = send(
972- &app,
973- "JELQ",
974- "/runs/cafebabe?technique=wet",
975- &[("content-type", "application/baby-oil")],
976- "",
977- )
978- .await;
979- assert!(
980- tenth
981- .headers()
982- .get_all("warning")
983- .iter()
984- .any(|w| w.to_str().unwrap().contains("a forum thread from 2009"))
985- );
986- assert!(header(&tenth, "link").contains("/forum/threads/2009"));
987- let announced = send(&app, "GET", "/runs/cafebabe", &[], "").await;
988- assert!(header(&announced, "link").contains("/forum/threads/2009"));
989-}
990-
991-#[tokio::test]
992-async fn rereading_the_thread_teaches_uli() {
993- let mut taught_runs = 0;
994- for seed in 0..100u64 {
995- let app = app_with(Floor::Bedroom, seed, |run| {
996- run.turn = BRANCH_TURN;
997- run.warmed_up = true;
998- run.story = Story::ClosedTab;
999- });
1000- let mut taught = false;
1001- for _ in 0..2 {
1002- let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1003- match read.status() {
1004- StatusCode::NOT_MODIFIED => {
1005- assert!(header(&read, "warning").contains("2009"));
1006- }
1007- StatusCode::OK => {
1008- assert!(header(&read, "location").contains("/techniques"));
1009- taught = true;
1010- break;
1011- }
1012- other => panic!("seed {seed} read the thread into {other}"),
1013- }
1014- }
1015- let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1016- assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
1017- if taught {
1018- taught_runs += 1;
1019- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
1020- assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
1021- } else {
1022- let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1023- assert_eq!(cached.status(), StatusCode::OK);
1024- assert!(header(&cached, "warning").starts_with("112"));
1025- assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);
1026- }
1027- }
1028- assert!(
1029- (30..=58).contains(&taught_runs),
1030- "{taught_runs}/100 learned uli in two reads"
1031- );
1032-}
1033-
1034-#[tokio::test]
1035-async fn meditation_is_free_but_only_once_per_floor() {
1036- let app = seeded_with(Floor::Bedroom, |_| {});
1037- let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1038- assert_eq!(response.status(), StatusCode::OK);
1039- assert_eq!(header(&response, "x-eq"), "10");
1040- assert_eq!(header(&response, "x-stamina"), "10/10");
1041- let again = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1042- assert_eq!(again.status(), StatusCode::CONFLICT);
1043-}
1044-
1045-#[tokio::test]
1046-async fn meditation_is_listed_from_the_start() {
1047- let app = seeded_with(Floor::Bedroom, |_| {});
1048- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1049- assert!(header(&view, "link").contains("technique=meditate"));
1050- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1051- assert_eq!(jelq.status(), StatusCode::OK);
1052-}
1053-
1054-fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {
1055- let state = Arc::new(AppState::default());
1056- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1057- run.floor = floor;
1058- setup(&mut run);
1059- state.insert(run);
1060- router(state)
1061-}
1062-
1063-#[tokio::test]
1064-async fn hold_is_offered_only_at_the_edges() {
1065- let calm = seeded_with(Floor::Bedroom, |_| {});
1066- let refused = send(&calm, "HOLD", "/runs/cafebabe", &[], "").await;
1067- assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED);
1068- assert!(!header(&refused, "allow").contains("HOLD"));
1069- let view = send(&calm, "GET", "/runs/cafebabe", &[], "").await;
1070- assert!(!header(&view, "link").contains("HOLD"));
1071- let options = send(&calm, "OPTIONS", "/runs/cafebabe", &[], "").await;
1072- assert!(!header(&options, "allow").contains("HOLD"));
1073-
1074- let winded = seeded_with(Floor::Bedroom, |run| run.stamina = 2);
1075- let listed = send(&winded, "GET", "/runs/cafebabe", &[], "").await;
1076- assert!(header(&listed, "link").contains("HOLD"));
1077- let options = send(&winded, "OPTIONS", "/runs/cafebabe", &[], "").await;
1078- assert!(header(&options, "allow").contains("HOLD"));
1079-
1080- let hot = seeded_with(Floor::Bedroom, |run| {
1081- run.eq = 75;
1082- run.warmed_up = true;
1083- });
1084- let held = send(&hot, "HOLD", "/runs/cafebabe", &[], "").await;
1085- assert_eq!(held.status(), StatusCode::NO_CONTENT);
1086- assert_eq!(header(&held, "x-stamina"), "10/10");
1087-}
1088-
1089-#[tokio::test]
1090-async fn holding_calms_and_sometimes_reflects() {
1091- let mut calmed = 0;
1092- let mut reflected = 0;
1093- let mut ruptured = 0;
1094- for seed in 0..120u64 {
1095- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1096- run.eq = 75;
1097- match run.hold() {
1098- Ok(_) if run.eq == 0 => reflected += 1,
1099- Ok(_) => calmed += 1,
1100- Err(_) => ruptured += 1,
1101- }
1102- }
1103- assert!(
1104- calmed > 100 && reflected > 0,
1105- "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 120"
1106- );
1107- assert!(
1108- calmed + reflected + ruptured == 120 && ruptured <= 4,
1109- "the flat 1 in 250 ruptured {ruptured} of 120 holds"
1110- );
1111-}
1112-
1113-#[tokio::test]
1114-async fn leaving_the_band_warms_the_next_floor() {
1115- let app = seeded_with(Floor::Bedroom, |run| {
1116- run.eq = 60;
1117- run.warmed_up = true;
1118- run.here.turns = 6;
1119- run.items.baby_oil = 9;
1120- });
1121- let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
1122- assert_eq!(left.status(), StatusCode::SEE_OTHER);
1123- let detail = json(left).await["detail"].as_str().unwrap().to_owned();
1124- assert!(detail.contains("the warmth came with you"));
1125-
1126- let lube = [("content-type", "application/baby-oil")];
1127- send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1128- // The shower is a +35 warm-up, so EQ lands in the band and the band bonus rides along.
1129- // The arrival bonus is the one point the second jelq loses.
1130- let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1131- assert!(
1132- header(&first, "server-timing").contains("d20+4"),
1133- "the first jelq of a warm arrival rolls with +1, got {}",
1134- header(&first, "server-timing")
1135- );
1136- let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1137- assert!(header(&second, "server-timing").contains("d20+3"));
1138-}
1139-
1140-#[tokio::test]
1141-async fn greed_multiplies_gains_above_the_band() {
1142- let lube = [("content-type", "application/baby-oil")];
1143- for seed in 0..60u64 {
1144- let app = app_with(Floor::Bedroom, seed, |run| {
1145- run.eq = 60;
1146- run.warmed_up = true;
1147- });
1148- let in_band = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1149- let app = app_with(Floor::Bedroom, seed, |run| {
1150- run.eq = 75;
1151- run.warmed_up = true;
1152- });
1153- let greedy = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1154- if in_band.status() != StatusCode::OK || greedy.status() != StatusCode::OK {
1155- continue;
1156- }
1157- let steady = json(in_band).await["length"].as_f64().unwrap();
1158- let eager = json(greedy).await["length"].as_f64().unwrap();
1159- assert!(
1160- (eager - steady * 1.5).abs() < 1e-9,
1161- "seed {seed} gained {eager} above the band against {steady} inside it"
1162- );
1163- return;
1164- }
1165- panic!("no seed in 60 produced two clean rolls to compare");
1166-}
1167-
1168-#[tokio::test]
1169-async fn the_kitchen_is_only_on_the_home_floors() {
1170- let absent = seeded_with(Floor::Bathroom, |_| {});
1171- let no_kitchen = send(&absent, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1172- assert_eq!(no_kitchen.status(), StatusCode::NOT_FOUND);
1173- let view = send(&absent, "GET", "/runs/cafebabe", &[], "").await;
1174- assert!(!header(&view, "link").contains("/kitchen"));
1175-
1176- let app = seeded_with(Floor::Bedroom, |_| {});
1177- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1178- assert!(header(&view, "link").contains("/kitchen"));
1179- let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1180- assert_ne!(raid.status(), StatusCode::NOT_FOUND);
1181-}
1182-
1183-#[tokio::test]
1184-async fn raids_cost_the_turn_and_the_fridge_pays_out_sometimes() {
1185- let mut bounties = std::collections::BTreeSet::new();
1186- let mut told = 0;
1187- for seed in 0..80u64 {
1188- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1189- let Ok(outcome) = run.raid_kitchen() else {
1190- panic!("the first raid should never fail");
1191- };
1192- assert_eq!(run.turn, 1, "a raid costs the floor turn");
1193- assert!(!run.here.melon_offered, "no melon at starting EQ");
1194- if outcome.status == StatusCode::NO_CONTENT {
1195- bounties.insert("empty");
1196- } else {
1197- bounties.insert("loot");
1198- }
1199- told += u32::from(run.kitchen_tellings);
1200- }
1201- assert!(bounties.contains("loot") && bounties.contains("empty"));
1202- assert!(
1203- (0..80).any(|seed| {
1204- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1205- run.raid_kitchen().is_ok() && run.kitchen_tellings == 0
1206- }),
1207- "most raids go unremarked"
1208- );
1209- assert!(told > 0, "someone should have been told across 80 raids");
1210-}
1211-
1212-#[tokio::test]
1213-async fn the_watermelon_offers_itself_above_ninety() {
1214- let mut offered = 0;
1215- let mut caught = 0;
1216- let mut survived = 0;
1217- for seed in 0..120u64 {
1218- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1219- run.eq = 95;
1220- let Ok(outcome) = run.raid_kitchen() else {
1221- continue;
1222- };
1223- if outcome.status != StatusCode::MULTIPLE_CHOICES {
1224- continue;
1225- }
1226- offered += 1;
1227- let gains = run.length;
1228- let disgrace = run.shame;
1229- if run.commit_melon().is_err() {
1230- caught += 1;
1231- assert_eq!(run.ended.as_ref().unwrap().cause.slug(), "watermelon");
1232- } else {
1233- survived += 1;
1234- assert_eq!(run.eq, 0);
1235- assert_eq!(run.shame, disgrace + 5);
1236- assert_eq!(run.length, gains, "the gains are kept");
1237- }
1238- }
1239- assert!(
1240- offered > 20,
1241- "the melon should appear in two fifths of raids"
1242- );
1243- assert!(caught > 0 && survived > 0, "caught is one in three");
1244-}
1245-
1246-#[tokio::test]
1247-async fn a_melon_offer_does_not_survive_a_non_kitchen_turn() {
1248- for seed in 0..60u64 {
1249- let jelq_app = app_with(Floor::Bedroom, seed, |run| {
1250- run.warmed_up = true;
1251- run.here.melon_offered = true;
1252- });
1253- let stroked = send(
1254- &jelq_app,
1255- "JELQ",
1256- "/runs/cafebabe?technique=wet&intensity=2",
1257- &[],
1258- "",
1259- )
1260- .await;
1261- if stroked.status() != StatusCode::OK && stroked.status() != StatusCode::PARTIAL_CONTENT {
1262- continue;
1263- }
1264- let bite = send(
1265- &jelq_app,
1266- "POST",
1267- "/runs/cafebabe/kitchen/watermelon",
1268- &[],
1269- "",
1270- )
1271- .await;
1272- assert_eq!(
1273- bite.status(),
1274- StatusCode::CONFLICT,
1275- "seed {seed} kept the melon through a jelq"
1276- );
1277-
1278- let hold_app = app_with(Floor::Bedroom, seed, |run| {
1279- run.stamina = 2;
1280- run.here.melon_offered = true;
1281- });
1282- let held = send(&hold_app, "HOLD", "/runs/cafebabe", &[], "").await;
1283- if held.status() != StatusCode::NO_CONTENT {
1284- continue;
1285- }
1286- let bite = send(
1287- &hold_app,
1288- "POST",
1289- "/runs/cafebabe/kitchen/watermelon",
1290- &[],
1291- "",
1292- )
1293- .await;
1294- assert_eq!(
1295- bite.status(),
1296- StatusCode::CONFLICT,
1297- "seed {seed} kept the melon through a hold"
1298- );
1299-
1300- let read_app = app_with(Floor::Bedroom, seed, |run| {
1301- run.story = Story::ClosedTab;
1302- run.here.melon_offered = true;
1303- });
1304- let read = send(&read_app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1305- if read.status() == StatusCode::INTERNAL_SERVER_ERROR {
1306- continue;
1307- }
1308- let bite = send(
1309- &read_app,
1310- "POST",
1311- "/runs/cafebabe/kitchen/watermelon",
1312- &[],
1313- "",
1314- )
1315- .await;
1316- assert_eq!(
1317- bite.status(),
1318- StatusCode::CONFLICT,
1319- "seed {seed} kept the melon through a re-read"
1320- );
1321- }
1322-
1323- let mut stood = 0;
1324- for seed in 0..60u64 {
1325- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1326- run.eq = 95;
1327- run.here.melon_offered = true;
1328- if run.raid_kitchen().is_err() {
1329- continue;
1330- }
1331- stood += 1;
1332- assert!(
1333- run.here.melon_offered,
1334- "seed {seed} lost the melon to another raid"
1335- );
1336- }
1337- assert!(stood > 40, "raids at eq 95 rarely rupture");
1338-}
1339-
1340-#[tokio::test]
1341-async fn the_kitchen_closes_after_two_tellings() {
1342- let mut closed = None;
1343- for seed in 0..300u64 {
1344- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1345- let first = run.raid_kitchen();
1346- let second = run.raid_kitchen();
1347- if second.is_err() && first.is_ok() {
1348- assert_eq!(run.kitchen_tellings, 2);
1349- let Err(third) = run.raid_kitchen() else {
1350- panic!("the kitchen should be gone");
1351- };
1352- assert_eq!(third.status(), StatusCode::GONE);
1353- closed = Some(seed);
1354- break;
1355- }
1356- }
1357- assert!(
1358- closed.is_some(),
1359- "two tellings should occur within 300 seeds"
1360- );
1361-}
1362-
1363-#[tokio::test]
1364-async fn lubes_run_out_but_spit_never_does() {
1365- let app = seeded_with(Floor::Bedroom, |run| {
1366- run.warmed_up = true;
1367- run.items.baby_oil = 1;
1368- });
1369- let lube = [("content-type", "application/baby-oil")];
1370- let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1371- assert_ne!(first.status(), StatusCode::PRECONDITION_FAILED);
1372- let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1373- assert_eq!(second.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1374-
1375- let spit = [("content-type", "text/plain; charset=spit")];
1376- for _ in 0..3 {
1377- let wet = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
1378- assert_ne!(wet.status(), StatusCode::PRECONDITION_FAILED);
1379- }
1380-}
1381-
1382-#[tokio::test]
1383-async fn energy_drinks_add_three_stamina() {
1384- let app = seeded_with(Floor::Bedroom, |run| {
1385- run.stamina = 2;
1386- run.items.energy_drinks = 1;
1387- });
1388- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1389- assert!(header(&view, "link").contains("technique=energy"));
1390- let drink = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
1391- assert_eq!(drink.status(), StatusCode::OK);
1392- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1393- assert_eq!(header(&view, "x-stamina"), "5/10");
1394- let again = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
1395- assert_eq!(again.status(), StatusCode::FAILED_DEPENDENCY);
1396-}
1397-
1398-#[tokio::test]
1399-async fn the_working_supplement_resets_stamina() {
1400- let mut worked = 0;
1401- let mut bunk = 0;
1402- for seed in 0..60u64 {
1403- let app = app_with(Floor::Bedroom, seed, |run| {
1404- run.stamina = 1;
1405- run.items.supplements = 1;
1406- });
1407- let taken = send(&app, "JELQ", "/runs/cafebabe?technique=supplement", &[], "").await;
1408- if taken.status() == StatusCode::OK {
1409- worked += 1;
1410- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1411- assert_eq!(header(&view, "x-stamina"), "10/10");
1412- } else {
1413- assert_eq!(taken.status(), StatusCode::BAD_GATEWAY);
1414- bunk += 1;
1415- }
1416- }
1417- assert!(worked > 0 && bunk > 0, "one in five should work too well");
1418-}
1419-
1420-#[tokio::test]
1421-async fn dry_jelqs_build_friction_and_real_lube_does_not() {
1422- let app = seeded_with(Floor::Bedroom, |run| {
1423- run.warmed_up = true;
1424- run.foreskin = Some(0.9);
1425- });
1426- let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1427- assert_eq!(header(&dry, "x-friction"), "2/10");
1428- let lube = [("content-type", "application/baby-oil")];
1429- let wet = send(
1430- &app,
1431- "JELQ",
1432- "/runs/cafebabe?technique=wet&intensity=5",
1433- &lube,
1434- "",
1435- )
1436- .await;
1437- assert_eq!(header(&wet, "x-friction"), "2/10");
1438- let spit = [("content-type", "text/plain; charset=spit")];
1439- let glisten = send(
1440- &app,
1441- "JELQ",
1442- "/runs/cafebabe?technique=wet&intensity=5",
1443- &spit,
1444- "",
1445- )
1446- .await;
1447- assert_eq!(header(&glisten, "x-friction"), "3/10");
1448-}
1449-
1450-#[tokio::test]
1451-async fn switching_grips_makes_the_friction_cheaper() {
1452- let app = seeded_with(Floor::Bedroom, |run| {
1453- run.warmed_up = true;
1454- run.foreskin = Some(0.9);
1455- });
1456- let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1457- assert_eq!(header(&dry, "x-friction"), "2/10");
1458- send(
1459- &app,
1460- "GET",
1461- "/runs/cafebabe/grip",
1462- &[("upgrade", "underhand"), ("connection", "upgrade")],
1463- "",
1464- )
1465- .await;
1466- let switched = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1467- assert_eq!(header(&switched, "x-friction"), "3/10");
1468- let settled = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1469- assert_eq!(header(&settled, "x-friction"), "5/10");
1470-}
1471-
1472-#[tokio::test]
1473-async fn holding_and_meditating_shed_friction() {
1474- let app = seeded_with(Floor::Bedroom, |run| {
1475- run.eq = 80;
1476- run.friction = 5;
1477- });
1478- let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1479- assert_eq!(held.status(), StatusCode::NO_CONTENT);
1480- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1481- assert_eq!(header(&view, "x-friction"), "3/10");
1482-
1483- let taught = seeded_with(Floor::Bedroom, |run| {
1484- run.friction = 5;
1485- });
1486- let calmed = send(
1487- &taught,
1488- "JELQ",
1489- "/runs/cafebabe?technique=meditate",
1490- &[],
1491- "",
1492- )
1493- .await;
1494- assert_eq!(calmed.status(), StatusCode::OK);
1495- let view = send(&taught, "GET", "/runs/cafebabe", &[], "").await;
1496- assert_eq!(header(&view, "x-friction"), "3/10");
1497-}
1498-
1499-#[tokio::test]
1500-async fn a_friction_burn_halves_the_gains() {
1501- let lube = [("content-type", "application/baby-oil")];
1502- let path = "/runs/cafebabe?technique=wet&intensity=5";
1503- for seed in 0..60u64 {
1504- let steady = app_with(Floor::Bedroom, seed, |run| {
1505- run.warmed_up = true;
1506- run.eq = 60;
1507- run.items.baby_oil = 9;
1508- });
1509- let hurt_app = app_with(Floor::Bedroom, seed, |run| {
1510- run.warmed_up = true;
1511- run.eq = 60;
1512- run.items.baby_oil = 9;
1513- run.conditions.friction_burn = true;
1514- });
1515- let calm = send(&steady, "JELQ", path, &lube, "").await;
1516- let hurt = send(&hurt_app, "JELQ", path, &lube, "").await;
1517- if calm.status() != StatusCode::OK || hurt.status() != StatusCode::OK {
1518- continue;
1519- }
1520- let calm_gain = json(calm).await["length"].as_f64().unwrap();
1521- let hurt_gain = json(hurt).await["length"].as_f64().unwrap();
1522- assert!(
1523- (hurt_gain * 2.0 - calm_gain).abs() < 1e-9,
1524- "seed {seed} burned {hurt_gain} against {calm_gain}"
1525- );
1526- return;
1527- }
1528- panic!("no seed in 60 produced two clean rolls to compare");
1529-}
1530-
1531-#[tokio::test]
1532-async fn the_burn_warns_and_the_ice_treats_it() {
1533- let app = seeded_with(Floor::Bedroom, |run| {
1534- run.warmed_up = true;
1535- run.eq = 60;
1536- run.items.baby_oil = 9;
1537- run.friction = 7;
1538- run.conditions.friction_burn = true;
1539- });
1540- let lube = [("content-type", "application/baby-oil")];
1541- let jelq = send(
1542- &app,
1543- "JELQ",
1544- "/runs/cafebabe?technique=wet&intensity=5",
1545- &lube,
1546- "",
1547- )
1548- .await;
1549- let warnings: Vec<_> = jelq
1550- .headers()
1551- .get_all("warning")
1552- .iter()
1553- .filter_map(|w| w.to_str().ok())
1554- .collect();
1555- assert!(warnings.contains(&"199 - \"friction burn\""));
1556- send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
1557- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1558- let warnings: Vec<_> = view
1559- .headers()
1560- .get_all("warning")
1561- .iter()
1562- .filter_map(|w| w.to_str().ok())
1563- .collect();
1564- assert!(!warnings.contains(&"199 - \"friction burn\""));
1565-}
1566-
1567-#[tokio::test]
1568-async fn the_burn_shifts_the_rupture_tiers() {
1569- async fn ruptures(burned: bool) -> u32 {
1570- let mut gone = 0;
1571- for seed in 0..400u64 {
1572- let app = app_with(Floor::Bedroom, seed, |run| {
1573- run.warmed_up = true;
1574- run.eq = 60;
1575- run.items.baby_oil = 9;
1576- run.conditions.friction_burn = burned;
1577- });
1578- let lube = [("content-type", "application/baby-oil")];
1579- let jelq = send(
1580- &app,
1581- "JELQ",
1582- "/runs/cafebabe?technique=wet&intensity=5",
1583- &lube,
1584- "",
1585- )
1586- .await;
1587- if jelq.status() == StatusCode::INTERNAL_SERVER_ERROR {
1588- gone += 1;
1589- }
1590- }
1591- gone
1592- }
1593- let burned = ruptures(true).await;
1594- let calm = ruptures(false).await;
1595- assert!(
1596- burned >= 8 && calm <= 4,
1597- "burned {burned} ruptures against {calm}"
1598- );
1599-}
1600-
1601-#[tokio::test]
1602-async fn meditation_respects_the_priapism_floor() {
1603- let app = seeded_with(Floor::Bedroom, |run| {
1604- run.eq = 80;
1605- run.conditions.priapism_turns = 3;
1606- });
1607- let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1608- assert_eq!(calmed.status(), StatusCode::OK);
1609- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1610- assert_eq!(header(&view, "x-eq"), "75");
1611-}
1612-
1613-#[tokio::test]
1614-async fn the_kitchen_pays_out_at_the_documented_odds() {
1615- let mut oil = 0;
1616- let mut drinks = 0;
1617- let mut packs = 0;
1618- for seed in 0..600u64 {
1619- let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1620- run.kitchen_tellings = 0;
1621- let held = (
1622- run.items.coconut_oil,
1623- run.items.energy_drinks,
1624- run.items.ice_packs,
1625- );
1626- let _ = run.raid_kitchen();
1627- oil += u32::from(run.items.coconut_oil > held.0);
1628- drinks += u32::from(run.items.energy_drinks > held.1);
1629- packs += u32::from(run.items.ice_packs > held.2);
1630- }
1631- assert!((170..=230).contains(&oil), "coconut {oil} in 600");
1632- assert!((90..=150).contains(&drinks), "energy {drinks} in 600");
1633- assert!((45..=105).contains(&packs), "ice {packs} in 600");
1634-}
1635-
1636-#[tokio::test]
1637-async fn a_refused_jelq_leaves_the_lube_alone() {
1638- let app = seeded_with(Floor::Bedroom, |run| {
1639- run.warmed_up = true;
1640- run.items.extender = true;
1641- run.girth = 6.0;
1642- run.length = 6.0;
1643- });
1644- let lube = [("content-type", "application/baby-oil")];
1645- let full = send(
1646- &app,
1647- "JELQ",
1648- "/runs/cafebabe?technique=wet&intensity=5",
1649- &lube,
1650- "",
1651- )
1652- .await;
1653- assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
1654- let inv = send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await;
1655- let body = json(inv).await;
1656- let held = body["items"].as_array().unwrap();
1657- assert!(held.iter().any(|i| i["name"] == "baby-oil"));
1658-}
1659-
1660-#[tokio::test]
1661-async fn an_empty_bottle_is_declined_before_the_enemy_stirs() {
1662- let control = seeded_with(Floor::ChangingRoom, |run| {
1663- run.warmed_up = true;
1664- run.suspicion = SUSPICION_LIMIT;
1665- });
1666- let lube = [("content-type", "application/baby-oil")];
1667- let caught = send(
1668- &control,
1669- "JELQ",
1670- "/runs/cafebabe?technique=wet&intensity=5",
1671- &lube,
1672- "",
1673- )
1674- .await;
1675- assert_eq!(caught.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
1676-
1677- let app = seeded_with(Floor::ChangingRoom, |run| {
1678- run.warmed_up = true;
1679- run.suspicion = SUSPICION_LIMIT;
1680- run.items.baby_oil = 0;
1681- });
1682- let refused = send(
1683- &app,
1684- "JELQ",
1685- "/runs/cafebabe?technique=wet&intensity=5",
1686- &lube,
1687- "",
1688- )
1689- .await;
1690- assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1691- let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1692- assert_eq!(header(&view, "x-suspicion"), "5/5");
1693- assert_eq!(header(&view, "x-shame"), "0");
1694-}
1695-
1696-#[tokio::test]
1697-async fn the_top_tier_rolls_a_single_third() {
1698- let mut ruptures = 0;
1699- let mut early = 0;
1700- for seed in 0..400u64 {
1701- let app = app_with(Floor::Bedroom, seed, |run| {
1702- run.warmed_up = true;
1703- run.eq = 96;
1704- run.items.baby_oil = 9;
1705- run.items.blinds = true;
1706- });
1707- let lube = [("content-type", "application/baby-oil")];
1708- let jelq = send(
1709- &app,
1710- "JELQ",
1711- "/runs/cafebabe?technique=wet&intensity=5",
1712- &lube,
1713- "",
1714- )
1715- .await;
1716- match jelq.status() {
1717- StatusCode::INTERNAL_SERVER_ERROR => ruptures += 1,
1718- StatusCode::TOO_EARLY => early += 1,
1719- other => panic!("seed {seed} ended in {other}"),
1720- }
1721- }
1722- assert!(
1723- (90..=175).contains(&ruptures),
1724- "ruptures {ruptures} of 400 against {early} early"
1725- );
1726- assert!(
1727- (225..=310).contains(&early),
1728- "early {early} of 400 against {ruptures} ruptures"
1729- );
1730-}
1731-
1732-#[tokio::test]
1733-async fn priapism_holds_the_floor_through_every_reset() {
1734- let exit = seeded_with(Floor::Bedroom, |run| {
1735- run.warmed_up = true;
1736- run.eq = 90;
1737- run.here.turns = 99;
1738- run.conditions.priapism_turns = 3;
1739- });
1740- let out = send(&exit, "POST", "/runs/cafebabe/floor", &[], "").await;
1741- assert_eq!(out.status(), StatusCode::SEE_OTHER);
1742- let view = send(&exit, "GET", "/runs/cafebabe", &[], "").await;
1743- assert_eq!(header(&view, "x-eq"), "75");
1744-
1745- let melon = seeded_with(Floor::Bedroom, |run| {
1746- run.eq = 95;
1747- run.here.melon_offered = true;
1748- run.conditions.priapism_turns = 2;
1749- });
1750- let bite = send(&melon, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1751- assert_eq!(bite.status(), StatusCode::OK);
1752- let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
1753- assert_eq!(header(&view, "x-eq"), "75");
1754-}
1755-
1756-#[tokio::test]
1757-async fn holding_rolls_a_flat_rupture_chance() {
1758- async fn ruptures(eq: i32, stamina: i32) -> u32 {
1759- let mut gone = 0;
1760- for seed in 0..600u64 {
1761- let app = app_with(Floor::Bedroom, seed, |run| {
1762- run.warmed_up = true;
1763- run.eq = eq;
1764- run.stamina = stamina;
1765- });
1766- let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1767- if held.status() == StatusCode::INTERNAL_SERVER_ERROR {
1768- gone += 1;
1769- }
1770- }
1771- gone
1772- }
1773- let high = ruptures(90, 10).await;
1774- let low = ruptures(30, 2).await;
1775- assert!((0..=9).contains(&high), "high holds ruptured {high}/600");
1776- assert!((0..=9).contains(&low), "low holds ruptured {low}/600");
1777-}
1778-
1779-#[tokio::test]
1780-async fn the_kitchen_rolls_the_normal_tiers() {
1781- async fn ruptures(eq: i32) -> u32 {
1782- let mut gone = 0;
1783- for seed in 0..600u64 {
1784- let app = app_with(Floor::Bedroom, seed, |run| run.eq = eq);
1785- let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1786- if raid.status() == StatusCode::INTERNAL_SERVER_ERROR {
1787- gone += 1;
1788- }
1789- }
1790- gone
1791- }
1792- let hot = ruptures(96).await;
1793- let cool = ruptures(40).await;
1794- assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");
1795- assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");
1796-}
1797-
1798-#[tokio::test]
1799-async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {
1800- let app = seeded_with(Floor::Bedroom, |run| run.warmed_up = true);
1801- let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1802- assert_eq!(read.status(), StatusCode::NOT_FOUND);
1803- let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1804- assert_eq!(header(&after, "x-floor"), "1");
1805- assert!(!header(&after, "link").contains("/re-read"));
1806-}
1807-
1808-#[tokio::test]
1809-async fn any_other_action_takes_the_melon_off_the_table() {
1810- for (method, uri) in [
1811- ("JELQ", "/runs/cafebabe?technique=warmup"),
1812- ("POST", "/runs/cafebabe/drawer"),
1813- ] {
1814- let app = seeded_with(Floor::Bedroom, |run| {
1815- run.eq = 95;
1816- run.here.melon_offered = true;
1817- });
1818- send(&app, method, uri, &[], "").await;
1819- let melon = send(&app, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1820- assert_eq!(melon.status(), StatusCode::CONFLICT, "{method} {uri}");
1821- }
1822-}
1823-
1824-#[tokio::test]
1825-async fn a_refused_jelq_does_not_break_the_edging_streak() {
1826- let app = seeded_with(Floor::ChangingRoom, |run| {
1827- run.warmed_up = true;
1828- run.eq = 45;
1829- run.here.edges = 2;
1830- });
1831- let refused = send(
1832- &app,
1833- "JELQ",
1834- "/runs/cafebabe?technique=dry",
1835- &[("content-type", "application/baby-oil")],
1836- "",
1837- )
1838- .await;
1839- assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1840- let third = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1841- assert_eq!(third.status(), StatusCode::LOOP_DETECTED);
1842-}
1843-
1844-#[tokio::test]
1845-async fn an_interrupted_edge_keeps_no_gains() {
1846- let app = seeded_with(Floor::ChangingRoom, |run| {
1847- run.warmed_up = true;
1848- run.eq = 10;
1849- });
1850- let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1851- let girth = header(&before, "x-girth").to_owned();
1852- let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1853- assert_eq!(edge.status(), StatusCode::NOT_FOUND);
1854- assert_eq!(header(&edge, "x-girth"), girth);
1855-}
1856-
1857-#[tokio::test]
1858-async fn rummaging_for_the_key_leaves_the_drawer_unsearched() {
1859- let mut found = 0;
1860- for seed in 0..20u64 {
1861- let app = app_with(Floor::Hotel, seed, |run| {
1862- run.here.wifi_accepted = true;
1863- run.items.cage = true;
1864- });
1865- for _ in 0..30 {
1866- let rummage = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1867- if rummage.status() == StatusCode::OK {
1868- break;
1869- }
1870- }
1871- let search = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1872- if search.status() == StatusCode::FOUND {
1873- assert!(header(&search, "location").ends_with("/inventory/do-not-disturb-sign"));
1874- found += 1;
1875- }
1876- }
1877- assert!(
1878- found >= 18,
1879- "only {found} of 20 hotel drawers survived the rummage"
1880- );
1881-}
1882-
1883-#[tokio::test]
1884-async fn a_full_server_buries_the_dead_to_make_room() {
1885- let app = crate::app(Config {
1886- max_runs: 1,
1887- ..Config::default()
1888- });
1889- let first = send(&app, "POST", "/runs", &[], "").await;
1890- let location = header(&first, "location").to_owned();
1891- let full = send(&app, "POST", "/runs", &[], "").await;
1892- assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
1893- send(&app, "DELETE", &location, &[], "").await;
1894- let second = send(&app, "POST", "/runs", &[], "").await;
1895- assert_eq!(second.status(), StatusCode::CREATED);
1896-}
1897-
1898-#[tokio::test]
1899-async fn the_towel_warms_once_until_the_kettle_boils() {
1900- let app = seeded_with(Floor::Bedroom, |_| {});
1901- let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1902- assert_eq!(warm.status(), StatusCode::OK);
1903- let again = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1904- assert_eq!(again.status(), StatusCode::CONFLICT);
1905-
1906- let app = seeded_with(Floor::Bedroom, |run| {
1907- run.warmed_up = true;
1908- run.here.kettle_boiled = true;
1909- });
1910- let reheated = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1911- assert_eq!(reheated.status(), StatusCode::OK);
1912- let cold = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1913- assert_eq!(cold.status(), StatusCode::CONFLICT);
1914-}
1915-
1916-#[tokio::test]
1917-async fn the_refractory_period_is_the_next_hold() {
1918- let app = seeded_with(Floor::Hotel, |run| {
1919- run.here.wifi_accepted = true;
1920- run.warmed_up = true;
1921- run.eq = 60;
1922- run.stamina = 0;
1923- run.exhausted = true;
1924- });
1925- let tired = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1926- assert_eq!(tired.status(), StatusCode::TOO_MANY_REQUESTS);
1927- let hold = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1928- assert_eq!(hold.status(), StatusCode::OK);
1929- assert_eq!(header(&hold, "x-stamina"), "10/10");
1930- let cold = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1931- assert_eq!(cold.status(), StatusCode::PRECONDITION_FAILED);
1932-}
1933-
1934-#[tokio::test]
1935-async fn friction_rises_with_vigour_and_edging_chafes() {
1936- let spit = [("content-type", "text/plain; charset=spit")];
1937- for (intensity, friction) in [(5, "1/10"), (7, "2/10"), (10, "3/10")] {
1938- let app = seeded_with(Floor::Hotel, |run| {
1939- run.here.wifi_accepted = true;
1940- run.warmed_up = true;
1941- run.eq = 55;
1942- run.foreskin = Some(0.5);
1943- });
1944- let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");
1945- let jelq = send(&app, "JELQ", &uri, &spit, "").await;
1946- assert_eq!(
1947- header(&jelq, "x-friction"),
1948- friction,
1949- "intensity {intensity}"
1950- );
1951- }
1952- let app = seeded_with(Floor::Hotel, |run| {
1953- run.here.wifi_accepted = true;
1954- run.warmed_up = true;
1955- run.eq = 55;
1956- run.foreskin = Some(0.5);
1957- });
1958- let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1959- assert_eq!(header(&edge, "x-friction"), "2/10");
1960-}
1961-
1962-#[tokio::test]
1963-async fn the_body_changes_how_the_skin_takes_it() {
1964- let spit = [("content-type", "text/plain; charset=spit")];
1965- for (foreskin, girth, friction) in [(0.5, 4.5, "1/10"), (1.2, 4.5, "0/10"), (0.5, 5.8, "2/10")]
1966- {
1967- let app = seeded_with(Floor::Hotel, |run| {
1968- run.here.wifi_accepted = true;
1969- run.warmed_up = true;
1970- run.eq = 55;
1971- run.foreskin = Some(foreskin);
1972- run.girth = girth;
1973- });
1974- let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
1975- assert_eq!(header(&jelq, "x-friction"), friction, "{foreskin} {girth}");
1976- }
1977-}
1978-
1979-#[tokio::test]
1980-async fn a_thick_girth_outgrows_the_underhand_grip() {
1981- let app = seeded_with(Floor::Bedroom, |run| run.girth = 5.8);
1982- let grip = send(
1983- &app,
1984- "GET",
1985- "/runs/cafebabe/grip",
1986- &[("upgrade", "underhand"), ("connection", "upgrade")],
1987- "",
1988- )
1989- .await;
1990- assert_eq!(grip.status(), StatusCode::PAYLOAD_TOO_LARGE);
1991-}
1992-
1993-#[tokio::test]
1994-async fn respectable_girth_waves_off_the_gym_staff() {
1995- for (girth, status) in [
1996- (4.5, StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS),
1997- (5.2, StatusCode::OK),
1998- ] {
1999- let app = seeded_with(Floor::ChangingRoom, |run| {
2000- run.warmed_up = true;
2001- run.eq = 45;
2002- run.girth = girth;
2003- run.suspicion = SUSPICION_LIMIT;
2004- });
2005- let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
2006- if girth > 5.0 {
2007- assert_ne!(edge.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
2008- assert!(header(&edge, "warning").contains("gym staff"));
2009- } else {
2010- assert_eq!(edge.status(), status);
2011- }
2012- }
2013-}
2014-
2015-#[tokio::test]
2016-async fn kev_is_the_bar_in_the_changing_room() {
2017- for (length, shamed) in [(5.0, true), (7.0, false)] {
2018- let app = seeded_with(Floor::Bathroom, |run| {
2019- run.length = length;
2020- run.here.turns = 6;
2021- });
2022- let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
2023- assert_eq!(header(&left, "x-floor"), "3");
2024- let warning = left
2025- .headers()
2026- .get("warning")
2027- .map(|w| w.to_str().unwrap().to_owned());
2028- assert_eq!(
2029- warning.is_some_and(|w| w.contains("Kev")),
2030- shamed,
2031- "length {length}"
2032- );
2033- }
2034-}
2035-
2036-#[tokio::test]
2037-async fn the_forum_respects_volume() {
2038- let state = Arc::new(AppState::default());
2039- for (id, length, girth) in [("slim", 7.0, 4.0), ("stout", 5.0, 5.5)] {
2040- let mut run = Run::new(id.into(), id.into(), 1);
2041- run.length = length;
2042- run.girth = girth;
2043- state.insert(run);
2044- }
2045- let app = router(state);
2046- let board = json(
2047- send(
2048- &app,
2049- "GET",
2050- "/leaderboard?sort=volume&seeded=include",
2051- &[],
2052- "",
2053- )
2054- .await,
2055- )
2056- .await;
2057- assert_eq!(board["entries"][0]["public_id"], "stout");
2058- assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);
2059-}
2060-
2061-#[tokio::test]
2062-async fn the_dead_outlive_the_server() {
2063- let path = std::env::temp_dir().join(format!("jelqd-{}.sqlite", std::process::id()));
2064- let config = || Config {
2065- db: Some(path.clone()),
2066- ..Config::default()
2067- };
2068- let first = crate::app(config());
2069- let created = send(&first, "POST", "/runs", &[], "").await;
2070- let location = header(&created, "location").to_owned();
2071- send(&first, "DELETE", &location, &[], "").await;
2072- drop(first);
2073-
2074- let second = crate::app(config());
2075- let gone = send(&second, "GET", &location, &[], "").await;
2076- assert_eq!(gone.status(), StatusCode::GONE);
2077- let graveyard = json(send(&second, "GET", "/graveyard", &[], "").await).await;
2078- assert_eq!(graveyard["runs"].as_array().unwrap().len(), 1);
2079- let board = json(send(&second, "GET", "/leaderboard", &[], "").await).await;
2080- assert_eq!(board["entries"][0]["status"], "gone");
2081- for suffix in ["", "-wal", "-shm"] {
2082- let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
2083- }
2084-}
2085-
2086-#[tokio::test]
2087-async fn pump_bro_charges_the_thin_more_for_girth() {
2088- for (girth, ring, oil) in [(4.5, 11, 4), (5.2, 8, 4)] {
2089- let app = seeded_with(Floor::ChangingRoom, |run| run.girth = girth);
2090- let shop = json(send(&app, "GET", "/runs/cafebabe/shop", &[], "").await).await;
2091- let price = |slug: &str| {
2092- shop["stock"]
2093- .as_array()
2094- .unwrap()
2095- .iter()
2096- .find(|item| item["item"] == slug)
2097- .unwrap()["price"]
2098- .as_u64()
2099- .unwrap()
2100- };
2101- assert_eq!(price("cock-ring"), ring, "girth {girth}");
2102- assert_eq!(price("baby-oil"), oil, "girth {girth}");
2103- }
2104-}
2105-
2106-#[tokio::test]
2107-async fn a_long_run_fills_the_jeans_before_a_thick_one() {
2108- let app = seeded_with(Floor::Bedroom, |run| {
2109- run.warmed_up = true;
2110- run.items.extender = true;
2111- run.girth = 4.8;
2112- run.length = 7.5;
2113- });
2114- let lube = [("content-type", "application/baby-oil")];
2115- let full = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
2116- assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
2117-}
Added file: src/tests/body.rs+400 additions
@@ -0,0 +1,400 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn deleting_the_foreskin_requires_certainty() {
5+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
6+ let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
7+ assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
8+ let look = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
9+ assert_eq!(look.status(), StatusCode::OK);
10+ let etag = header(&look, "etag").to_owned();
11+ assert!(text(look).await.contains('~'));
12+ let response = send(
13+ &app,
14+ "DELETE",
15+ "/runs/cafebabe/foreskin",
16+ &[("if-match", &etag)],
17+ "",
18+ )
19+ .await;
20+ assert_eq!(response.status(), StatusCode::NO_CONTENT);
21+ let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
22+ assert_eq!(response.status(), StatusCode::GONE);
23+}
24+
25+#[tokio::test]
26+async fn length_supports_inch_ranges() {
27+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
28+ let response = send(
29+ &app,
30+ "GET",
31+ "/runs/cafebabe/length",
32+ &[("range", "inches=0-1")],
33+ "",
34+ )
35+ .await;
36+ assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
37+ assert!(header(&response, "content-range").starts_with("inches 0.00-1.00/"));
38+ assert_eq!(text(response).await.len(), 25);
39+ let absurd = send(
40+ &app,
41+ "GET",
42+ "/runs/cafebabe/length",
43+ &[("range", "inches=40-50")],
44+ "",
45+ )
46+ .await;
47+ assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
48+ let malformed = send(
49+ &app,
50+ "GET",
51+ "/runs/cafebabe/length",
52+ &[("range", "inches=6.17")],
53+ "",
54+ )
55+ .await;
56+ assert_eq!(malformed.status(), StatusCode::OK);
57+ let suffix = send(
58+ &app,
59+ "GET",
60+ "/runs/cafebabe/length",
61+ &[("range", "inches=-1")],
62+ "",
63+ )
64+ .await;
65+ assert_eq!(suffix.status(), StatusCode::PARTIAL_CONTENT);
66+ assert!(text(suffix).await.ends_with('D'));
67+ for (range, status) in [
68+ ("inches=1-", StatusCode::PARTIAL_CONTENT),
69+ ("inches=40-", StatusCode::RANGE_NOT_SATISFIABLE),
70+ ("inches=-0", StatusCode::RANGE_NOT_SATISFIABLE),
71+ ("inches=2-1", StatusCode::OK),
72+ ("inches=NaN-2", StatusCode::OK),
73+ ("inches=0-inf", StatusCode::OK),
74+ ] {
75+ let response = send(
76+ &app,
77+ "GET",
78+ "/runs/cafebabe/length",
79+ &[("range", range)],
80+ "",
81+ )
82+ .await;
83+ assert_eq!(response.status(), status, "{range}");
84+ }
85+}
86+
87+#[tokio::test]
88+async fn the_displayed_length_is_accepted_as_honest() {
89+ let app = seeded_with(Floor::Forum, |run| run.body.length = 5.2496);
90+ let response = send(
91+ &app,
92+ "GET",
93+ "/runs/cafebabe/elder",
94+ &[("proxy-authorization", "Ruler 5.25")],
95+ "",
96+ )
97+ .await;
98+ assert_eq!(response.status(), StatusCode::OK);
99+}
100+
101+#[tokio::test]
102+async fn dry_jelqs_build_friction_and_real_lube_does_not() {
103+ let app = seeded_with(Floor::Bedroom, |run| {
104+ run.body.warmed_up = true;
105+ run.body.foreskin = Some(0.9);
106+ });
107+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
108+ assert_eq!(header(&dry, "x-friction"), "2/10");
109+ let lube = [("content-type", "application/baby-oil")];
110+ let wet = send(
111+ &app,
112+ "JELQ",
113+ "/runs/cafebabe?technique=wet&intensity=5",
114+ &lube,
115+ "",
116+ )
117+ .await;
118+ assert_eq!(header(&wet, "x-friction"), "2/10");
119+ let spit = [("content-type", "text/plain; charset=spit")];
120+ let glisten = send(
121+ &app,
122+ "JELQ",
123+ "/runs/cafebabe?technique=wet&intensity=5",
124+ &spit,
125+ "",
126+ )
127+ .await;
128+ assert_eq!(header(&glisten, "x-friction"), "3/10");
129+}
130+
131+#[tokio::test]
132+async fn switching_grips_makes_the_friction_cheaper() {
133+ let app = seeded_with(Floor::Bedroom, |run| {
134+ run.body.warmed_up = true;
135+ run.body.foreskin = Some(0.9);
136+ });
137+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
138+ assert_eq!(header(&dry, "x-friction"), "2/10");
139+ send(
140+ &app,
141+ "GET",
142+ "/runs/cafebabe/grip",
143+ &[("upgrade", "underhand"), ("connection", "upgrade")],
144+ "",
145+ )
146+ .await;
147+ let switched = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
148+ assert_eq!(header(&switched, "x-friction"), "3/10");
149+ let settled = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
150+ assert_eq!(header(&settled, "x-friction"), "5/10");
151+}
152+
153+#[tokio::test]
154+async fn holding_and_meditating_shed_friction() {
155+ let app = seeded_with(Floor::Bedroom, |run| {
156+ run.body.eq = 80;
157+ run.body.friction = 5;
158+ });
159+ let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
160+ assert_eq!(held.status(), StatusCode::NO_CONTENT);
161+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
162+ assert_eq!(header(&view, "x-friction"), "3/10");
163+
164+ let taught = seeded_with(Floor::Bedroom, |run| {
165+ run.body.friction = 5;
166+ });
167+ let calmed = send(
168+ &taught,
169+ "JELQ",
170+ "/runs/cafebabe?technique=meditate",
171+ &[],
172+ "",
173+ )
174+ .await;
175+ assert_eq!(calmed.status(), StatusCode::OK);
176+ let view = send(&taught, "GET", "/runs/cafebabe", &[], "").await;
177+ assert_eq!(header(&view, "x-friction"), "3/10");
178+}
179+
180+#[tokio::test]
181+async fn a_friction_burn_halves_the_gains() {
182+ let lube = [("content-type", "application/baby-oil")];
183+ let path = "/runs/cafebabe?technique=wet&intensity=5";
184+ for seed in 0..60u64 {
185+ let steady = app_with(Floor::Bedroom, seed, |run| {
186+ run.body.warmed_up = true;
187+ run.body.eq = 60;
188+ run.items.lubes.baby_oil = 9;
189+ });
190+ let hurt_app = app_with(Floor::Bedroom, seed, |run| {
191+ run.body.warmed_up = true;
192+ run.body.eq = 60;
193+ run.items.lubes.baby_oil = 9;
194+ run.body.conditions.friction_burn = true;
195+ });
196+ let calm = send(&steady, "JELQ", path, &lube, "").await;
197+ let hurt = send(&hurt_app, "JELQ", path, &lube, "").await;
198+ if calm.status() != StatusCode::OK || hurt.status() != StatusCode::OK {
199+ continue;
200+ }
201+ let calm_gain = json(calm).await["length"].as_f64().unwrap();
202+ let hurt_gain = json(hurt).await["length"].as_f64().unwrap();
203+ assert!(
204+ (hurt_gain * 2.0 - calm_gain).abs() < 1e-9,
205+ "seed {seed} burned {hurt_gain} against {calm_gain}"
206+ );
207+ return;
208+ }
209+ panic!("no seed in 60 produced two clean rolls to compare");
210+}
211+
212+#[tokio::test]
213+async fn the_burn_warns_and_the_ice_treats_it() {
214+ let app = seeded_with(Floor::Bedroom, |run| {
215+ run.body.warmed_up = true;
216+ run.body.eq = 60;
217+ run.items.lubes.baby_oil = 9;
218+ run.body.friction = 7;
219+ run.body.conditions.friction_burn = true;
220+ });
221+ let lube = [("content-type", "application/baby-oil")];
222+ let jelq = send(
223+ &app,
224+ "JELQ",
225+ "/runs/cafebabe?technique=wet&intensity=5",
226+ &lube,
227+ "",
228+ )
229+ .await;
230+ let warnings: Vec<_> = jelq
231+ .headers()
232+ .get_all("warning")
233+ .iter()
234+ .filter_map(|w| w.to_str().ok())
235+ .collect();
236+ assert!(warnings.contains(&"199 - \"friction burn\""));
237+ send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
238+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
239+ let warnings: Vec<_> = view
240+ .headers()
241+ .get_all("warning")
242+ .iter()
243+ .filter_map(|w| w.to_str().ok())
244+ .collect();
245+ assert!(!warnings.contains(&"199 - \"friction burn\""));
246+}
247+
248+#[tokio::test]
249+async fn friction_rises_with_vigour_and_edging_chafes() {
250+ let spit = [("content-type", "text/plain; charset=spit")];
251+ for (intensity, friction) in [(5, "1/10"), (7, "2/10"), (10, "3/10")] {
252+ let app = seeded_with(Floor::Hotel, |run| {
253+ run.here.wifi_accepted = true;
254+ run.body.warmed_up = true;
255+ run.body.eq = 55;
256+ run.body.foreskin = Some(0.5);
257+ });
258+ let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");
259+ let jelq = send(&app, "JELQ", &uri, &spit, "").await;
260+ assert_eq!(
261+ header(&jelq, "x-friction"),
262+ friction,
263+ "intensity {intensity}"
264+ );
265+ }
266+ let app = seeded_with(Floor::Hotel, |run| {
267+ run.here.wifi_accepted = true;
268+ run.body.warmed_up = true;
269+ run.body.eq = 55;
270+ run.body.foreskin = Some(0.5);
271+ });
272+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
273+ assert_eq!(header(&edge, "x-friction"), "2/10");
274+}
275+
276+#[tokio::test]
277+async fn the_body_changes_how_the_skin_takes_it() {
278+ let spit = [("content-type", "text/plain; charset=spit")];
279+ for (foreskin, girth, friction) in [(0.5, 4.5, "1/10"), (1.2, 4.5, "0/10"), (0.5, 5.8, "2/10")]
280+ {
281+ let app = seeded_with(Floor::Hotel, |run| {
282+ run.here.wifi_accepted = true;
283+ run.body.warmed_up = true;
284+ run.body.eq = 55;
285+ run.body.foreskin = Some(foreskin);
286+ run.body.girth = girth;
287+ });
288+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
289+ assert_eq!(header(&jelq, "x-friction"), friction, "{foreskin} {girth}");
290+ }
291+}
292+
293+#[tokio::test]
294+async fn a_thick_girth_outgrows_the_underhand_grip() {
295+ let app = seeded_with(Floor::Bedroom, |run| run.body.girth = 5.8);
296+ let grip = send(
297+ &app,
298+ "GET",
299+ "/runs/cafebabe/grip",
300+ &[("upgrade", "underhand"), ("connection", "upgrade")],
301+ "",
302+ )
303+ .await;
304+ assert_eq!(grip.status(), StatusCode::PAYLOAD_TOO_LARGE);
305+}
306+
307+#[tokio::test]
308+async fn a_long_run_fills_the_jeans_before_a_thick_one() {
309+ let app = seeded_with(Floor::Bedroom, |run| {
310+ run.body.warmed_up = true;
311+ run.items.kit.extender = true;
312+ run.body.girth = 4.8;
313+ run.body.length = 7.5;
314+ });
315+ let lube = [("content-type", "application/baby-oil")];
316+ let full = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
317+ assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
318+}
319+
320+#[tokio::test]
321+async fn a_refused_jelq_leaves_the_lube_alone() {
322+ let app = seeded_with(Floor::Bedroom, |run| {
323+ run.body.warmed_up = true;
324+ run.items.kit.extender = true;
325+ run.body.girth = 6.0;
326+ run.body.length = 6.0;
327+ });
328+ let lube = [("content-type", "application/baby-oil")];
329+ let full = send(
330+ &app,
331+ "JELQ",
332+ "/runs/cafebabe?technique=wet&intensity=5",
333+ &lube,
334+ "",
335+ )
336+ .await;
337+ assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
338+ let inv = send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await;
339+ let body = json(inv).await;
340+ let held = body["items"].as_array().unwrap();
341+ assert!(held.iter().any(|i| i["name"] == "baby-oil"));
342+}
343+
344+#[tokio::test]
345+async fn lubes_run_out_but_spit_never_does() {
346+ let app = seeded_with(Floor::Bedroom, |run| {
347+ run.body.warmed_up = true;
348+ run.items.lubes.baby_oil = 1;
349+ });
350+ let lube = [("content-type", "application/baby-oil")];
351+ send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
352+ let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
353+ assert_eq!(second.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
354+
355+ let spit = [("content-type", "text/plain; charset=spit")];
356+ for _ in 0..3 {
357+ let wet = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
358+ assert_ne!(
359+ wet.status(),
360+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
361+ "spit never runs out"
362+ );
363+ }
364+}
365+
366+#[tokio::test]
367+async fn patching_costs_a_little_eq() {
368+ let app = seeded_with(Floor::Bedroom, |run| run.body.eq = 50);
369+ let patch = send(
370+ &app,
371+ "PATCH",
372+ "/runs/cafebabe",
373+ &[("content-type", "application/json-patch+json")],
374+ r#"[{"op": "test", "path": "/girth", "value": 1.0}]"#,
375+ )
376+ .await;
377+ assert_eq!(patch.status(), StatusCode::CONFLICT);
378+ assert_eq!(header(&patch, "x-eq"), "47");
379+}
380+
381+#[tokio::test]
382+async fn a_combo_chafes_twice_and_costs_two_stamina() {
383+ let app = seeded_with(Floor::Hotel, |run| {
384+ run.here.wifi_accepted = true;
385+ run.body.warmed_up = true;
386+ run.body.eq = 55;
387+ run.body.foreskin = Some(0.5);
388+ });
389+ let spit = [("content-type", "text/plain; charset=spit")];
390+ let combo = send(
391+ &app,
392+ "JELQ",
393+ "/runs/cafebabe?technique=wet,stretch",
394+ &spit,
395+ "",
396+ )
397+ .await;
398+ assert_eq!(header(&combo, "x-friction"), "2/10");
399+ assert_eq!(header(&combo, "x-stamina"), "8/10");
400+}
Added file: src/tests/floors.rs+456 additions
@@ -0,0 +1,456 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn only_the_key_opens_the_cage() {
5+ let app = seeded_with(Floor::ChangingRoom, |run| run.items.kit.cage = true);
6+ send(&app, "POST", "/runs/cafebabe/shop/ice-pack", &[], "").await;
7+ let still = send(
8+ &app,
9+ "GET",
10+ "/runs/cafebabe/inventory/chastity-cage",
11+ &[],
12+ "",
13+ )
14+ .await;
15+ assert_eq!(still.status(), StatusCode::OK);
16+ send(&app, "POST", "/runs/cafebabe/shop/cage-key", &[], "").await;
17+ let gone = send(
18+ &app,
19+ "GET",
20+ "/runs/cafebabe/inventory/chastity-cage",
21+ &[],
22+ "",
23+ )
24+ .await;
25+ assert_eq!(gone.status(), StatusCode::NOT_FOUND);
26+}
27+
28+#[tokio::test]
29+async fn pump_bro_upsells() {
30+ let closed = seeded_with(Floor::Bedroom, |_| {});
31+ let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
32+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
33+
34+ let app = seeded_with(Floor::ChangingRoom, |_| {});
35+ let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
36+ assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
37+ assert_eq!(header(&pump, "upgrade"), "pump-pro/2.0");
38+ let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
39+ assert_eq!(pro.status(), StatusCode::CREATED);
40+ let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
41+ assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
42+}
43+
44+#[tokio::test]
45+async fn the_elder_is_stale() {
46+ let app = seeded_with(Floor::Forum, |run| run.body.length = 5.0);
47+ let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
48+ assert_eq!(
49+ unverified.status(),
50+ StatusCode::PROXY_AUTHENTICATION_REQUIRED
51+ );
52+ let lie = send(
53+ &app,
54+ "GET",
55+ "/runs/cafebabe/elder",
56+ &[("proxy-authorization", "Ruler 9")],
57+ "",
58+ )
59+ .await;
60+ assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
61+ let csv = send(
62+ &app,
63+ "GET",
64+ "/runs/cafebabe/elder",
65+ &[("proxy-authorization", "Ruler 5.0")],
66+ "",
67+ )
68+ .await;
69+ assert_eq!(csv.status(), StatusCode::OK);
70+ let stale = send(
71+ &app,
72+ "GET",
73+ "/runs/cafebabe/elder",
74+ &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
75+ "",
76+ )
77+ .await;
78+ assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
79+}
80+
81+#[tokio::test]
82+async fn the_hotel_has_a_captive_portal() {
83+ let app = seeded_with(Floor::Hotel, |_| {});
84+ let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
85+ assert_eq!(
86+ blocked.status(),
87+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED
88+ );
89+ let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
90+ assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
91+ let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
92+ assert_eq!(online.status(), StatusCode::OK);
93+}
94+
95+#[tokio::test]
96+async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {
97+ let app = seeded_with(Floor::ChangingRoom, |run| run.items.kit.cage = true);
98+ let mut statuses = Vec::new();
99+ for _ in 0..30 {
100+ let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
101+ statuses.push(response.status());
102+ if response.status() == StatusCode::OK {
103+ break;
104+ }
105+ }
106+ assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");
107+ assert!(
108+ statuses[..statuses.len() - 1]
109+ .iter()
110+ .all(|s| *s == StatusCode::NO_CONTENT)
111+ );
112+}
113+
114+#[tokio::test]
115+async fn an_open_tab_is_linked_and_jelq_still_answers_with_choices() {
116+ let app = seeded_with(Floor::Bathroom, |run| {
117+ run.online.story = Story::Pending;
118+ run.body.warmed_up = true;
119+ });
120+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
121+ let links = header(&response, "link");
122+ assert!(links.contains("/branches/forum>; rel=\"https://jelq.dev/rels/branch\""));
123+ assert!(links.contains("/branches/close-tab>; rel=\"https://jelq.dev/rels/branch\""));
124+ assert!(links.contains("?technique=wet>"));
125+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
126+ assert_eq!(jelq.status(), StatusCode::MULTIPLE_CHOICES);
127+}
128+
129+#[tokio::test]
130+async fn either_story_choice_is_reachable_during_cooldown() {
131+ for choice in ["forum", "close-tab"] {
132+ let app = seeded_with(Floor::Bathroom, |run| {
133+ run.turn = 9;
134+ run.body.warmed_up = true;
135+ run.body.eq = 45;
136+ run.body.stamina = 1;
137+ run.items.stores.door_locks = 1;
138+ });
139+ let tenth = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
140+ assert_eq!(tenth.status(), StatusCode::OK);
141+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
142+ assert!(!header(&response, "link").contains("?technique="));
143+ let path = format!("/runs/cafebabe/branches/{choice}");
144+ assert!(
145+ header(&response, "link")
146+ .contains(&format!("<{path}>; rel=\"https://jelq.dev/rels/branch\""))
147+ );
148+ let chosen = send(&app, "POST", &path, &[], "").await;
149+ assert_eq!(chosen.status(), StatusCode::SEE_OTHER);
150+ assert!(!header(&chosen, "link").contains("/branches/"));
151+ let repeated = send(&app, "POST", &path, &[], "").await;
152+ assert_eq!(repeated.status(), StatusCode::CONFLICT);
153+ }
154+}
155+
156+#[tokio::test]
157+async fn the_thread_announces_itself_at_the_branch() {
158+ let app = seeded_with(Floor::Bathroom, |run| {
159+ run.turn = 8;
160+ run.body.warmed_up = true;
161+ run.items.lubes.baby_oil = 9;
162+ });
163+ send(
164+ &app,
165+ "JELQ",
166+ "/runs/cafebabe?technique=wet",
167+ &[("content-type", "application/baby-oil")],
168+ "",
169+ )
170+ .await;
171+ let ninth = send(&app, "GET", "/runs/cafebabe", &[], "").await;
172+ assert!(!header(&ninth, "link").contains("/forum/threads/2009"));
173+ let tenth = send(
174+ &app,
175+ "JELQ",
176+ "/runs/cafebabe?technique=wet",
177+ &[("content-type", "application/baby-oil")],
178+ "",
179+ )
180+ .await;
181+ assert!(
182+ tenth
183+ .headers()
184+ .get_all("warning")
185+ .iter()
186+ .any(|w| w.to_str().unwrap().contains("a forum thread from 2009"))
187+ );
188+ assert!(header(&tenth, "link").contains("/forum/threads/2009"));
189+ let announced = send(&app, "GET", "/runs/cafebabe", &[], "").await;
190+ assert!(header(&announced, "link").contains("/forum/threads/2009"));
191+}
192+
193+#[tokio::test]
194+async fn rereading_the_thread_teaches_uli() {
195+ let mut taught_runs = 0;
196+ for seed in 0..100u64 {
197+ let app = app_with(Floor::Bedroom, seed, |run| {
198+ run.turn = BRANCH_TURN;
199+ run.body.warmed_up = true;
200+ run.online.story = Story::ClosedTab;
201+ });
202+ let mut taught = false;
203+ for _ in 0..2 {
204+ let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
205+ match read.status() {
206+ StatusCode::NOT_MODIFIED => {
207+ assert!(header(&read, "warning").contains("2009"));
208+ }
209+ StatusCode::OK => {
210+ assert!(header(&read, "location").contains("/techniques"));
211+ taught = true;
212+ break;
213+ }
214+ other => panic!("seed {seed} read the thread into {other}"),
215+ }
216+ }
217+ let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
218+ assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
219+ if taught {
220+ taught_runs += 1;
221+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
222+ assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
223+ } else {
224+ let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
225+ assert_eq!(cached.status(), StatusCode::OK);
226+ assert!(header(&cached, "warning").starts_with("112"));
227+ assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);
228+ }
229+ }
230+ assert!(
231+ (30..=58).contains(&taught_runs),
232+ "{taught_runs}/100 learned uli in two reads"
233+ );
234+}
235+
236+#[tokio::test]
237+async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {
238+ let app = seeded_with(Floor::Bedroom, |run| run.body.warmed_up = true);
239+ let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
240+ assert_eq!(read.status(), StatusCode::NOT_FOUND);
241+ let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
242+ assert!(!header(&after, "link").contains("/re-read"));
243+}
244+
245+#[tokio::test]
246+async fn rummaging_for_the_key_leaves_the_drawer_unsearched() {
247+ let mut found = 0;
248+ for seed in 0..20u64 {
249+ let app = app_with(Floor::Hotel, seed, |run| {
250+ run.here.wifi_accepted = true;
251+ run.items.kit.cage = true;
252+ });
253+ for _ in 0..30 {
254+ let rummage = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
255+ if rummage.status() == StatusCode::OK {
256+ break;
257+ }
258+ }
259+ let search = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
260+ if search.status() == StatusCode::FOUND {
261+ assert!(header(&search, "location").ends_with("/inventory/do-not-disturb-sign"));
262+ found += 1;
263+ }
264+ }
265+ assert!(
266+ found >= 18,
267+ "only {found} of 20 hotel drawers survived the rummage"
268+ );
269+}
270+
271+#[tokio::test]
272+async fn kev_is_the_bar_in_the_changing_room() {
273+ for (length, shamed) in [(5.0, true), (7.0, false)] {
274+ let app = seeded_with(Floor::Bathroom, |run| {
275+ run.body.length = length;
276+ run.here.turns = 6;
277+ });
278+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
279+ assert_eq!(header(&left, "x-floor"), "3");
280+ let warning = left
281+ .headers()
282+ .get("warning")
283+ .map(|w| w.to_str().unwrap().to_owned());
284+ assert_eq!(
285+ warning.is_some_and(|w| w.contains("Kev")),
286+ shamed,
287+ "length {length}"
288+ );
289+ }
290+}
291+
292+#[tokio::test]
293+async fn pump_bro_charges_the_thin_more_for_girth() {
294+ for (girth, ring, oil) in [(4.5, 11, 4), (5.2, 8, 4)] {
295+ let app = seeded_with(Floor::ChangingRoom, |run| run.body.girth = girth);
296+ let shop = json(send(&app, "GET", "/runs/cafebabe/shop", &[], "").await).await;
297+ let price = |slug: &str| {
298+ shop["stock"]
299+ .as_array()
300+ .unwrap()
301+ .iter()
302+ .find(|item| item["item"] == slug)
303+ .unwrap()["price"]
304+ .as_u64()
305+ .unwrap()
306+ };
307+ assert_eq!(price("cock-ring"), ring, "girth {girth}");
308+ assert_eq!(price("baby-oil"), oil, "girth {girth}");
309+ }
310+}
311+
312+#[tokio::test]
313+async fn the_clinic_escalates_toward_honesty() {
314+ let Harness { app, .. } = harness(Floor::Clinic, SEED, |_| {});
315+ let mut hints = Vec::new();
316+ for _ in 0..3 {
317+ let response = send(
318+ &app,
319+ "GET",
320+ "/runs/cafebabe/diagnosis",
321+ &[("authorization", "Excuse \"changing\"")],
322+ "",
323+ )
324+ .await;
325+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
326+ hints.push(header(&response, "www-authenticate").to_owned());
327+ }
328+ assert!(hints[2].contains("Be honest."));
329+}
330+
331+#[tokio::test]
332+async fn the_true_ending_is_not_modified() {
333+ let Harness { app: early, .. } = harness(Floor::Bedroom, SEED, |_| {});
334+ let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
335+ assert_eq!(response.status(), StatusCode::TOO_EARLY);
336+
337+ let Harness {
338+ app, origin: etag, ..
339+ } = harness(Floor::Clinic, SEED, |_| {});
340+ assert!(
341+ !etag.contains("cafebabe"),
342+ "the origin ETag must not be derivable from the id"
343+ );
344+ let response = send(
345+ &app,
346+ "GET",
347+ "/runs/cafebabe/diagnosis",
348+ &[
349+ ("authorization", "Bearer honesty"),
350+ ("if-none-match", &etag),
351+ ],
352+ "",
353+ )
354+ .await;
355+ assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
356+ assert_eq!(header(&response, "x-diagnosis"), "normal");
357+}
358+
359+#[tokio::test]
360+async fn the_elder_bans_three_lies_unless_the_girth_impresses() {
361+ let forum = |girth: f64| {
362+ seeded_with(Floor::Forum, move |run| {
363+ run.body.length = 6.0;
364+ run.body.girth = girth;
365+ })
366+ };
367+ let fib = [("proxy-authorization", "Ruler 6.5")];
368+ let thin = forum(4.5);
369+ for _ in 0..2 {
370+ let strike = send(&thin, "GET", "/runs/cafebabe/elder", &fib, "").await;
371+ assert_eq!(strike.status(), StatusCode::PROXY_AUTHENTICATION_REQUIRED);
372+ }
373+ let banned = send(&thin, "GET", "/runs/cafebabe/elder", &fib, "").await;
374+ assert_eq!(banned.status(), StatusCode::FORBIDDEN);
375+ assert!(
376+ json(banned).await["type"]
377+ .as_str()
378+ .unwrap()
379+ .ends_with("banned")
380+ );
381+
382+ let thick = forum(5.2);
383+ for _ in 0..4 {
384+ let excused = send(&thick, "GET", "/runs/cafebabe/elder", &fib, "").await;
385+ assert_eq!(excused.status(), StatusCode::PROXY_AUTHENTICATION_REQUIRED);
386+ assert!(header(&excused, "x-forum-note").contains("Girth is also a dimension"));
387+ }
388+}
389+
390+#[tokio::test]
391+async fn the_zip_ends_one_exit_in_two_hundred() {
392+ let mut zipped = 0;
393+ for seed in 0..4000u64 {
394+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
395+ run.here.turns = 6;
396+ if run.advance().is_err() && run.ended.is_some() {
397+ zipped += 1;
398+ }
399+ }
400+ assert!(
401+ (8..=35).contains(&zipped),
402+ "{zipped} zips in 4000 exits, about 20 expected"
403+ );
404+}
405+
406+#[tokio::test]
407+async fn a_prescription_ends_the_run_and_the_true_ending_still_waits() {
408+ let Harness { app, origin, .. } = harness(Floor::Clinic, SEED, |_| {});
409+ let honest = [("authorization", "Bearer honesty")];
410+ let prescribed = send(&app, "GET", "/runs/cafebabe/diagnosis", &honest, "").await;
411+ assert_eq!(prescribed.status(), StatusCode::OK);
412+ assert!(header(&prescribed, "server-timing").contains("volume;"));
413+ assert!(
414+ json(prescribed).await["detail"]
415+ .as_str()
416+ .unwrap()
417+ .starts_with("Congratulations")
418+ );
419+ let board = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
420+ assert_eq!(board["entries"][0]["status"], "prescribed");
421+
422+ let conditional = [
423+ ("authorization", "Bearer honesty"),
424+ ("if-none-match", origin.as_str()),
425+ ];
426+ let cured = send(&app, "GET", "/runs/cafebabe/diagnosis", &conditional, "").await;
427+ assert_eq!(cured.status(), StatusCode::NOT_MODIFIED);
428+ let view = json(send(&app, "GET", "/runs/cafebabe", &[], "").await).await;
429+ assert_eq!(view["status"], "normal");
430+}
431+
432+#[tokio::test]
433+async fn the_foreskin_window_has_hard_edges() {
434+ for (foreskin, refused) in [
435+ (0.79, Some(StatusCode::LENGTH_REQUIRED)),
436+ (0.8, None),
437+ (2.0, None),
438+ (2.01, Some(StatusCode::PAYLOAD_TOO_LARGE)),
439+ ] {
440+ let app = seeded_with(Floor::Hotel, |run| {
441+ run.here.wifi_accepted = true;
442+ run.body.warmed_up = true;
443+ run.body.eq = 55;
444+ run.body.foreskin = Some(foreskin);
445+ });
446+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
447+ match refused {
448+ Some(status) => assert_eq!(dry.status(), status, "foreskin {foreskin}"),
449+ None => assert!(
450+ ![StatusCode::LENGTH_REQUIRED, StatusCode::PAYLOAD_TOO_LARGE]
451+ .contains(&dry.status()),
452+ "foreskin {foreskin} sits inside the window"
453+ ),
454+ }
455+ }
456+}
Added file: src/tests/household.rs+339 additions
@@ -0,0 +1,339 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn mum_accepts_a_known_excuse() {
5+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);
6+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
7+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
8+ assert_eq!(header(&response, "via"), "1.1 mum");
9+ let response = send(
10+ &app,
11+ "GET",
12+ "/runs/cafebabe",
13+ &[("authorization", "Excuse \"homework\"")],
14+ "",
15+ )
16+ .await;
17+ assert_eq!(response.status(), StatusCode::OK);
18+}
19+
20+#[tokio::test]
21+async fn mum_runs_out_of_patience() {
22+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);
23+ let lie = [("authorization", "Excuse \"it is for science\"")];
24+ for _ in 0..2 {
25+ let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
26+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
27+ }
28+ let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
29+ assert_eq!(caught.status(), StatusCode::GONE);
30+ assert_eq!(
31+ json(caught).await["type"],
32+ "https://jelq.dev/problems/caught"
33+ );
34+}
35+
36+#[tokio::test]
37+async fn mum_remembers_excuses_by_christmas() {
38+ let app = seeded_with(Floor::Nans, |run| {
39+ mum_at_the_door(run);
40+ run.household.heard.push(Excuse::Homework);
41+ });
42+ let basic = send(
43+ &app,
44+ "GET",
45+ "/runs/cafebabe",
46+ &[("authorization", "Excuse \"changing\"")],
47+ "",
48+ )
49+ .await;
50+ assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
51+ assert!(header(&basic, "www-authenticate").starts_with("Digest"));
52+ let replay = send(
53+ &app,
54+ "GET",
55+ "/runs/cafebabe",
56+ &[(
57+ "authorization",
58+ "Digest username=\"homework\", nonce=\"abc\"",
59+ )],
60+ "",
61+ )
62+ .await;
63+ assert_eq!(
64+ json(replay).await["type"],
65+ "https://jelq.dev/problems/excuse-replayed"
66+ );
67+ let fresh = send(
68+ &app,
69+ "GET",
70+ "/runs/cafebabe",
71+ &[(
72+ "authorization",
73+ "Digest username=\"wrapping presents\", nonce=\"abc\"",
74+ )],
75+ "",
76+ )
77+ .await;
78+ assert_eq!(fresh.status(), StatusCode::OK);
79+}
80+
81+#[tokio::test]
82+async fn a_pulled_muscle_is_a_true_excuse() {
83+ let app = seeded_with(Floor::Nans, |run| {
84+ mum_at_the_door(run);
85+ run.body.conditions.pulled_muscle = true;
86+ run.household.heard.push(Excuse::PulledAMuscle);
87+ });
88+ let response = send(
89+ &app,
90+ "GET",
91+ "/runs/cafebabe",
92+ &[(
93+ "authorization",
94+ "Digest username=\"pulled a muscle\", nonce=\"abc\"",
95+ )],
96+ "",
97+ )
98+ .await;
99+ assert_eq!(response.status(), StatusCode::OK);
100+}
101+
102+#[tokio::test]
103+async fn ignoring_mum_has_consequences() {
104+ let app = seeded_with(Floor::Bedroom, |run| {
105+ mum_at_the_door(run);
106+ run.here.turns = 6;
107+ });
108+ let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;
109+ assert_eq!(
110+ json(knock).await["type"],
111+ "https://jelq.dev/problems/someone-at-the-door"
112+ );
113+ let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
114+ assert_eq!(
115+ leave.status(),
116+ StatusCode::UNAUTHORIZED,
117+ "no leaving with Mum at the door"
118+ );
119+ send(&app, "GET", "/runs/cafebabe", &[], "").await;
120+ let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;
121+ assert_eq!(opened.status(), StatusCode::GONE);
122+ assert_eq!(
123+ json(opened).await["type"],
124+ "https://jelq.dev/problems/opened"
125+ );
126+}
127+
128+#[tokio::test]
129+async fn mom_speaks_american() {
130+ let app = seeded_with(Floor::Nans, mum_at_the_door);
131+ let us = [("accept-language", "en-US")];
132+ let knock = send(&app, "GET", "/runs/cafebabe", &us, "").await;
133+ assert_eq!(header(&knock, "content-language"), "en-US");
134+ assert_eq!(header(&knock, "via"), "1.1 mom");
135+ assert!(
136+ json(knock).await["detail"]
137+ .as_str()
138+ .unwrap()
139+ .contains("Someone is at the door.")
140+ );
141+ let excuse = [
142+ ("accept-language", "en-US"),
143+ (
144+ "authorization",
145+ "Digest username=\"carving the turkey\", nonce=\"abc\"",
146+ ),
147+ ];
148+ let answered = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
149+ assert_eq!(answered.status(), StatusCode::OK);
150+ let floor = json(send(&app, "GET", "/runs/cafebabe/floor", &us, "").await).await;
151+ assert_eq!(floor["name"], "Grandma's house for Thanksgiving");
152+}
153+
154+#[tokio::test]
155+async fn an_empty_bottle_is_declined_before_the_enemy_stirs() {
156+ let control = seeded_with(Floor::ChangingRoom, |run| {
157+ run.body.warmed_up = true;
158+ run.standing.suspicion = SUSPICION_LIMIT;
159+ });
160+ let lube = [("content-type", "application/baby-oil")];
161+ let caught = send(
162+ &control,
163+ "JELQ",
164+ "/runs/cafebabe?technique=wet&intensity=5",
165+ &lube,
166+ "",
167+ )
168+ .await;
169+ assert_eq!(caught.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
170+
171+ let app = seeded_with(Floor::ChangingRoom, |run| {
172+ run.body.warmed_up = true;
173+ run.standing.suspicion = SUSPICION_LIMIT;
174+ run.items.lubes.baby_oil = 0;
175+ });
176+ let refused = send(
177+ &app,
178+ "JELQ",
179+ "/runs/cafebabe?technique=wet&intensity=5",
180+ &lube,
181+ "",
182+ )
183+ .await;
184+ assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
185+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
186+ assert_eq!(header(&view, "x-suspicion"), "5/5");
187+ assert_eq!(header(&view, "x-shame"), "0");
188+}
189+
190+#[tokio::test]
191+async fn respectable_girth_waves_off_the_gym_staff() {
192+ let staff = |girth: f64| {
193+ seeded_with(Floor::ChangingRoom, move |run| {
194+ run.body.warmed_up = true;
195+ run.body.eq = 45;
196+ run.body.girth = girth;
197+ run.standing.suspicion = SUSPICION_LIMIT;
198+ })
199+ };
200+ let thin = send(
201+ &staff(4.5),
202+ "JELQ",
203+ "/runs/cafebabe?technique=edge",
204+ &[],
205+ "",
206+ )
207+ .await;
208+ assert_eq!(thin.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
209+ assert_eq!(header(&thin, "x-shame"), "5");
210+
211+ let thick = send(
212+ &staff(5.2),
213+ "JELQ",
214+ "/runs/cafebabe?technique=edge",
215+ &[],
216+ "",
217+ )
218+ .await;
219+ assert_eq!(thick.status(), StatusCode::OK);
220+ assert!(
221+ warnings(&thick)
222+ .iter()
223+ .any(|w| w.contains("the gym staff have seen worse"))
224+ );
225+}
226+
227+#[tokio::test]
228+async fn the_kitchen_is_only_on_the_home_floors() {
229+ let absent = seeded_with(Floor::Bathroom, |_| {});
230+ let no_kitchen = send(&absent, "POST", "/runs/cafebabe/kitchen", &[], "").await;
231+ assert_eq!(no_kitchen.status(), StatusCode::NOT_FOUND);
232+ let view = send(&absent, "GET", "/runs/cafebabe", &[], "").await;
233+ assert!(!header(&view, "link").contains("/kitchen"));
234+
235+ let app = seeded_with(Floor::Bedroom, |_| {});
236+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
237+ assert!(header(&view, "link").contains("/kitchen"));
238+ let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
239+ assert_ne!(raid.status(), StatusCode::NOT_FOUND);
240+}
241+
242+#[tokio::test]
243+async fn any_other_action_takes_the_melon_off_the_table() {
244+ for (method, uri) in [
245+ ("JELQ", "/runs/cafebabe?technique=warmup"),
246+ ("POST", "/runs/cafebabe/drawer"),
247+ ] {
248+ let app = seeded_with(Floor::Bedroom, |run| {
249+ run.body.eq = 95;
250+ run.here.kitchen.melon_offered = true;
251+ });
252+ send(&app, method, uri, &[], "").await;
253+ let melon = send(&app, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
254+ assert_eq!(melon.status(), StatusCode::CONFLICT, "{method} {uri}");
255+ }
256+}
257+
258+#[tokio::test]
259+async fn the_truth_works_under_any_scheme_and_costs_ten() {
260+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);
261+ let truth = [("authorization", "Whatever honesty")];
262+ let confessed = send(&app, "GET", "/runs/cafebabe", &truth, "").await;
263+ assert_eq!(confessed.status(), StatusCode::OK);
264+ assert_eq!(header(&confessed, "x-shame"), "10");
265+}
266+
267+#[tokio::test]
268+async fn mum_refuses_basic_and_stale_nonces() {
269+ let bedroom = seeded_with(Floor::Bedroom, mum_at_the_door);
270+ let basic = send(
271+ &bedroom,
272+ "GET",
273+ "/runs/cafebabe",
274+ &[("authorization", "Basic aG9tZXdvcms=")],
275+ "",
276+ )
277+ .await;
278+ assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
279+ assert!(
280+ json(basic).await["type"]
281+ .as_str()
282+ .unwrap()
283+ .ends_with("/basic")
284+ );
285+
286+ let nans = seeded_with(Floor::Nans, mum_at_the_door);
287+ let stale = [(
288+ "authorization",
289+ "Digest username=\"on a call\", nonce=\"yesterday\"",
290+ )];
291+ let refused = send(&nans, "GET", "/runs/cafebabe", &stale, "").await;
292+ assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
293+ assert!(
294+ json(refused).await["type"]
295+ .as_str()
296+ .unwrap()
297+ .ends_with("stale-nonce")
298+ );
299+}
300+
301+#[tokio::test]
302+async fn american_players_can_say_studying() {
303+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);
304+ let excuse = [
305+ ("accept-language", "en-US"),
306+ ("authorization", "Excuse \"Studying\""),
307+ ];
308+ let response = send(&app, "GET", "/runs/cafebabe", &excuse, "").await;
309+ assert_eq!(response.status(), StatusCode::OK);
310+}
311+
312+#[tokio::test]
313+async fn raids_get_noisier_and_the_household_notices() {
314+ let told = |suspicion: u8| {
315+ (0..400u64)
316+ .filter(|&seed| {
317+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
318+ run.body.eq = 40;
319+ run.standing.suspicion = suspicion;
320+ run.raid_kitchen().is_ok() && run.household.kitchen_tellings == 1
321+ })
322+ .count()
323+ };
324+ let quiet = told(0);
325+ let noisy = told(4);
326+ assert!(
327+ (60..=150).contains(&quiet),
328+ "a quiet house told {quiet} of 400 raids, about 100 expected"
329+ );
330+ assert!(
331+ (240..=340).contains(&noisy),
332+ "a noisy house told {noisy} of 400 raids, about 300 expected"
333+ );
334+
335+ let mut run = Run::new("cafebabe".into(), "cafe".into(), SEED);
336+ run.body.eq = 40;
337+ assert!(run.raid_kitchen().is_ok());
338+ assert_eq!(run.standing.suspicion, 1, "every raid makes a little noise");
339+}
Added file: src/tests/meta.rs+252 additions
@@ -0,0 +1,252 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn forged_ruler_cookies_are_lies() {
5+ let forged = send(
6+ &app(),
7+ "GET",
8+ "/leaderboard?sort=content-length",
9+ &[("cookie", "ruler=1")],
10+ "",
11+ )
12+ .await;
13+ assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
14+}
15+
16+#[tokio::test]
17+async fn listings_never_reveal_the_private_id() {
18+ let app = app();
19+ let created = send(&app, "POST", "/runs", &[], "").await;
20+ let location = header(&created, "location").to_owned();
21+ let board = text(send(&app, "GET", "/leaderboard", &[], "").await).await;
22+ let private = location.trim_start_matches("/runs/");
23+ assert!(!board.contains(private));
24+ let entry: Value = serde_json::from_str(&board).unwrap();
25+ let public = entry["entries"][0]["public_id"].as_str().unwrap();
26+ let guess = send(&app, "DELETE", &format!("/runs/{public}"), &[], "").await;
27+ assert_eq!(guess.status(), StatusCode::NOT_FOUND);
28+}
29+
30+#[tokio::test]
31+async fn seeded_runs_are_unranked() {
32+ let app = app();
33+ send(&app, "POST", "/runs?seed=7", &[], "").await;
34+ let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
35+ assert_eq!(board["entries"].as_array().unwrap().len(), 0);
36+ let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
37+ assert_eq!(all["entries"][0]["ranked"], false);
38+}
39+
40+#[tokio::test]
41+async fn idle_runs_are_evicted_and_the_server_has_a_limit() {
42+ let state = Arc::new(
43+ AppState::new(Config {
44+ idle_ttl: Duration::ZERO,
45+ ..Config::default()
46+ })
47+ .unwrap(),
48+ );
49+ let forgetful = router(Arc::clone(&state));
50+ let first = send(&forgetful, "POST", "/runs", &[], "").await;
51+ let location = header(&first, "location").to_owned();
52+ state.sweep(std::time::Instant::now()).unwrap();
53+ let gone = send(&forgetful, "GET", &location, &[], "").await;
54+ assert_eq!(gone.status(), StatusCode::NOT_FOUND);
55+ let board = json(send(&forgetful, "GET", "/leaderboard", &[], "").await).await;
56+ assert_eq!(
57+ board["entries"][0]["status"], "alive",
58+ "a swept run keeps its place on the leaderboard"
59+ );
60+
61+ let tiny = crate::app(Config {
62+ max_runs: 1,
63+ ..Config::default()
64+ })
65+ .unwrap();
66+ send(&tiny, "POST", "/runs", &[], "").await;
67+ let full = send(&tiny, "POST", "/runs", &[], "").await;
68+ assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
69+}
70+
71+#[tokio::test]
72+async fn shame_from_two_runs_adds_up() {
73+ let state = Arc::new(AppState::default());
74+ state.insert(Run::new("cafebabe".into(), "cafe".into(), SEED));
75+ state.insert(Run::new("deadbeef".into(), "dead".into(), 7));
76+ let app = router(state);
77+ let first = send(&app, "TRACE", "/runs/cafebabe", &[], "").await;
78+ assert_eq!(header(&first, "x-shame"), "1");
79+ let cookie = header(&first, "set-cookie")
80+ .split(';')
81+ .next()
82+ .unwrap()
83+ .to_owned();
84+ let second = send(&app, "TRACE", "/runs/deadbeef", &[("cookie", &cookie)], "").await;
85+ assert_eq!(header(&second, "x-shame"), "2");
86+}
87+
88+#[tokio::test]
89+async fn the_root_negotiates() {
90+ let app = app();
91+ let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
92+ assert_eq!(browser.status(), StatusCode::SEE_OTHER);
93+ assert_eq!(header(&browser, "location"), "/ui/");
94+ let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
95+ assert_eq!(json(curl).await["ui"], "/ui/");
96+ let page = send(&app, "GET", "/ui/", &[], "").await;
97+ assert_eq!(page.status(), StatusCode::OK);
98+}
99+
100+#[tokio::test]
101+async fn the_ui_replays_what_browsers_cannot_send() {
102+ let app = app();
103+ let envelope =
104+ r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
105+ let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
106+ assert_eq!(traced["status"], 405);
107+ let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
108+ let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
109+ assert_eq!(redirected["status"], 303);
110+ assert_eq!(redirected["reason"], "See Other");
111+}
112+
113+#[tokio::test]
114+async fn the_ui_decodes_gzip_for_display() {
115+ let app = seeded_with(Floor::Bedroom, |run| run.items.kit.ring = true);
116+ let envelope =
117+ r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
118+ let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
119+ assert!(
120+ shown["body"]
121+ .as_str()
122+ .unwrap()
123+ .contains("\"public_id\":\"cafe\"")
124+ );
125+ let encoding = shown["headers"]
126+ .as_array()
127+ .unwrap()
128+ .iter()
129+ .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
130+ assert!(encoding);
131+}
132+
133+#[tokio::test]
134+async fn the_default_locale_is_british() {
135+ let Harness { app, .. } = harness(Floor::Nans, SEED, |_| {});
136+ let floor = send(&app, "GET", "/runs/cafebabe/floor", &[], "").await;
137+ assert_eq!(header(&floor, "content-language"), "en-GB");
138+ assert!(header(&floor, "vary").contains("Accept-Language"));
139+ assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
140+}
141+
142+#[tokio::test]
143+async fn a_full_server_buries_the_dead_to_make_room() {
144+ let app = crate::app(Config {
145+ max_runs: 1,
146+ ..Config::default()
147+ })
148+ .unwrap();
149+ let first = send(&app, "POST", "/runs", &[], "").await;
150+ let location = header(&first, "location").to_owned();
151+ let full = send(&app, "POST", "/runs", &[], "").await;
152+ assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
153+ send(&app, "DELETE", &location, &[], "").await;
154+ let second = send(&app, "POST", "/runs", &[], "").await;
155+ assert_eq!(second.status(), StatusCode::CREATED);
156+}
157+
158+#[tokio::test]
159+async fn the_forum_respects_volume() {
160+ let state = Arc::new(AppState::default());
161+ for (id, length, girth) in [("slim", 7.0, 4.0), ("stout", 5.0, 5.5)] {
162+ let mut run = Run::new(id.into(), id.into(), 1);
163+ run.body.length = length;
164+ run.body.girth = girth;
165+ state.insert(run);
166+ }
167+ let app = router(state);
168+ let board = json(
169+ send(
170+ &app,
171+ "GET",
172+ "/leaderboard?sort=volume&seeded=include",
173+ &[],
174+ "",
175+ )
176+ .await,
177+ )
178+ .await;
179+ assert_eq!(board["entries"][0]["public_id"], "stout");
180+ assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);
181+}
182+
183+#[tokio::test]
184+async fn the_dead_outlive_the_server() {
185+ let path = std::env::temp_dir().join(format!("jelqd-{}.sqlite", std::process::id()));
186+ let config = || Config {
187+ db: Some(path.clone()),
188+ ..Config::default()
189+ };
190+ let first = crate::app(config()).unwrap();
191+ let created = send(&first, "POST", "/runs", &[], "").await;
192+ let location = header(&created, "location").to_owned();
193+ send(&first, "DELETE", &location, &[], "").await;
194+ drop(first);
195+
196+ let second = crate::app(config()).unwrap();
197+ let gone = send(&second, "GET", &location, &[], "").await;
198+ assert_eq!(gone.status(), StatusCode::GONE);
199+ let graveyard = json(send(&second, "GET", "/graveyard", &[], "").await).await;
200+ assert_eq!(graveyard["runs"].as_array().unwrap().len(), 1);
201+ let board = json(send(&second, "GET", "/leaderboard", &[], "").await).await;
202+ assert_eq!(board["entries"][0]["status"], "gone");
203+ for suffix in ["", "-wal", "-shm"] {
204+ let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
205+ }
206+}
207+
208+#[tokio::test]
209+async fn the_graveyard_pages_by_fifty() {
210+ let app = app();
211+ for _ in 0..51 {
212+ let created = send(&app, "POST", "/runs", &[], "").await;
213+ let location = header(&created, "location").to_owned();
214+ send(&app, "DELETE", &location, &[], "").await;
215+ }
216+ let first = send(&app, "GET", "/graveyard", &[], "").await;
217+ assert!(header(&first, "link").contains("page=2>; rel=\"next\""));
218+ assert_eq!(json(first).await["runs"].as_array().unwrap().len(), 50);
219+ let second = send(&app, "GET", "/graveyard?page=2", &[], "").await;
220+ assert!(header(&second, "link").contains("page=1>; rel=\"prev\""));
221+ assert_eq!(json(second).await["runs"].as_array().unwrap().len(), 1);
222+}
223+
224+#[tokio::test]
225+async fn a_prescribed_run_outlives_the_server() {
226+ let path = std::env::temp_dir().join(format!("jelqd-prescribed-{}.sqlite", std::process::id()));
227+ let config = || Config {
228+ db: Some(path.clone()),
229+ ..Config::default()
230+ };
231+ let state = Arc::new(AppState::new(config()).unwrap());
232+ let mut run = Run::new("cafebabe".into(), "cafe".into(), SEED);
233+ run.floor = Floor::Clinic;
234+ state.insert(run);
235+ let first = router(state);
236+ send(
237+ &first,
238+ "GET",
239+ "/runs/cafebabe/diagnosis",
240+ &[("authorization", "Bearer honesty")],
241+ "",
242+ )
243+ .await;
244+ drop(first);
245+
246+ let second = crate::app(config()).unwrap();
247+ let board = json(send(&second, "GET", "/leaderboard?seeded=include", &[], "").await).await;
248+ assert_eq!(board["entries"][0]["status"], "prescribed");
249+ for suffix in ["", "-wal", "-shm"] {
250+ let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
251+ }
252+}
Added file: src/tests/methods.rs+150 additions
@@ -0,0 +1,150 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn creating_a_run_returns_location_and_etag() {
5+ let response = send(&app(), "POST", "/runs?seed=1", &[], "").await;
6+ assert_eq!(response.status(), StatusCode::CREATED);
7+ assert!(header(&response, "location").starts_with("/runs/"));
8+ assert!(response.headers().contains_key(header::ETAG));
9+ assert_eq!(header(&response, "server"), "jelqd/0.6.9");
10+}
11+
12+#[tokio::test]
13+async fn bodies_do_not_repeat_the_headers() {
14+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
15+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
16+ assert!(response.headers().contains_key("x-eq"));
17+ let body = json(response).await;
18+ for stat in [
19+ "eq",
20+ "girth",
21+ "foreskin",
22+ "stamina",
23+ "cash",
24+ "self_reported_length",
25+ ] {
26+ assert!(body.get(stat).is_none(), "{stat} is already a header");
27+ }
28+}
29+
30+#[tokio::test]
31+async fn jelqing_cold_fails_the_precondition() {
32+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
33+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
34+ assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
35+ assert_eq!(
36+ header(&response, "content-type"),
37+ "application/problem+json"
38+ );
39+}
40+
41+#[tokio::test]
42+async fn wet_without_lube_is_unsupported_media() {
43+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
44+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
45+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
46+ assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
47+ let images = send(
48+ &app,
49+ "JELQ",
50+ "/runs/cafebabe?technique=wet",
51+ &[("content-type", "image/jpeg")],
52+ "",
53+ )
54+ .await;
55+ assert_eq!(json(images).await["detail"], "No.");
56+}
57+
58+#[tokio::test]
59+async fn unknown_methods_are_not_implemented() {
60+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
61+ let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
62+ assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
63+ let teapot = send(&app, "BREW", "/runs/cafebabe", &[], "").await;
64+ assert_eq!(teapot.status(), StatusCode::IM_A_TEAPOT);
65+}
66+
67+#[tokio::test]
68+async fn the_clinic_does_not_advertise_jelq() {
69+ let Harness { app, .. } = harness(Floor::Clinic, SEED, |_| {});
70+ let options = send(&app, "OPTIONS", "/runs/cafebabe", &[], "").await;
71+ assert!(!header(&options, "allow").contains("JELQ"));
72+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
73+ assert_eq!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
74+ assert!(!header(&jelq, "allow").contains("JELQ"));
75+ let Harness { app: bedroom, .. } = harness(Floor::Bedroom, SEED, |_| {});
76+ let options = send(&bedroom, "OPTIONS", "/runs/cafebabe", &[], "").await;
77+ assert!(header(&options, "allow").contains("JELQ"));
78+}
79+
80+#[tokio::test]
81+async fn patch_only_accepts_the_truth() {
82+ let Harness { app, .. } = harness(Floor::Bedroom, SEED, |_| {});
83+ let patch = [("content-type", "application/json-patch+json")];
84+ let replace = send(
85+ &app,
86+ "PATCH",
87+ "/runs/cafebabe",
88+ &patch,
89+ r#"[{"op":"replace","path":"/length","value":12}]"#,
90+ )
91+ .await;
92+ assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
93+ let lie = send(
94+ &app,
95+ "PATCH",
96+ "/runs/cafebabe",
97+ &patch,
98+ r#"[{"op":"test","path":"/length","value":40}]"#,
99+ )
100+ .await;
101+ assert_eq!(
102+ json(lie).await["type"],
103+ "https://jelq.dev/problems/exaggeration"
104+ );
105+}
106+
107+#[tokio::test]
108+async fn http_1_0_gets_the_pdf_treatment() {
109+ let request = Request::builder()
110+ .uri("/graveyard")
111+ .version(Version::HTTP_10)
112+ .body(Body::empty())
113+ .unwrap();
114+ let response = app().oneshot(request).await.unwrap();
115+ assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
116+}
117+
118+#[tokio::test]
119+async fn links_offer_only_usable_techniques() {
120+ let app = seeded_with(Floor::Bedroom, |run| run.body.foreskin = Some(1.0));
121+ let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
122+ assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);
123+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
124+ let usable = advertised(&warm);
125+ assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
126+ assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
127+ assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
128+ assert!(
129+ header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
130+ );
131+
132+ let Harness { app: forum, .. } = harness(Floor::Forum, SEED, |_| {});
133+ let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
134+ assert!(advertised(&response).is_empty());
135+}
136+
137+#[tokio::test]
138+async fn links_hide_resources_that_would_do_nothing() {
139+ let Harness { app: bedroom, .. } = harness(Floor::Bedroom, SEED, |_| {});
140+ let before = linked_rels(&send(&bedroom, "GET", "/runs/cafebabe", &[], "").await);
141+ assert!(before.contains(&"drawer".to_owned()));
142+ assert!(!before.contains(&"diagnosis".to_owned()));
143+ assert!(!before.contains(&"thread".to_owned()));
144+ let searched = send(&bedroom, "POST", "/runs/cafebabe/drawer", &[], "").await;
145+ assert!(!linked_rels(&searched).contains(&"drawer".to_owned()));
146+
147+ let Harness { app: clinic, .. } = harness(Floor::Clinic, SEED, |_| {});
148+ let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
149+ assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
150+}
Added file: src/tests/mod.rs+148 additions
@@ -0,0 +1,148 @@
1+mod body;
2+mod floors;
3+mod household;
4+mod meta;
5+mod methods;
6+mod rolls;
7+mod turns;
8+
9+pub use std::{sync::Arc, time::Duration};
10+
11+pub use axum::{
12+ Router,
13+ body::{Body, to_bytes},
14+ http::{Method, Request, StatusCode, Version, header},
15+ response::Response,
16+};
17+pub use serde_json::Value;
18+use tower::ServiceExt;
19+
20+pub use crate::{
21+ AppState, Config,
22+ game::{
23+ Excuse, Floor, MumVisit, Run, Story,
24+ balance::{BRANCH_TURN, SUSPICION_LIMIT},
25+ },
26+ router,
27+};
28+
29+pub const SEED: u64 = 69;
30+
31+pub async fn send(
32+ app: &Router,
33+ method: &str,
34+ uri: &str,
35+ headers: &[(&str, &str)],
36+ body: &str,
37+) -> Response {
38+ let mut request = Request::builder()
39+ .method(Method::from_bytes(method.as_bytes()).unwrap())
40+ .uri(uri);
41+ for (name, value) in headers {
42+ request = request.header(*name, *value);
43+ }
44+ app.clone()
45+ .oneshot(request.body(Body::from(body.to_owned())).unwrap())
46+ .await
47+ .unwrap()
48+}
49+
50+pub async fn json(response: Response) -> Value {
51+ serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
52+}
53+
54+pub async fn text(response: Response) -> String {
55+ String::from_utf8(
56+ to_bytes(response.into_body(), usize::MAX)
57+ .await
58+ .unwrap()
59+ .to_vec(),
60+ )
61+ .unwrap()
62+}
63+
64+pub fn header<'a>(response: &'a Response, name: &str) -> &'a str {
65+ response.headers()[name].to_str().unwrap()
66+}
67+
68+/// Every `Warning` on a response, since a turn can carry several.
69+pub fn warnings(response: &Response) -> Vec<String> {
70+ response
71+ .headers()
72+ .get_all("warning")
73+ .iter()
74+ .map(|value| value.to_str().unwrap().to_owned())
75+ .collect()
76+}
77+
78+/// A server holding one run, `cafebabe`, set up by `setup` before anyone can reach it.
79+pub struct Harness {
80+ pub app: Router,
81+ pub origin: String,
82+}
83+
84+pub fn harness(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Harness {
85+ let state = Arc::new(AppState::default());
86+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
87+ run.floor = floor;
88+ setup(&mut run);
89+ let origin = run.origin_etag();
90+ state.insert(run);
91+ Harness {
92+ app: router(state),
93+ origin,
94+ }
95+}
96+
97+pub fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
98+ harness(floor, SEED, setup).app
99+}
100+
101+pub fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {
102+ harness(floor, seed, setup).app
103+}
104+
105+pub fn app() -> Router {
106+ crate::app(Config::default()).unwrap()
107+}
108+
109+pub fn mum_at_the_door(run: &mut Run) {
110+ run.household.mum = Some(MumVisit {
111+ errand: "She heard a noise.",
112+ nonce: "abc".into(),
113+ strikes: 0,
114+ knocked: false,
115+ });
116+}
117+
118+/// The `Link` entries on a response, as `(rel, title)` with the rel base stripped.
119+pub fn links(response: &Response) -> Vec<(String, Option<String>)> {
120+ header(response, "link")
121+ .split(", <")
122+ .filter_map(|entry| {
123+ let rel = entry.split("rels/").nth(1)?.split('"').next()?.to_owned();
124+ let title = entry
125+ .split("title=\"")
126+ .nth(1)
127+ .and_then(|t| t.split('"').next())
128+ .map(str::to_owned);
129+ Some((rel, title))
130+ })
131+ .collect()
132+}
133+
134+pub fn advertised(response: &Response) -> Vec<String> {
135+ links(response)
136+ .into_iter()
137+ .filter(|(rel, _)| rel == "technique")
138+ .filter_map(|(_, title)| title)
139+ .collect()
140+}
141+
142+pub fn linked_rels(response: &Response) -> Vec<String> {
143+ links(response)
144+ .into_iter()
145+ .map(|(rel, _)| rel)
146+ .filter(|rel| rel != "technique")
147+ .collect()
148+}
Added file: src/tests/rolls.rs+193 additions
@@ -0,0 +1,193 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn holding_calms_and_sometimes_reflects() {
5+ let mut calmed = 0;
6+ let mut reflected = 0;
7+ let mut ruptured = 0;
8+ for seed in 0..600u64 {
9+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
10+ run.body.eq = 75;
11+ match run.hold() {
12+ Ok(_) if run.body.eq == 0 => reflected += 1,
13+ Ok(_) => calmed += 1,
14+ Err(_) => ruptured += 1,
15+ }
16+ }
17+ assert!(
18+ (25..=80).contains(&reflected),
19+ "one HOLD in twelve should reflect, about 50 of 600, got {reflected} and {calmed} calm"
20+ );
21+ assert!(
22+ ruptured <= 7,
23+ "the flat 1 in 250 ruptured {ruptured} of 600 holds, about 2.4 expected"
24+ );
25+}
26+
27+#[tokio::test]
28+async fn the_burn_shifts_the_rupture_tiers() {
29+ async fn ruptures(burned: bool) -> u32 {
30+ let mut gone = 0;
31+ for seed in 0..400u64 {
32+ let app = app_with(Floor::Bedroom, seed, |run| {
33+ run.body.warmed_up = true;
34+ run.body.eq = 60;
35+ run.items.lubes.baby_oil = 9;
36+ run.body.conditions.friction_burn = burned;
37+ });
38+ let lube = [("content-type", "application/baby-oil")];
39+ let jelq = send(
40+ &app,
41+ "JELQ",
42+ "/runs/cafebabe?technique=wet&intensity=5",
43+ &lube,
44+ "",
45+ )
46+ .await;
47+ if jelq.status() == StatusCode::INTERNAL_SERVER_ERROR {
48+ gone += 1;
49+ }
50+ }
51+ gone
52+ }
53+ let burned = ruptures(true).await;
54+ let calm = ruptures(false).await;
55+ assert!(
56+ burned >= 8 && calm <= 4,
57+ "burned {burned} ruptures against {calm}"
58+ );
59+}
60+
61+#[tokio::test]
62+async fn the_top_tier_rolls_a_single_third() {
63+ let mut ruptures = 0;
64+ let mut early = 0;
65+ for seed in 0..400u64 {
66+ let app = app_with(Floor::Bedroom, seed, |run| {
67+ run.body.warmed_up = true;
68+ run.body.eq = 96;
69+ run.items.lubes.baby_oil = 9;
70+ run.items.kit.blinds = true;
71+ });
72+ let lube = [("content-type", "application/baby-oil")];
73+ let jelq = send(
74+ &app,
75+ "JELQ",
76+ "/runs/cafebabe?technique=wet&intensity=5",
77+ &lube,
78+ "",
79+ )
80+ .await;
81+ match jelq.status() {
82+ StatusCode::INTERNAL_SERVER_ERROR => ruptures += 1,
83+ StatusCode::TOO_EARLY => early += 1,
84+ other => panic!("seed {seed} ended in {other}"),
85+ }
86+ }
87+ assert!(
88+ (90..=175).contains(&ruptures),
89+ "ruptures {ruptures} of 400 against {early} early"
90+ );
91+ assert!(
92+ (225..=310).contains(&early),
93+ "early {early} of 400 against {ruptures} ruptures"
94+ );
95+}
96+
97+#[tokio::test]
98+async fn holding_rolls_a_flat_rupture_chance() {
99+ async fn ruptures(eq: i32, stamina: i32) -> u32 {
100+ let mut gone = 0;
101+ for seed in 0..600u64 {
102+ let app = app_with(Floor::Bedroom, seed, |run| {
103+ run.body.warmed_up = true;
104+ run.body.eq = eq;
105+ run.body.stamina = stamina;
106+ });
107+ let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
108+ if held.status() == StatusCode::INTERNAL_SERVER_ERROR {
109+ gone += 1;
110+ }
111+ }
112+ gone
113+ }
114+ let high = ruptures(90, 10).await;
115+ let low = ruptures(30, 2).await;
116+ assert!((0..=7).contains(&high), "high holds ruptured {high}/600");
117+ assert!((0..=7).contains(&low), "low holds ruptured {low}/600");
118+}
119+
120+#[tokio::test]
121+async fn the_kitchen_pays_out_at_the_documented_odds() {
122+ let mut oil = 0;
123+ let mut drinks = 0;
124+ let mut packs = 0;
125+ for seed in 0..600u64 {
126+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
127+ run.household.kitchen_tellings = 0;
128+ let held = (
129+ run.items.lubes.coconut_oil,
130+ run.items.stores.energy_drinks,
131+ run.items.stores.ice_packs,
132+ );
133+ let _ = run.raid_kitchen();
134+ oil += u32::from(run.items.lubes.coconut_oil > held.0);
135+ drinks += u32::from(run.items.stores.energy_drinks > held.1);
136+ packs += u32::from(run.items.stores.ice_packs > held.2);
137+ }
138+ assert!((170..=230).contains(&oil), "coconut {oil} in 600");
139+ assert!((90..=150).contains(&drinks), "energy {drinks} in 600");
140+ assert!((45..=105).contains(&packs), "ice {packs} in 600");
141+}
142+
143+#[tokio::test]
144+async fn the_kitchen_rolls_the_normal_tiers() {
145+ async fn ruptures(eq: i32) -> u32 {
146+ let mut gone = 0;
147+ for seed in 0..600u64 {
148+ let app = app_with(Floor::Bedroom, seed, |run| run.body.eq = eq);
149+ let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
150+ if raid.status() == StatusCode::INTERNAL_SERVER_ERROR {
151+ gone += 1;
152+ }
153+ }
154+ gone
155+ }
156+ let hot = ruptures(96).await;
157+ let cool = ruptures(40).await;
158+ assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");
159+ assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");
160+}
161+
162+#[tokio::test]
163+async fn degloving_takes_one_hard_dry_stroke_in_sixty() {
164+ let mut degloved = 0;
165+ for seed in 0..1200u64 {
166+ let app = app_with(Floor::Hotel, seed, |run| {
167+ run.here.wifi_accepted = true;
168+ run.body.warmed_up = true;
169+ run.body.eq = 55;
170+ run.body.foreskin = Some(1.0);
171+ });
172+ let dry = send(
173+ &app,
174+ "JELQ",
175+ "/runs/cafebabe?technique=dry&intensity=8",
176+ &[],
177+ "",
178+ )
179+ .await;
180+ if dry.status() == StatusCode::GONE
181+ && json(dry).await["type"]
182+ .as_str()
183+ .unwrap()
184+ .ends_with("degloved")
185+ {
186+ degloved += 1;
187+ }
188+ }
189+ assert!(
190+ (8..=35).contains(&degloved),
191+ "{degloved} degloved in 1200 hard dry strokes, about 20 expected"
192+ );
193+}
Added file: src/tests/turns.rs+225 additions
@@ -0,0 +1,225 @@
1+use super::*;
2+
3+#[tokio::test]
4+async fn meditation_is_free_but_only_once_per_floor() {
5+ let app = seeded_with(Floor::Bedroom, |_| {});
6+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
7+ assert_eq!(response.status(), StatusCode::OK);
8+ assert_eq!(header(&response, "x-eq"), "10");
9+ assert_eq!(header(&response, "x-stamina"), "10/10");
10+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
11+ assert_eq!(again.status(), StatusCode::CONFLICT);
12+}
13+
14+#[tokio::test]
15+async fn meditation_respects_the_priapism_floor() {
16+ let app = seeded_with(Floor::Bedroom, |run| {
17+ run.body.eq = 80;
18+ run.body.conditions.priapism_turns = 3;
19+ });
20+ let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
21+ assert_eq!(calmed.status(), StatusCode::OK);
22+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
23+ assert_eq!(header(&view, "x-eq"), "75");
24+}
25+
26+#[tokio::test]
27+async fn priapism_holds_the_floor_through_every_reset() {
28+ let exit = seeded_with(Floor::Bedroom, |run| {
29+ run.body.warmed_up = true;
30+ run.body.eq = 90;
31+ run.here.turns = 99;
32+ run.body.conditions.priapism_turns = 3;
33+ });
34+ let out = send(&exit, "POST", "/runs/cafebabe/floor", &[], "").await;
35+ assert_eq!(out.status(), StatusCode::SEE_OTHER);
36+ let view = send(&exit, "GET", "/runs/cafebabe", &[], "").await;
37+ assert_eq!(header(&view, "x-eq"), "75");
38+
39+ let melon = seeded_with(Floor::Bedroom, |run| {
40+ run.body.eq = 95;
41+ run.here.kitchen.melon_offered = true;
42+ run.body.conditions.priapism_turns = 2;
43+ });
44+ let bite = send(&melon, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
45+ assert_eq!(bite.status(), StatusCode::OK);
46+ let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
47+ assert_eq!(header(&view, "x-eq"), "75");
48+}
49+
50+#[tokio::test]
51+async fn the_towel_warms_once_until_the_kettle_boils() {
52+ let app = seeded_with(Floor::Bedroom, |_| {});
53+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
54+ assert_eq!(warm.status(), StatusCode::OK);
55+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
56+ assert_eq!(again.status(), StatusCode::CONFLICT);
57+
58+ let app = seeded_with(Floor::Bedroom, |run| {
59+ run.body.warmed_up = true;
60+ run.here.kitchen.kettle_boiled = true;
61+ });
62+ let reheated = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
63+ assert_eq!(reheated.status(), StatusCode::OK);
64+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
65+ assert_eq!(cold.status(), StatusCode::CONFLICT);
66+}
67+
68+#[tokio::test]
69+async fn the_refractory_period_is_the_next_hold() {
70+ let app = seeded_with(Floor::Hotel, |run| {
71+ run.here.wifi_accepted = true;
72+ run.body.warmed_up = true;
73+ run.body.eq = 60;
74+ run.body.stamina = 0;
75+ run.body.exhausted = true;
76+ });
77+ let tired = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
78+ assert_eq!(tired.status(), StatusCode::TOO_MANY_REQUESTS);
79+ let hold = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
80+ assert_eq!(hold.status(), StatusCode::OK);
81+ assert_eq!(header(&hold, "x-stamina"), "10/10");
82+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
83+ assert_eq!(cold.status(), StatusCode::PRECONDITION_FAILED);
84+}
85+
86+#[tokio::test]
87+async fn a_refused_jelq_does_not_break_the_edging_streak() {
88+ let app = seeded_with(Floor::ChangingRoom, |run| {
89+ run.body.warmed_up = true;
90+ run.body.eq = 45;
91+ run.here.edges = 2;
92+ });
93+ let refused = send(
94+ &app,
95+ "JELQ",
96+ "/runs/cafebabe?technique=dry",
97+ &[("content-type", "application/baby-oil")],
98+ "",
99+ )
100+ .await;
101+ assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
102+ let third = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
103+ assert_eq!(third.status(), StatusCode::LOOP_DETECTED);
104+}
105+
106+#[tokio::test]
107+async fn an_interrupted_edge_keeps_no_gains() {
108+ let app = seeded_with(Floor::ChangingRoom, |run| {
109+ run.body.warmed_up = true;
110+ run.body.eq = 10;
111+ });
112+ let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
113+ let girth = header(&before, "x-girth").to_owned();
114+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
115+ assert_eq!(edge.status(), StatusCode::NOT_FOUND);
116+ assert_eq!(header(&edge, "x-girth"), girth);
117+}
118+
119+#[tokio::test]
120+async fn greed_multiplies_gains_above_the_band() {
121+ let lube = [("content-type", "application/baby-oil")];
122+ for (eq, greed) in [(75, 1.5), (88, 2.0)] {
123+ let mut compared = false;
124+ for seed in 0..120u64 {
125+ let app = app_with(Floor::Bedroom, seed, |run| {
126+ run.body.eq = 60;
127+ run.body.warmed_up = true;
128+ });
129+ let in_band = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
130+ let app = app_with(Floor::Bedroom, seed, |run| {
131+ run.body.eq = eq;
132+ run.body.warmed_up = true;
133+ });
134+ let greedy = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
135+ if in_band.status() != StatusCode::OK || greedy.status() != StatusCode::OK {
136+ continue;
137+ }
138+ let steady = json(in_band).await["length"].as_f64().unwrap();
139+ let eager = json(greedy).await["length"].as_f64().unwrap();
140+ assert!(
141+ (eager - steady * greed).abs() < 1e-9,
142+ "seed {seed} gained {eager} at {eq} EQ against {steady} inside the band"
143+ );
144+ compared = true;
145+ break;
146+ }
147+ assert!(compared, "no seed produced two clean rolls at {eq} EQ");
148+ }
149+}
150+
151+#[tokio::test]
152+async fn queued_gains_arrive_on_their_turn() {
153+ let mut run = Run::new("cafebabe".into(), "cafe".into(), SEED);
154+ run.body.eq = 75;
155+ let before = run.body.length;
156+ run.body.pending.push(crate::game::Pending {
157+ due: run.turn + 1,
158+ length: 0.5,
159+ girth: 0.0,
160+ foreskin: 0.0,
161+ });
162+ assert!(run.hold().is_ok());
163+ assert!((run.body.length - before - 0.5).abs() < 1e-9);
164+ assert!(run.body.pending.is_empty());
165+}
166+
167+#[tokio::test]
168+async fn hold_keeps_the_shop_and_the_melon_open() {
169+ let hotel = seeded_with(Floor::Hotel, |run| {
170+ run.here.wifi_accepted = true;
171+ run.body.eq = 80;
172+ });
173+ send(&hotel, "GET", "/runs/cafebabe/shop", &[], "").await;
174+ let held = send(&hotel, "HOLD", "/runs/cafebabe", &[], "").await;
175+ assert!(linked_rels(&held).contains(&"shop-item".to_owned()));
176+
177+ let mut run = Run::new("cafebabe".into(), "cafe".into(), SEED);
178+ run.body.eq = 80;
179+ run.here.kitchen.melon_offered = true;
180+ assert!(run.hold().is_ok());
181+ assert!(run.here.kitchen.melon_offered, "the offer survives a HOLD");
182+}
183+
184+#[tokio::test]
185+async fn leaving_the_band_warms_the_next_floor() {
186+ let app = seeded_with(Floor::Bedroom, |run| {
187+ run.body.eq = 60;
188+ run.body.warmed_up = true;
189+ run.here.turns = 6;
190+ run.items.lubes.baby_oil = 9;
191+ });
192+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
193+ assert_eq!(left.status(), StatusCode::SEE_OTHER);
194+ let detail = json(left).await["detail"].as_str().unwrap().to_owned();
195+ assert!(detail.contains("the warmth came with you"));
196+
197+ let lube = [("content-type", "application/baby-oil")];
198+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
199+ // The shower is a +35 warm-up, so EQ lands in the band and the band bonus rides along.
200+ // The arrival bonus is the one point the second jelq loses.
201+ let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
202+ assert!(
203+ header(&first, "server-timing").contains("d20+4"),
204+ "the first jelq of a warm arrival rolls with +1, got {}",
205+ header(&first, "server-timing")
206+ );
207+ let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
208+ assert!(header(&second, "server-timing").contains("d20+3"));
209+}
210+
211+#[tokio::test]
212+async fn energy_drinks_add_three_stamina() {
213+ let app = seeded_with(Floor::Bedroom, |run| {
214+ run.body.stamina = 2;
215+ run.items.stores.energy_drinks = 1;
216+ });
217+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
218+ assert!(header(&view, "link").contains("technique=energy"));
219+ let drink = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
220+ assert_eq!(drink.status(), StatusCode::OK);
221+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
222+ assert_eq!(header(&view, "x-stamina"), "5/10");
223+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
224+ assert_eq!(again.status(), StatusCode::FAILED_DEPENDENCY);
225+}

show truncated diff