an http based roguelike rpg about jelqing
Commit 8b8e83329a1d
tarball74 files changed+6356-5352
@@ -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"] }@@ -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 {@@ -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;@@ -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)];@@ -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;@@ -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;@@ -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;@@ -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];@@ -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;@@ -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;@@ -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;@@ -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::*;@@ -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;@@ -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.girth133
+ self.body.girth135134
),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_RESPECTED147
+ self.body.girth >= GIRTH_RESPECTED149148
}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
-}@@ -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() == slug81
+ && !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::GONE90
+ }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::Alive133
+ }134
+}@@ -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::ALL102
+ .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_boiled107
+ }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
+}@@ -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.girth82
+ self.body.length, self.body.girth7183
);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
}),@@ -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
+ given23
+ .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
+ condition29
+ .split(',')30
+ .map(str::trim)31
+ .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)32
+}@@ -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
+}@@ -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 = first108
+ && 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 of128
+ /// 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.cage160
+ }161
+}@@ -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 u816
+ }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
+}@@ -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.notices34
- .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.turns66
- ),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
- view130
- }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::Bedroom148
- && 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::Bathroom153
- && first154
- && 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::ChangingRoom162
- && first163
- && 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 of254
- /// 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.notices265
- .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.cage291
- }292
-}@@ -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_since229283
.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)@@ -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_accepted103
+ }104
+105
+ /// Shame follows the player across runs in a signed cookie. Each response adds this run's new106
+ /// 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
+ total114
+ }115
+116
+ pub fn trace(&mut self) {117
+ self.standing.shame += 1;118
+ }119
+120
+ pub fn measured(&self) -> bool {121
+ self.items.kit.ruler122
+ }123
+124
+ pub fn id(&self) -> &str {125
+ &self.ident.id126
+ }127
+128
+ pub fn last_seen(&self) -> Instant {129
+ self.ident.last_seen130
+ }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.foreskin140
+ }141
+142
+ pub fn grip(&self) -> Grip {143
+ self.body.grip144
+ }145
+146
+ pub fn knows_uli(&self) -> bool {147
+ self.online.uli_taught148
+ }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> = self156
+ .items157
+ .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
+}@@ -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_RUPTURE2425
} else {2526
026
- }27
+ };28
+ self.body.eq > line - burn2729
}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_BAND33
- } else if self.eq <= HOT_EQ - self.burn() {35
+ } else if !self.hotter_than(HOT_EQ) {3436
RUPTURE_WARM3537
} else {3638
RUPTURE_HOT3739
}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::Dry4344
&& 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 - burn56
+ 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.blinds65
+ && !self.items.kit.blinds6766
&& 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.peyronie94
+ if self.body.conditions.peyronie9695
&& 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.notices182
- .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",@@ -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
authorization70134
.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
- EXCUSES81
- .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_now203
- || self204
- .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 = self279
+ .household280
+ .mum281
+ .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.notices245
- .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
}@@ -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::ALL121
+ .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
+}@@ -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::ALL231
- .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
-}@@ -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_LINE62
+ let melon = self.body.eq > RUPTURE_LINE6763
&& 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::StopRaidingTheFridge9591
} else {96
- "Stop raiding the kitchen."92
+ Notice::StopRaidingTheKitchen9793
});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));@@ -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
+}@@ -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.013
+}14
+15
+pub(super) fn round3(x: f64) -> f64 {16
+ (x * 1000.0).round() / 1000.017
+}18
+19
+pub(super) fn honest(claimed: f64, actual: f64) -> bool {20
+ (claimed - actual).abs() < HONEST_TOLERANCE21
+}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
+}@@ -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.091
-}92
-93
-fn round3(x: f64) -> f64 {94
- (x * 1000.0).round() / 1000.095
-}96
-97
-fn honest(claimed: f64, actual: f64) -> bool {98
- (claimed - actual).abs() < HONEST_TOLERANCE99
-}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
- given112
- .eq_ignore_ascii_case(scheme)113
- .then(|| rest.trim().trim_matches('"'))114
-}115
-116
-pub fn etag_matches(condition: &str, etag: &str) -> bool {117
- condition118
- .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_EQ236
- } else {237
- 0238
- };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 stamped258
- /// 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.turn282
- ));283
- s.push_str(&format!(284
- "eq: {}\nstamina: {}\nfriction: {}\n",285
- self.eq, self.stamina, self.friction286
- ));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
- s314
- }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_length326
- }327
-328
- pub fn self_reported(&self) -> f64 {329
- self.current_length() + SELF_REPORT_INFLATION330
- }331
-332
- pub fn max_stamina(&self) -> i32 {333
- if self.conditions.pulled_muscle {334
- PULLED_STAMINA335
- } else {336
- MAX_STAMINA337
- }338
- }339
-340
- pub fn forum_read(&self) -> bool {341
- self.story == Story::ReadForum342
- }343
-344
- pub fn thread_reachable(&self) -> bool {345
- self.turn >= BRANCH_TURN346
- || 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::Gone359
- } else if self.clinic.cured {360
- RunStatus::Normal361
- } else if self.clinic.prescribed {362
- RunStatus::Prescribed363
- } else {364
- RunStatus::Alive365
- }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;@@ -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
+}@@ -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, the12
-/// 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 the75
- /// event. The model believes dull statements more readily than rare76
- /// 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 the89
- /// model as a choice between the calm answer and the event. The noul head90
- /// answers interrupt questions with yes; the choice head, offered the two91
- /// 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 == 110594
}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` and121
- /// ignores the descriptions; the clm arm reads them all and picks the one122
- /// 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() - 1135121
}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 from141
- /// 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 picks163
- /// 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),@@ -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
self4343
}44
+45
+ pub fn status(&self) -> StatusCode {46
+ self.status47
+ }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
+ self55
+ }56
+57
+ pub fn into_parts(self) -> (StatusCode, Payload, Vec<(HeaderName, String)>) {58
+ (self.status, self.payload, self.headers)59
+ }4460
}@@ -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 > 027
+ && self28
+ .body29
+ .jelq_grip30
+ .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_AT37
+ && !self.body.conditions.friction_burn38
+ && 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_CHILL62
+ } else {63
+ 064
+ };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_INTENSITY82
+ && !self.body.conditions.pulled_muscle83
+ && 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::Cat121
+ && 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.ring130
+ && self.body.eq > HOT_EQ131
+ && 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_PRIAPISM138
+ } else {139
+ RING_PRIAPISM140
+ },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 summoned166
+ || 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
+}@@ -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 <= 215
+ }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 is66
+ /// 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
+}@@ -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_LINE40
+ } else {41
+ RUPTURE_LINE42
+ };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(self74
+ .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_BONUS81
+ } else {82
+ 083
+ };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_BONUS91
+ } else {92
+ 093
+ };94
+ lube.map_or(0, Lube::modifier) + band + grip + arrival95
+ }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_HOT145
+ } else if self.body.eq > DANGER_EQ {146
+ GREED_WARM147
+ } else {148
+ 1.0149
+ };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
+}@@ -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_EQ145
+ } else {146
+ 0147
+ };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.friction187
+ );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
+ s214
+ }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_length226
+ }227
+228
+ pub fn self_reported(&self) -> f64 {229
+ self.current_length() + SELF_REPORT_INFLATION230
+ }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_STAMINA243
+ } else {244
+ MAX_STAMINA245
+ }246
+ }247
+248
+ pub fn forum_read(&self) -> bool {249
+ self.online.story == Story::ReadForum250
+ }251
+252
+ pub fn thread_reachable(&self) -> bool {253
+ self.turn >= BRANCH_TURN254
+ || 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::Gone267
+ } else if self.clinic.cured {268
+ RunStatus::Normal269
+ } else if self.clinic.prescribed {270
+ RunStatus::Prescribed271
+ } else {272
+ RunStatus::Alive273
+ }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
+}@@ -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> = SHOP3537
.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 = item6769
.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,@@ -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
+}@@ -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 u820
- }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::Alive282
- }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
-}@@ -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 {@@ -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.turns70
+ ),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_OTHERS116
+ .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
+ view134
+ }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
+}@@ -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::ALL278
- .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_AT360
- && !self.conditions.friction_burn361
- && 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_CHILL388
- } else {389
- 0390
- };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_INTENSITY408
- && !self.conditions.pulled_muscle409
- && 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::Cat447
- && 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.ring456
- && self.eq > HOT_EQ457
- && 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_PRIAPISM464
- } else {465
- RING_PRIAPISM466
- },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.notices478
- .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 <= 2485
- }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.notices530
- .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 is541
- /// 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 summoned573
- || 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_BONUS610
- } else {611
- 0612
- };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_BONUS620
- } else {621
- 0622
- };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_LINE633
- } else {634
- RUPTURE_LINE635
- };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_HOT715
- } else if self.eq > DANGER_EQ {716
- GREED_WARM717
- } else {718
- 1.0719
- };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
- outcome784
- }785
-}@@ -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();@@ -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.method54
+ }55
+56
+ pub fn body(&self) -> &[u8] {57
+ &self.body58
+ }59
+60
+ pub fn now(&self) -> Instant {61
+ self.now62
+ }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
+ message80
+ }5181
}5282
5383
impl<S: Send + Sync> FromRequest<S> for Ctx {@@ -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
+}@@ -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
+ response52
+}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> = dead65
+ .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
+ entry134
+}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> = state144
+ .runs145
+ .each(Record::of)146
+ .into_iter()147
+ .filter(|record| seeded || !record.seeded)148
+ .collect();149
+ let wanted = page * PAGE;150
+ match state151
+ .records152
+ .top(ranking, seeded, 0, wanted + entries.len())153
+ {154
+ Ok(recorded) => entries.extend(155
+ recorded156
+ .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> = entries164
+ .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
+}@@ -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 never9
+/// 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
+ out122
+}@@ -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
- response105
+ if state.runs.insert_new(run) {106
+ response107
+ } 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> = state140
- .records141
- .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::ContentLength171
- }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
- state203
- .records204
- .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> = entries216
- .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
- entry234
- })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,@@ -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 = headers47
+ .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 {@@ -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 and91
-/// 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 they125
+/// run on the blocking pool and leave the async workers free.92126
async fn offload<F>(state: Shared, work: F) -> Response93127
where94128
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
.await101132
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))@@ -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 = ctx19
+ .query("technique")20
+ .filter(|n| !n.trim().is_empty())21
+ .ok_or_else(required)?;22
+ names23
+ .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
+}@@ -1,9 +1,13 @@
11
//! The live runs. The table is only locked long enough to find a run, and each run has its own2
-//! 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 while3
+//! 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
+ true66
+ }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
+ slots78
+ .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> = runs84
+ let idle: Vec<String> = self85
+ .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
}@@ -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
+ false20
+}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
}@@ -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
}@@ -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_TELLINGS131
- }),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
- linked149
- .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 = technique178
- .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 this203
-/// 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 = ctx206
- .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
- total213
-}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
+ stats174
+}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
};@@ -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::Clinic4544
}4645
47
-fn never(_: &Run) -> bool {48
- false49
-}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 = ctx137
- .query("technique")138
- .filter(|n| !n.trim().is_empty())139
- .ok_or_else(required)?;140
- names141
- .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 usize239
-}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::Unsatisfiable270
- })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) = ctx318
- .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>> = body333
- .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) = ctx352
- .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> = run383
- .items384
- .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::ALL432179
.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()@@ -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 usize19
+}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::Unsatisfiable50
+ })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) = ctx100
+ .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>> = body115
+ .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) = ctx134
+ .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
+}@@ -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 = ctx10
+ .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
+}@@ -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 page5555
// 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
};@@ -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};@@ -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)).await6
+ 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
}@@ -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 NULL28
-);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 NULL29
+ );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 = row77
+ .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: cause81
- .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 SET167
+ 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 NULL199
+ 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 = db169
- .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
statement172
- .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
}@@ -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
- .await37
- .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
- .await48
- .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_REQUIRED519
- );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_REQUIRED570
- );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
- entry766
- .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
- entry799
- .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
- response935
- .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
- tenth981
- .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 == 01206
- }),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<_> = jelq1550
- .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<_> = view1559
- .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
- gone1592
- }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
- gone1772
- }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
- gone1791
- }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 = left2025
- .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
-}@@ -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<_> = jelq231
+ .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<_> = view240
+ .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
+}@@ -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_REQUIRED51
+ );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_REQUIRED88
+ );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
+ tenth183
+ .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 = left281
+ .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
+}@@ -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 == 1321
+ })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
+}@@ -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
+}@@ -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
+}@@ -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
+ .await47
+ .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
+ .await58
+ .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
+ response71
+ .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).app99
+}100
+101
+pub fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {102
+ harness(floor, seed, setup).app103
+}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 = entry125
+ .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
+}@@ -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
+ gone52
+ }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
+ gone113
+ }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
+ gone155
+ }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::GONE181
+ && 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(°loved),191
+ "{degloved} degloved in 1200 hard dry strokes, about 20 expected"192
+ );193
+}@@ -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
+}