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 rashow full diff (262144 bytes truncated)