an http based roguelike rpg about jelqing
Commit 782069860236
tarball38 files changed+4874-4108
@@ -22,7 +22,7 @@
2222
./Cargo.toml2323
./Cargo.lock2424
./src25
- ./tests25
+2626
];2727
};2828
in@@ -0,0 +1,38 @@
1
+use std::time::Duration;2
+3
+use crate::secret::Secret;4
+5
+pub struct Config {6
+ pub addr: String,7
+ pub refractory: Duration,8
+ pub secret: Secret,9
+ pub max_runs: usize,10
+ pub idle_ttl: Duration,11
+}12
+13
+impl Default for Config {14
+ fn default() -> Self {15
+ Self {16
+ addr: "127.0.0.1:6969".into(),17
+ refractory: Duration::from_secs(30),18
+ secret: Secret::random(),19
+ max_runs: 10_000,20
+ idle_ttl: Duration::from_secs(6 * 60 * 60),21
+ }22
+ }23
+}24
+25
+impl Config {26
+ pub fn from_env() -> Self {27
+ let var = |name| std::env::var(name).ok();28
+ let defaults = Self::default();29
+ Self {30
+ addr: var("JELQD_ADDR").unwrap_or(defaults.addr),31
+ refractory: var("JELQD_REFRACTORY_SECS")32
+ .and_then(|s| s.parse().ok())33
+ .map_or(defaults.refractory, Duration::from_secs),34
+ secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),35
+ ..defaults36
+ }37
+ }38
+}@@ -0,0 +1,68 @@
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 LOCKER_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
+pub const STORAGE_GIRTH: f64 = 5.0;42
+pub const PUT_GAIN: f64 = 0.01;43
+pub const PUMP_GAIN: f64 = 0.5;44
+pub const EXTENDER_GAIN: f64 = 0.004;45
+pub const EDGE_GIRTH: f64 = 0.004;46
+47
+pub const RUPTURE_IN_BAND: u32 = 250;48
+pub const RUPTURE_WARM: u32 = 25;49
+pub const RUPTURE_HOT: u32 = 10;50
+pub const RUPTURE_PREMATURE: u32 = 3;51
+pub const ZIPPER: u32 = 200;52
+pub const DEGLOVING: u32 = 60;53
+pub const DEGLOVING_INTENSITY: i32 = 8;54
+pub const WINDOW: u32 = 60;55
+pub const PULLED_MUSCLE: u32 = 12;56
+pub const PULLED_MUSCLE_INTENSITY: i32 = 9;57
+pub const RING_PRIAPISM: u32 = 10;58
+pub const SUPPLEMENT_WORKS: u32 = 5;59
+pub const OBSERVED: u32 = 6;60
+pub const CAT_TAKES_TOWEL: u32 = 5;61
+pub const FRIEND: u32 = 10;62
+pub const CURVE_MISSES: u32 = 3;63
+pub const HOTEL_STRESS: u32 = 3;64
+pub const VIRAL_ERRAND: u32 = 2;65
+66
+/// Visits are (suspicion + 1) in N per turn.67
+pub const MOM_VISIT: u32 = 16;68
+pub const WANDERER_VISIT: u32 = 32;@@ -0,0 +1,121 @@
1
+use axum::http::StatusCode;2
+use serde::Deserialize;3
+use serde_json::{Value, json};4
+5
+use super::{6
+ Grip, Outcome, Run,7
+ balance::{EXAGGERATION, PUT_GAIN},8
+ etag_matches, honest,9
+};10
+use crate::problem::Problem;11
+12
+#[derive(Deserialize)]13
+pub struct PatchOp {14
+ pub op: String,15
+ pub path: String,16
+ #[serde(default)]17
+ pub value: Value,18
+}19
+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
+ )26
+}27
+28
+impl Run {29
+ pub fn put(&mut self) -> Outcome {30
+ if self.put_applied {31
+ return Outcome::empty(StatusCode::NO_CONTENT);32
+ }33
+ self.put_applied = true;34
+ self.length += PUT_GAIN;35
+ self.touch();36
+ Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))37
+ }38
+39
+ pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {40
+ for op in ops {41
+ if op.op != "test" {42
+ return Err(Problem::new(43
+ StatusCode::UNPROCESSABLE_ENTITY,44
+ "wishful-thinking",45
+ "The server accepts only the truth.",46
+ )47
+ .with("op", op.op.clone()));48
+ }49
+ let actual = match op.path.as_str() {50
+ "/length" => self.current_length(),51
+ "/girth" => self.girth,52
+ "/foreskin" => self.foreskin.unwrap_or(0.0),53
+ _ => {54
+ return Err(Problem::new(55
+ StatusCode::UNPROCESSABLE_ENTITY,56
+ "no-such-measurement",57
+ "There is no such measurement.",58
+ )59
+ .with("path", op.path.clone()));60
+ }61
+ };62
+ let Some(claimed) = op.value.as_f64() else {63
+ return Err(Problem::new(64
+ StatusCode::BAD_REQUEST,65
+ "malformed-patch",66
+ "A test requires a numeric value.",67
+ ));68
+ };69
+ if op.path == "/length" && claimed >= actual + EXAGGERATION {70
+ self.shame += 2;71
+ self.touch();72
+ return Err(exaggeration());73
+ }74
+ if !honest(claimed, actual) {75
+ self.shame += 1;76
+ self.touch();77
+ return Err(78
+ Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")79
+ .with("path", op.path.clone()),80
+ );81
+ }82
+ }83
+ Ok(Outcome::json(84
+ StatusCode::OK,85
+ json!({ "detail": "Correct." }),86
+ ))87
+ }88
+89
+ pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {90
+ if self.foreskin.is_none() {91
+ return Err(Problem::new(92
+ StatusCode::GONE,93
+ "foreskin-gone",94
+ "The foreskin is gone. It will not be returning.",95
+ ));96
+ }97
+ let Some(if_match) = if_match else {98
+ return Err(Problem::new(99
+ StatusCode::PRECONDITION_REQUIRED,100
+ "confirmation-required",101
+ "The server requires you to be sure. Send If-Match.",102
+ ));103
+ };104
+ if !etag_matches(if_match, &self.etag()) {105
+ return Err(Problem::new(106
+ StatusCode::PRECONDITION_FAILED,107
+ "stale-foreskin",108
+ "The foreskin has changed since you last looked at it.",109
+ ));110
+ }111
+ self.foreskin = None;112
+ self.shame += 20;113
+ self.touch();114
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))115
+ }116
+117
+ pub fn set_grip(&mut self, grip: Grip) {118
+ self.grip = grip;119
+ self.touch();120
+ }121
+}@@ -1,253 +0,0 @@
1
-use axum::http::StatusCode;2
-use serde_json::{Value, json};3
-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(super) 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
-}62
-63
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]64
-pub enum Technique {65
- Warmup,66
- Ice,67
- Edge,68
- Pump,69
- Supplement,70
- Wet,71
- Dry,72
- Stretch,73
- Uli,74
-}75
-76
-#[derive(Clone, Copy)]77
-pub(super) enum LubeRule {78
- Required,79
- Forbidden,80
- Optional,81
-}82
-83
-pub(super) struct Profile {84
- pub dc: i32,85
- pub length: f64,86
- pub girth: f64,87
- pub foreskin: Option<(f64, f64)>,88
- pub foreskin_growth: f64,89
- pub lube: LubeRule,90
-}91
-92
-impl Technique {93
- pub const ALL: [Self; 9] = [94
- Self::Warmup,95
- Self::Ice,96
- Self::Edge,97
- Self::Pump,98
- Self::Supplement,99
- Self::Wet,100
- Self::Dry,101
- Self::Stretch,102
- Self::Uli,103
- ];104
-105
- pub fn name(self) -> &'static str {106
- match self {107
- Self::Warmup => "warmup",108
- Self::Ice => "ice",109
- Self::Edge => "edge",110
- Self::Pump => "pump",111
- Self::Supplement => "supplement",112
- Self::Wet => "wet",113
- Self::Dry => "dry",114
- Self::Stretch => "stretch",115
- Self::Uli => "uli",116
- }117
- }118
-119
- pub fn parse(name: &str) -> Option<Self> {120
- Self::ALL.into_iter().find(|t| t.name() == name)121
- }122
-123
- pub(super) fn profile(self) -> Option<Profile> {124
- let profile = match self {125
- Self::Warmup | Self::Ice | Self::Edge | Self::Pump | Self::Supplement => return None,126
- Self::Wet => Profile {127
- dc: 10,128
- length: 0.010,129
- girth: 0.005,130
- foreskin: None,131
- foreskin_growth: 0.0,132
- lube: LubeRule::Required,133
- },134
- Self::Dry => Profile {135
- dc: 12,136
- length: 0.008,137
- girth: 0.003,138
- foreskin: Some((0.8, 2.0)),139
- foreskin_growth: 0.01,140
- lube: LubeRule::Forbidden,141
- },142
- Self::Stretch => Profile {143
- dc: 11,144
- length: 0.015,145
- girth: 0.0,146
- foreskin: Some((0.0, 1.8)),147
- foreskin_growth: 0.03,148
- lube: LubeRule::Optional,149
- },150
- Self::Uli => Profile {151
- dc: 15,152
- length: 0.002,153
- girth: 0.020,154
- foreskin: None,155
- foreskin_growth: 0.0,156
- lube: LubeRule::Optional,157
- },158
- };159
- Some(profile)160
- }161
-162
- pub fn describe(self, forum_read: bool) -> Value {163
- let Some(p) = self.profile() else {164
- let effect = match self {165
- Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",166
- Self::Ice => "EQ -40. Consumes an ice pack.",167
- Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",168
- Self::Pump => "Large gains, all temporary. Requires a pump.",169
- _ => "Consumes a supplement.",170
- };171
- return json!({ "name": self.name(), "effect": effect });172
- };173
- let lubricant = match p.lube {174
- LubeRule::Required => "required",175
- LubeRule::Forbidden => "forbidden",176
- LubeRule::Optional => "optional",177
- };178
- let foreskin = p179
- .foreskin180
- .map(|(min, max)| json!({ "min": min, "max": max }));181
- json!({182
- "name": self.name(),183
- "dc": p.dc,184
- "lubricant": lubricant,185
- "foreskin": foreskin,186
- "locked": self == Self::Uli && !forum_read,187
- })188
- }189
-}190
-191
-pub struct ShopItem {192
- pub slug: &'static str,193
- pub price: u32,194
- pub description: &'static str,195
-}196
-197
-pub const SHOP: [ShopItem; 11] = [198
- ShopItem {199
- slug: "pump",200
- price: 10,201
- description: "A pump.",202
- },203
- ShopItem {204
- slug: "pump-pro",205
- price: 15,206
- description: "A pump, but professional.",207
- },208
- ShopItem {209
- slug: "extender",210
- price: 15,211
- description: "Passive gains every turn. Takes up room.",212
- },213
- ShopItem {214
- slug: "cock-ring",215
- price: 8,216
- description: "Doubles girth gains. Pump-Bro does not discuss the risks.",217
- },218
- ShopItem {219
- slug: "supplement",220
- price: 5,221
- description: "Clinically formulated. Ships from overseas.",222
- },223
- ShopItem {224
- slug: "door-lock",225
- price: 5,226
- description: "Keeps the roommate out once.",227
- },228
- ShopItem {229
- slug: "do-not-disturb-sign",230
- price: 5,231
- description: "Housekeeping respects it.",232
- },233
- ShopItem {234
- slug: "ruler",235
- price: 5,236
- description: "Pump-Bro does not recommend it.",237
- },238
- ShopItem {239
- slug: "window-blinds",240
- price: 5,241
- description: "The neighbour has a good camera.",242
- },243
- ShopItem {244
- slug: "ice-pack",245
- price: 3,246
- description: "Cold.",247
- },248
- ShopItem {249
- slug: "cage-key",250
- price: 15,251
- description: "Pump-Bro does not ask.",252
- },253
-];@@ -0,0 +1,73 @@
1
+use axum::http::{StatusCode, header};2
+use serde_json::json;3
+4
+use super::{Floor, Outcome, Run, etag_matches, parse_credentials, round2};5
+use crate::{headers::X_DIAGNOSIS, problem::Problem};6
+7
+const INTAKE: [&str; 3] = [8
+ "The clinic does not accept excuses.",9
+ "The clinic accepts one token. You have not been carrying it.",10
+ "Be honest.",11
+];12
+13
+impl Run {14
+ pub fn diagnose(15
+ &mut self,16
+ authorization: Option<&str>,17
+ if_none_match: Option<&str>,18
+ ) -> Result<Outcome, Problem> {19
+ if self.floor != Floor::Clinic {20
+ let floors = Floor::Clinic.number() - self.floor.number();21
+ return Err(Problem::new(22
+ StatusCode::TOO_EARLY,23
+ "no-appointment",24
+ format!("The clinic's next available appointment is {floors} floors away."),25
+ ));26
+ }27
+ let intake = Problem::new(28
+ StatusCode::UNAUTHORIZED,29
+ "intake",30
+ "The clinic requires authorization.",31
+ );32
+ let Some(authorization) = authorization else {33
+ return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));34
+ };35
+ let truthful = parse_credentials(authorization, "bearer")36
+ .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));37
+ if !truthful {38
+ let hint = INTAKE[self.clinic.intake_attempts.min(INTAKE.len() - 1)];39
+ self.clinic.intake_attempts += 1;40
+ return Err(intake.header(41
+ header::WWW_AUTHENTICATE,42
+ format!(43
+ "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""44
+ ),45
+ ));46
+ }47
+48
+ self.conditions.peyronie = false;49
+ self.conditions.priapism_turns = 0;50
+ let etag = self.origin_etag();51
+ if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {52
+ self.clinic.cured = true;53
+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)54
+ .header(header::ETAG, etag)55
+ .header(X_DIAGNOSIS, "normal"));56
+ }57
+ if !self.clinic.measured {58
+ self.clinic.measured = true;59
+ if !self.items.ruler {60
+ self.shame += 15;61
+ }62
+ }63
+ Ok(Outcome::json(64
+ StatusCode::OK,65
+ json!({66
+ "diagnosis": "within normal range",67
+ "measured_length": round2(self.length),68
+ "prescription": "Please stop.",69
+ }),70
+ )71
+ .header(header::ETAG, etag))72
+ }73
+}@@ -1,773 +0,0 @@
1
-use std::collections::HashMap;2
-3
-use axum::http::{HeaderName, StatusCode, header};4
-use rand::Rng;5
-use serde::Deserialize;6
-use serde_json::{Value, json};7
-8
-use super::{9
- Floor, Grip, Outcome, Run, SHOP, SUSPICION_LIMIT, X_DIAGNOSIS, honest, parse_credentials,10
- round2,11
-};12
-use crate::problem::Problem;13
-14
-pub const FORUM_THREAD: &str = "/forum/threads/2009";15
-pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";16
-17
-pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];18
-pub const THANKSGIVING_EXCUSES: [&str; 3] = ["on a call", "folding laundry", "praying"];19
-const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];20
-const PATIENCE: u8 = 3;21
-const CAUGHT: &str = "Mom has seen enough. The run has ended. Nobody will mention it at dinner.";22
-const BANNED: &str = "Your account has been banned. The forum can tell. The run has ended.";23
-24
-const INTAKE: [&str; 3] = [25
- "The clinic does not accept excuses.",26
- "The clinic accepts one token. You have not been carrying it.",27
- "Be honest.",28
-];29
-30
-const SPREADSHEET: &str = "\31
-date,length,girth,technique,notes,honest?32
-2007-01-03,7.50,5.50,wet,baseline,no33
-2007-06-11,7.50,5.50,wet,feeling fuller,no34
-2008-01-02,7.50,5.50,dry,new routine,no35
-2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no36
-2009-06-14,7.50,5.50,wet,progress is slow but real,no37
-";38
-39
-pub fn forum_thread() -> Value {40
- json!({41
- "title": "The definitive jelqing FAQ (updated 2009)",42
- "posts": [43
- { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },44
- { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },45
- { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },46
- { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },47
- { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },48
- { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },49
- { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },50
- ],51
- })52
-}53
-54
-#[derive(Deserialize)]55
-pub struct PatchOp {56
- pub op: String,57
- pub path: String,58
- #[serde(default)]59
- pub value: Value,60
-}61
-62
-fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {63
- let (scheme, rest) = authorization.trim().split_once(' ')?;64
- if !scheme.eq_ignore_ascii_case("digest") {65
- return None;66
- }67
- Some(68
- rest.split(',')69
- .filter_map(|pair| {70
- let (key, value) = pair.split_once('=')?;71
- Some((72
- key.trim().to_ascii_lowercase(),73
- value.trim().trim_matches('"').to_owned(),74
- ))75
- })76
- .collect(),77
- )78
-}79
-80
-fn exaggeration() -> Problem {81
- Problem::new(82
- StatusCode::BAD_REQUEST,83
- "exaggeration",84
- "The server has measured. Lying is not a technique.",85
- )86
-}87
-88
-impl Run {89
- pub fn excuses(&self) -> Vec<&'static str> {90
- if self.floor.number() < Floor::Grandmas.number() {91
- return EXCUSES.to_vec();92
- }93
- EXCUSES94
- .into_iter()95
- .chain(THANKSGIVING_EXCUSES)96
- .filter(|e| !self.heard.iter().any(|h| h == e))97
- .collect()98
- }99
-100
- pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {101
- let digest = self.floor == Floor::Grandmas;102
- let challenge_value = if digest {103
- format!("Digest realm=\"household\", nonce=\"{}\"", self.nonce)104
- } else {105
- "Excuse realm=\"household\"".to_owned()106
- };107
- let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);108
-109
- let Some(authorization) = authorization else {110
- return Err(challenge(111
- Problem::new(112
- StatusCode::UNAUTHORIZED,113
- "someone-at-the-door",114
- format!("Someone is at the door. {}", self.mom_errand),115
- )116
- .with("inventory", format!("/runs/{}/inventory", self.id)),117
- ));118
- };119
-120
- let spoken = authorization121
- .split_once(' ')122
- .map(|(_, rest)| rest.trim().trim_matches('"').to_ascii_lowercase())123
- .unwrap_or_default();124
- if TRUTHS.contains(&spoken.as_str()) {125
- self.shame += 10;126
- self.leave_door();127
- return Ok(());128
- }129
- if parse_credentials(authorization, "basic").is_some() {130
- return Err(self.mom_strike(challenge(Problem::new(131
- StatusCode::UNAUTHORIZED,132
- "basic",133
- "Mom does not accept Basic authentication. Nobody should.",134
- ))));135
- }136
-137
- let excuse = if digest {138
- let Some(params) = digest_params(authorization) else {139
- return Err(self.mom_strike(challenge(Problem::new(140
- StatusCode::UNAUTHORIZED,141
- "digest-required",142
- "Mom has started using Digest authentication.",143
- ))));144
- };145
- if params.get("nonce") != Some(&self.nonce) {146
- return Err(self.mom_strike(challenge(Problem::new(147
- StatusCode::UNAUTHORIZED,148
- "stale-nonce",149
- "That was a different conversation.",150
- ))));151
- }152
- params.get("username").cloned().unwrap_or_default()153
- } else {154
- parse_credentials(authorization, "excuse")155
- .unwrap_or_default()156
- .to_owned()157
- };158
- let excuse = excuse.to_ascii_lowercase();159
-160
- if TRUTHS.contains(&excuse.as_str()) {161
- self.shame += 10;162
- self.leave_door();163
- return Ok(());164
- }165
- let true_now = self.pulled_muscle && excuse == "pulled a muscle";166
- if digest && self.heard.contains(&excuse) && !true_now {167
- self.shame += 3;168
- self.touch();169
- return Err(self.mom_strike(challenge(Problem::new(170
- StatusCode::UNAUTHORIZED,171
- "excuse-replayed",172
- "Mom has heard that one before. She would have preferred the truth.",173
- ))));174
- }175
- if true_now || self.excuses().contains(&excuse.as_str()) {176
- self.heard.push(excuse);177
- self.leave_door();178
- return Ok(());179
- }180
- self.shame += 3;181
- self.touch();182
- let detail = if digest {183
- "Mom has not heard that one before. She does not believe it. She would have preferred the truth."184
- } else {185
- "Mom has not heard that one before. She does not believe it."186
- };187
- Err(self.mom_strike(challenge(Problem::new(188
- StatusCode::UNAUTHORIZED,189
- "excuse-rejected",190
- detail,191
- ))))192
- }193
-194
- pub(super) fn errand(&mut self, summoned: bool) -> &'static str {195
- if summoned {196
- return "She heard a noise.";197
- }198
- if self.filmed_at.is_some() && self.rng.random_ratio(1, 2) {199
- return "A neighbour has sent her something.";200
- }201
- let errands: &[&'static str] = if self.floor == Floor::Grandmas {202
- &[203
- "Grandma would like to see you.",204
- "Dinner is in ten minutes.",205
- "She needs help with the folding table.",206
- "An uncle has asked where you went.",207
- ]208
- } else {209
- &[210
- "She has brought laundry.",211
- "She wants to know if you have eaten.",212
- "She needs the wifi password.",213
- "She would like to talk about your future.",214
- ]215
- };216
- errands[self.rng.random_range(0..errands.len())]217
- }218
-219
- fn mom_strike(&mut self, problem: Problem) -> Problem {220
- self.mom_strikes += 1;221
- self.touch();222
- if self.mom_strikes < PATIENCE {223
- return problem;224
- }225
- self.end("caught", CAUGHT);226
- Problem::new(StatusCode::GONE, "caught", CAUGHT)227
- }228
-229
- fn elder_strike(&mut self, problem: Problem) -> Problem {230
- self.elder_strikes += 1;231
- self.touch();232
- if self.elder_strikes < PATIENCE {233
- return problem;234
- }235
- self.end("banned", BANNED);236
- Problem::new(StatusCode::FORBIDDEN, "banned", BANNED)237
- }238
-239
- fn leave_door(&mut self) {240
- self.mom_strikes = 0;241
- self.mom_present = false;242
- self.suspicion = 0;243
- self.touch();244
- }245
-246
- pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {247
- let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)248
- && self.rng.random_ratio(u32::from(self.suspicion) + 1, 32);249
- if self.suspicion < SUSPICION_LIMIT && !wanders_in {250
- return Ok(());251
- }252
- match self.floor {253
- Floor::Bathroom => {254
- self.suspicion = 0;255
- self.touch();256
- if self.items.door_locks > 0 {257
- self.items.door_locks -= 1;258
- return Ok(());259
- }260
- self.roommate_told = true;261
- Err(Problem::new(262
- StatusCode::CONFLICT,263
- "roommate",264
- "The roommate needs the bathroom. He will mention this to people.",265
- ))266
- }267
- Floor::LockerRoom => {268
- self.suspicion = 0;269
- self.shame += 5;270
- self.touch();271
- Err(Problem::new(272
- StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,273
- "gym-staff",274
- "Gym staff have been notified. The server cannot serve this request for legal reasons.",275
- ))276
- }277
- Floor::Hotel => {278
- self.suspicion = 0;279
- self.touch();280
- if self.items.dnd_sign {281
- return Ok(());282
- }283
- self.shame += 3;284
- Err(Problem::new(285
- StatusCode::UNAUTHORIZED,286
- "housekeeping",287
- "Housekeeping has entered. Housekeeping accepts no excuses.",288
- )289
- .header(header::WWW_AUTHENTICATE, "Excuse realm=\"housekeeping\""))290
- }291
- _ => Ok(()),292
- }293
- }294
-295
- pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {296
- self.drawer_searches += 1;297
- self.touch();298
- let first = self.drawer_searches == 1;299
- let id = self.id.clone();300
- let found = |slug: &str, detail: &str| {301
- Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))302
- .header(header::LOCATION, format!("/runs/{id}/inventory/{slug}"))303
- };304
- match self.floor {305
- Floor::Bedroom if first && !self.items.ruler => {306
- self.items.ruler = true;307
- self.shame += 10;308
- Ok(found(309
- "ruler",310
- "You now know. This is the first honest measurement of the run.",311
- ))312
- }313
- Floor::Bathroom if first => {314
- self.items.door_locks += 1;315
- Ok(found("door-lock", "A door lock. It works once."))316
- }317
- Floor::LockerRoom => {318
- self.shame += 3;319
- Err(Problem::new(320
- StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,321
- "not-your-locker",322
- "That locker belongs to Greg. The server cannot serve this request for legal reasons.",323
- ))324
- }325
- Floor::Grandmas if first => {326
- self.items.cage = true;327
- self.shame += 5;328
- Ok(found(329
- "chastity-cage",330
- "A small box. Inside was a cage. It is now on. The key is not in the box.",331
- ))332
- }333
- Floor::Grandmas if self.items.cage => {334
- self.items.cage = false;335
- Ok(Outcome::json(336
- StatusCode::OK,337
- json!({ "detail": "The key was under where the box had been. The cage is off." }),338
- ))339
- }340
- Floor::Hotel if first => {341
- self.items.dnd_sign = true;342
- self.items.ice_packs += 2;343
- Ok(found(344
- "do-not-disturb-sign",345
- "A Do Not Disturb sign, and two ice packs from the machine down the hall.",346
- ))347
- }348
- Floor::Forum if first => {349
- self.items.spreadsheet = true;350
- Ok(found(351
- "spreadsheet",352
- "Your own spreadsheet. Every line is flat.",353
- ))354
- }355
- Floor::Clinic => Err(Problem::new(356
- StatusCode::FORBIDDEN,357
- "doctors-drawer",358
- "That is the doctor's drawer.",359
- )),360
- _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),361
- }362
- }363
-364
- pub fn shop_view(&self) -> Result<Value, Problem> {365
- if !self.floor.shop_open() {366
- return Err(pump_bro_absent());367
- }368
- let stock: Vec<Value> = SHOP369
- .iter()370
- .map(|item| {371
- json!({372
- "item": item.slug,373
- "price": item.price,374
- "description": item.description,375
- "href": format!("/runs/{}/shop/{}", self.id, item.slug),376
- "method": "POST",377
- })378
- })379
- .collect();380
- Ok(json!({ "merchant": "Pump-Bro", "cash": self.cash, "stock": stock }))381
- }382
-383
- pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {384
- if !self.floor.shop_open() {385
- return Err(pump_bro_absent());386
- }387
- let Some(item) = SHOP.iter().find(|i| i.slug == slug) else {388
- return Err(Problem::new(389
- StatusCode::NOT_FOUND,390
- "not-for-sale",391
- "Pump-Bro does not sell that. He knows a guy.",392
- ));393
- };394
- if slug == "pump" {395
- return Err(Problem::new(396
- StatusCode::UPGRADE_REQUIRED,397
- "upsell",398
- "This pump requires an upgrade.",399
- )400
- .header(header::UPGRADE, "pump-pro/2.0")401
- .with("upgrade", format!("/runs/{}/shop/pump-pro", self.id)));402
- }403
- if self.cash < item.price {404
- return Err(Problem::new(405
- StatusCode::PAYMENT_REQUIRED,406
- "insufficient-funds",407
- "Payment is required. RFC 9110 has reserved this status for future use since 1997. The future is here and you cannot afford it.",408
- )409
- .with("cash", self.cash)410
- .with("price", item.price));411
- }412
- self.cash -= item.price;413
- self.touch();414
- let items = &mut self.items;415
- match slug {416
- "supplement" => {417
- items.supplements += 1;418
- return Err(Problem::new(419
- StatusCode::GATEWAY_TIMEOUT,420
- "still-shipping",421
- "The supplement is still shipping. You have been charged. It may arrive.",422
- )423
- .with("cash", self.cash));424
- }425
- "pump-pro" => items.pump_pro = true,426
- "extender" => items.extender = true,427
- "cock-ring" => items.ring = true,428
- "door-lock" => items.door_locks += 1,429
- "do-not-disturb-sign" => items.dnd_sign = true,430
- "ruler" => items.ruler = true,431
- "ice-pack" => items.ice_packs += 1,432
- "window-blinds" => items.blinds = true,433
- _ => items.cage = false,434
- }435
- Ok(Outcome::json(436
- StatusCode::CREATED,437
- json!({ "detail": "Purchased. No refunds.", "cash": self.cash }),438
- )439
- .header(440
- header::LOCATION,441
- format!("/runs/{}/inventory/{slug}", self.id),442
- ))443
- }444
-445
- pub fn drop_item(&mut self, slug: &str) -> Result<Outcome, Problem> {446
- match slug {447
- "extender" => self.items.extender = false,448
- "cock-ring" => self.items.ring = false,449
- "chastity-cage" => {450
- return Err(Problem::new(451
- StatusCode::LOCKED,452
- "caged",453
- "The cage is locked.",454
- ));455
- }456
- _ => {457
- return Err(Problem::new(458
- StatusCode::FORBIDDEN,459
- "cannot-be-dropped",460
- "That cannot be dropped.",461
- ));462
- }463
- }464
- self.touch();465
- Ok(Outcome::empty(StatusCode::NO_CONTENT))466
- }467
-468
- pub(super) fn branch_offer(&self) -> Outcome {469
- let forum = format!("/runs/{}/branches/forum", self.id);470
- let close = format!("/runs/{}/branches/close-tab", self.id);471
- Outcome::json(472
- StatusCode::MULTIPLE_CHOICES,473
- json!({474
- "detail": "A browser tab is open. It has been open for some time.",475
- "choices": [476
- { "href": forum, "method": "POST", "title": "Read the forum" },477
- { "href": close, "method": "POST", "title": "Close the tab" },478
- ],479
- }),480
- )481
- .header(482
- header::LINK,483
- format!(484
- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""485
- ),486
- )487
- }488
-489
- pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {490
- if !self.branch_pending {491
- return Err(Problem::new(492
- StatusCode::CONFLICT,493
- "no-decision-pending",494
- "There is no decision pending.",495
- ));496
- }497
- let (location, detail) = match choice {498
- "forum" => {499
- self.forum_read = true;500
- self.shame += 3;501
- (502
- format!("/runs/{}/techniques", self.id),503
- "The thread is from 2009. A technique called uli is discussed at length.",504
- )505
- }506
- "close-tab" => {507
- self.suspicion = 0;508
- (509
- format!("/runs/{}", self.id),510
- "The tab is closed. Nobody saw it.",511
- )512
- }513
- _ => {514
- return Err(Problem::new(515
- StatusCode::NOT_FOUND,516
- "no-such-choice",517
- "That was not one of the choices.",518
- ));519
- }520
- };521
- self.branch_pending = false;522
- self.touch();523
- Ok(524
- Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))525
- .header(header::LOCATION, location),526
- )527
- }528
-529
- pub fn put(&mut self) -> Outcome {530
- if self.put_applied {531
- return Outcome::empty(StatusCode::NO_CONTENT);532
- }533
- self.put_applied = true;534
- self.length += 0.01;535
- self.touch();536
- Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))537
- }538
-539
- pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {540
- for op in ops {541
- if op.op != "test" {542
- return Err(Problem::new(543
- StatusCode::UNPROCESSABLE_ENTITY,544
- "wishful-thinking",545
- "The server accepts only the truth.",546
- )547
- .with("op", op.op.clone()));548
- }549
- let actual = match op.path.as_str() {550
- "/length" => self.current_length(),551
- "/girth" => self.girth,552
- "/foreskin" => self.foreskin.unwrap_or(0.0),553
- _ => {554
- return Err(Problem::new(555
- StatusCode::UNPROCESSABLE_ENTITY,556
- "no-such-measurement",557
- "There is no such measurement.",558
- )559
- .with("path", op.path.clone()));560
- }561
- };562
- let Some(claimed) = op.value.as_f64() else {563
- return Err(Problem::new(564
- StatusCode::BAD_REQUEST,565
- "malformed-patch",566
- "A test requires a numeric value.",567
- ));568
- };569
- if op.path == "/length" && claimed >= actual + 1.0 {570
- self.shame += 2;571
- self.touch();572
- return Err(exaggeration());573
- }574
- if !honest(claimed, actual) {575
- self.shame += 1;576
- self.touch();577
- return Err(578
- Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")579
- .with("path", op.path.clone()),580
- );581
- }582
- }583
- Ok(Outcome::json(584
- StatusCode::OK,585
- json!({ "detail": "Correct." }),586
- ))587
- }588
-589
- pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {590
- if self.foreskin.is_none() {591
- return Err(Problem::new(592
- StatusCode::GONE,593
- "foreskin-gone",594
- "The foreskin is gone. It will not be returning.",595
- ));596
- }597
- let Some(if_match) = if_match else {598
- return Err(Problem::new(599
- StatusCode::PRECONDITION_REQUIRED,600
- "confirmation-required",601
- "The server requires you to be sure. Send If-Match.",602
- ));603
- };604
- if !super::etag_matches(if_match, &self.etag()) {605
- return Err(Problem::new(606
- StatusCode::PRECONDITION_FAILED,607
- "stale-foreskin",608
- "The foreskin has changed since you last looked at it.",609
- ));610
- }611
- self.foreskin = None;612
- self.shame += 20;613
- self.touch();614
- Ok(Outcome::empty(StatusCode::NO_CONTENT))615
- }616
-617
- pub fn set_grip(&mut self, grip: Grip) {618
- self.grip = grip;619
- self.touch();620
- }621
-622
- pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {623
- if self.floor != Floor::Hotel {624
- return Err(Problem::new(625
- StatusCode::NOT_FOUND,626
- "no-portal",627
- "There is no captive portal here. There is only the regular kind of captivity.",628
- ));629
- }630
- self.wifi_accepted = true;631
- self.touch();632
- Ok(Outcome::json(633
- StatusCode::SEE_OTHER,634
- json!({ "detail": "Connected. The hotel does not want to know." }),635
- )636
- .header(header::LOCATION, format!("/runs/{}", self.id)))637
- }638
-639
- pub fn elder(640
- &mut self,641
- proxy_authorization: Option<&str>,642
- if_modified_since: Option<&str>,643
- ) -> Result<Outcome, Problem> {644
- if self.floor != Floor::Forum {645
- return Err(Problem::new(646
- StatusCode::NOT_FOUND,647
- "elder-absent",648
- "The Elder only posts on the forum.",649
- ));650
- }651
- if !self.verified {652
- let verification = |detail: &str| {653
- Problem::new(654
- StatusCode::PROXY_AUTHENTICATION_REQUIRED,655
- "verification-required",656
- detail,657
- )658
- .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")659
- };660
- let Some(credentials) = proxy_authorization else {661
- return Err(verification("The forum requires verification."));662
- };663
- let Some(claimed) =664
- parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())665
- else {666
- return Err(verification("The forum requires verification, in inches."));667
- };668
- let actual = self.current_length();669
- if claimed >= actual + 1.0 {670
- self.shame += 2;671
- return Err(self.elder_strike(exaggeration()));672
- }673
- if !honest(claimed, actual) {674
- self.shame += 1;675
- return Err(676
- self.elder_strike(verification("Verification failed. The forum can tell."))677
- );678
- }679
- self.verified = true;680
- self.touch();681
- }682
-683
- let last_modified =684
- httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");685
- let current = if_modified_since686
- .and_then(|d| httpdate::parse_http_date(d).ok())687
- .is_some_and(|since| since >= last_modified);688
- if current {689
- self.elder_beaten = true;690
- self.touch();691
- return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)692
- .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));693
- }694
- let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)695
- .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);696
- if self.filmed_at.is_some() {697
- return Ok(spreadsheet.header(698
- HeaderName::from_static("x-forum-note"),699
- "We have seen the footage.",700
- ));701
- }702
- Ok(spreadsheet)703
- }704
-705
- pub fn diagnose(706
- &mut self,707
- authorization: Option<&str>,708
- if_none_match: Option<&str>,709
- ) -> Result<Outcome, Problem> {710
- if self.floor != Floor::Clinic {711
- let floors = Floor::Clinic.number() - self.floor.number();712
- return Err(Problem::new(713
- StatusCode::TOO_EARLY,714
- "no-appointment",715
- format!("The clinic's next available appointment is {floors} floors away."),716
- ));717
- }718
- let intake = Problem::new(719
- StatusCode::UNAUTHORIZED,720
- "intake",721
- "The clinic requires authorization.",722
- );723
- let Some(authorization) = authorization else {724
- return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));725
- };726
- let truthful = parse_credentials(authorization, "bearer")727
- .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));728
- if !truthful {729
- let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];730
- self.intake_attempts += 1;731
- return Err(intake.header(732
- header::WWW_AUTHENTICATE,733
- format!(734
- "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""735
- ),736
- ));737
- }738
-739
- self.peyronie = false;740
- self.priapism_turns = 0;741
- let etag = self.origin_etag.clone();742
- if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {743
- self.cured = true;744
- return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)745
- .header(header::ETAG, etag)746
- .header(X_DIAGNOSIS, "normal"));747
- }748
- if !self.measured {749
- self.measured = true;750
- if !self.items.ruler {751
- self.shame += 15;752
- }753
- }754
- Ok(Outcome::json(755
- StatusCode::OK,756
- json!({757
- "diagnosis": "within normal range",758
- "measured_length": round2(self.length),759
- "you_said": round2(self.self_reported()),760
- "prescription": "Please stop.",761
- }),762
- )763
- .header(header::ETAG, etag))764
- }765
-}766
-767
-fn pump_bro_absent() -> Problem {768
- Problem::new(769
- StatusCode::NOT_FOUND,770
- "pump-bro-absent",771
- "Pump-Bro is not here.",772
- )773
-}@@ -0,0 +1,193 @@
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::{FLOOR_ALLOWANCE, FLOOR_TURNS, STARTING_EQ, ZIPPER},7
+ round2,8
+};9
+use crate::problem::Problem;10
+11
+impl Run {12
+ pub(super) fn next_floor(&mut self, paid: bool) {13
+ let Some(next) = self.floor.next() else {14
+ return;15
+ };16
+ self.floor = next;17
+ self.here = FloorState::default();18
+ self.warmed_up = false;19
+ self.eq = STARTING_EQ;20
+ self.suspicion = 0;21
+ self.mom = None;22
+ if paid {23
+ self.cash += FLOOR_ALLOWANCE;24
+ }25
+ self.touch();26
+ }27
+28
+ pub fn advance(&mut self) -> Result<Outcome, Problem> {29
+ match self.floor {30
+ Floor::Clinic => {31
+ return Err(Problem::new(32
+ StatusCode::CONFLICT,33
+ "no-further-floor",34
+ "There is no further floor. There is only the diagnosis.",35
+ ));36
+ }37
+ Floor::Forum if self.elder != Elder::Beaten => {38
+ return Err(Problem::new(39
+ StatusCode::CONFLICT,40
+ "elder-unaddressed",41
+ "The Elder has not been addressed.",42
+ )43
+ .with("elder", self.path("/elder")));44
+ }45
+ floor if floor.timed() && self.here.turns < FLOOR_TURNS => {46
+ return Err(Problem::new(47
+ StatusCode::TOO_EARLY,48
+ "floor-not-over",49
+ format!(50
+ "The floor is not over. {} turns remain.",51
+ FLOOR_TURNS - self.here.turns52
+ ),53
+ ));54
+ }55
+ _ => {}56
+ }57
+ if self.chance(ZIPPER) {58
+ return Err(self.end(Cause::Zipper));59
+ }60
+ let reverted = self.here.temp_length;61
+ self.next_floor(true);62
+ let mut body = json!({63
+ "detail": format!("You have arrived at {}.", self.floor.name()),64
+ "note": self.floor.note(),65
+ });66
+ if reverted > 0.0 {67
+ body["reverted"] = json!(round2(reverted));68
+ }69
+ Ok(70
+ Outcome::json(StatusCode::SEE_OTHER, body)71
+ .header(header::LOCATION, self.path("/floor")),72
+ )73
+ }74
+75
+ pub fn floor_view(&self) -> Value {76
+ let mut view = json!({77
+ "name": self.floor.name(),78
+ "note": self.floor.note(),79
+ "turns": self.here.turns,80
+ "turns_required": self.floor.timed().then_some(FLOOR_TURNS),81
+ "exit": { "href": self.path("/floor"), "method": "POST" },82
+ });83
+ match self.floor {84
+ Floor::LockerRoom => {85
+ view["others"] = json!([86
+ { "name": "Greg", "self_reported_length": 9.5 },87
+ { "name": "Dmitri", "self_reported_length": 8.75 },88
+ { "name": "a man in a towel", "self_reported_length": 11.0 },89
+ ]);90
+ }91
+ Floor::Hotel => view["portal"] = json!(self.path("/portal")),92
+ Floor::Forum => {93
+ view["thread"] = json!(super::FORUM_THREAD);94
+ view["elder"] = json!(self.path("/elder"));95
+ }96
+ Floor::Clinic => view["diagnosis"] = json!(self.path("/diagnosis")),97
+ _ => {}98
+ }99
+ if self.floor.shop_open() {100
+ view["shop"] = json!(self.path("/shop"));101
+ }102
+ view103
+ }104
+105
+ fn found(&self, item: Item, detail: &str) -> Outcome {106
+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail })).header(107
+ header::LOCATION,108
+ self.path(&format!("/inventory/{}", item.slug())),109
+ )110
+ }111
+112
+ pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {113
+ self.here.drawer_searches += 1;114
+ self.touch();115
+ let first = self.here.drawer_searches == 1;116
+ match self.floor {117
+ Floor::Bedroom if first && !self.items.ruler => {118
+ self.items.grant(Item::Ruler);119
+ self.shame += 10;120
+ Ok(self.found(121
+ Item::Ruler,122
+ "You now know. This is the first honest measurement of the run.",123
+ ))124
+ }125
+ Floor::Bathroom if first => {126
+ self.items.grant(Item::DoorLock);127
+ Ok(self.found(Item::DoorLock, "A door lock. It works once."))128
+ }129
+ Floor::LockerRoom => {130
+ self.shame += 3;131
+ Err(Problem::new(132
+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,133
+ "not-your-locker",134
+ "That locker belongs to Greg. The server cannot serve this request for legal reasons.",135
+ ))136
+ }137
+ Floor::Grandmas if first => {138
+ self.items.grant(Item::Cage);139
+ self.shame += 5;140
+ Ok(self.found(141
+ Item::Cage,142
+ "A small box. Inside was a cage. It is now on. The key is not in the box.",143
+ ))144
+ }145
+ Floor::Grandmas if self.items.cage => {146
+ self.items.grant(Item::CageKey);147
+ Ok(Outcome::json(148
+ StatusCode::OK,149
+ json!({ "detail": "The key was under where the box had been. The cage is off." }),150
+ ))151
+ }152
+ Floor::Hotel if first => {153
+ self.items.grant(Item::DndSign);154
+ self.items.grant(Item::IcePack);155
+ self.items.grant(Item::IcePack);156
+ Ok(self.found(157
+ Item::DndSign,158
+ "A Do Not Disturb sign, and two ice packs from the machine down the hall.",159
+ ))160
+ }161
+ Floor::Forum if first => {162
+ self.items.grant(Item::Spreadsheet);163
+ Ok(self.found(164
+ Item::Spreadsheet,165
+ "Your own spreadsheet. Every line is flat.",166
+ ))167
+ }168
+ Floor::Clinic => Err(Problem::new(169
+ StatusCode::FORBIDDEN,170
+ "doctors-drawer",171
+ "That is the doctor's drawer.",172
+ )),173
+ _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),174
+ }175
+ }176
+177
+ pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {178
+ if self.floor != Floor::Hotel {179
+ return Err(Problem::new(180
+ StatusCode::NOT_FOUND,181
+ "no-portal",182
+ "There is no captive portal here. There is only the regular kind of captivity.",183
+ ));184
+ }185
+ self.here.wifi_accepted = true;186
+ self.touch();187
+ Ok(Outcome::json(188
+ StatusCode::SEE_OTHER,189
+ json!({ "detail": "Connected. The hotel does not want to know." }),190
+ )191
+ .header(header::LOCATION, self.path("")))192
+ }193
+}@@ -0,0 +1,184 @@
1
+use axum::http::{StatusCode, header};2
+use serde_json::{Value, json};3
+4
+use super::{5
+ Cause, Elder, Floor, Outcome, Run, Story,6
+ balance::{EXAGGERATION, PATIENCE},7
+ body::exaggeration,8
+ honest, parse_credentials,9
+};10
+use crate::{headers::X_FORUM_NOTE, problem::Problem};11
+12
+pub const FORUM_THREAD: &str = "/forum/threads/2009";13
+pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";14
+15
+const SPREADSHEET: &str = "\16
+date,length,girth,technique,notes,honest?17
+2007-01-03,7.50,5.50,wet,baseline,no18
+2007-06-11,7.50,5.50,wet,feeling fuller,no19
+2008-01-02,7.50,5.50,dry,new routine,no20
+2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no21
+2009-06-14,7.50,5.50,wet,progress is slow but real,no22
+";23
+24
+pub fn forum_thread() -> Value {25
+ json!({26
+ "title": "The definitive jelqing FAQ (updated 2009)",27
+ "posts": [28
+ { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },29
+ { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },30
+ { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },31
+ { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },32
+ { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },33
+ { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },34
+ { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },35
+ ],36
+ })37
+}38
+39
+fn verification(detail: &str) -> Problem {40
+ Problem::new(41
+ StatusCode::PROXY_AUTHENTICATION_REQUIRED,42
+ "verification-required",43
+ detail,44
+ )45
+ .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")46
+}47
+48
+impl Run {49
+ pub(super) fn branch_offer(&self) -> Outcome {50
+ let forum = self.path("/branches/forum");51
+ let close = self.path("/branches/close-tab");52
+ Outcome::json(53
+ StatusCode::MULTIPLE_CHOICES,54
+ json!({55
+ "detail": "A browser tab is open. It has been open for some time.",56
+ "choices": [57
+ { "href": forum, "method": "POST", "title": "Read the forum" },58
+ { "href": close, "method": "POST", "title": "Close the tab" },59
+ ],60
+ }),61
+ )62
+ .header(63
+ header::LINK,64
+ format!(65
+ "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""66
+ ),67
+ )68
+ }69
+70
+ pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {71
+ if self.story != Story::Pending {72
+ return Err(Problem::new(73
+ StatusCode::CONFLICT,74
+ "no-decision-pending",75
+ "There is no decision pending.",76
+ ));77
+ }78
+ let (story, location, detail) = match choice {79
+ "forum" => {80
+ self.shame += 3;81
+ (82
+ Story::ReadForum,83
+ self.path("/techniques"),84
+ "The thread is from 2009. A technique called uli is discussed at length.",85
+ )86
+ }87
+ "close-tab" => {88
+ self.suspicion = 0;89
+ (90
+ Story::ClosedTab,91
+ self.path(""),92
+ "The tab is closed. Nobody saw it.",93
+ )94
+ }95
+ _ => {96
+ return Err(Problem::new(97
+ StatusCode::NOT_FOUND,98
+ "no-such-choice",99
+ "That was not one of the choices.",100
+ ));101
+ }102
+ };103
+ self.story = story;104
+ self.touch();105
+ Ok(106
+ Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))107
+ .header(header::LOCATION, location),108
+ )109
+ }110
+111
+ fn elder_strike(&mut self, problem: Problem) -> Problem {112
+ self.touch();113
+ let Elder::Unverified { strikes } = &mut self.elder else {114
+ return problem;115
+ };116
+ *strikes += 1;117
+ if *strikes < PATIENCE {118
+ return problem;119
+ }120
+ self.end(Cause::Banned)121
+ }122
+123
+ /// Failed verification counts toward a ban even on GET, the way repeated bad credentials lock an account.124
+ fn verify_member(&mut self, proxy_authorization: Option<&str>) -> Result<(), Problem> {125
+ if !matches!(self.elder, Elder::Unverified { .. }) {126
+ return Ok(());127
+ }128
+ let Some(credentials) = proxy_authorization else {129
+ return Err(verification("The forum requires verification."));130
+ };131
+ let Some(claimed) =132
+ parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())133
+ else {134
+ return Err(verification("The forum requires verification, in inches."));135
+ };136
+ let actual = self.current_length();137
+ if claimed >= actual + EXAGGERATION {138
+ self.shame += 2;139
+ return Err(self.elder_strike(exaggeration()));140
+ }141
+ if !honest(claimed, actual) {142
+ self.shame += 1;143
+ return Err(self.elder_strike(verification("Verification failed. The forum can tell.")));144
+ }145
+ self.elder = Elder::Verified;146
+ self.touch();147
+ Ok(())148
+ }149
+150
+ pub fn elder(151
+ &mut self,152
+ proxy_authorization: Option<&str>,153
+ if_modified_since: Option<&str>,154
+ ) -> Result<Outcome, Problem> {155
+ if self.floor != Floor::Forum {156
+ return Err(Problem::new(157
+ StatusCode::NOT_FOUND,158
+ "elder-absent",159
+ "The Elder only posts on the forum.",160
+ ));161
+ }162
+ self.verify_member(proxy_authorization)?;163
+164
+ let last_modified =165
+ httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");166
+ let current = if_modified_since167
+ .and_then(|d| httpdate::parse_http_date(d).ok())168
+ .is_some_and(|since| since >= last_modified);169
+ if current {170
+ if self.elder != Elder::Beaten {171
+ self.elder = Elder::Beaten;172
+ self.touch();173
+ }174
+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)175
+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));176
+ }177
+ let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)178
+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);179
+ if self.filmed_at.is_some() {180
+ return Ok(spreadsheet.header(X_FORUM_NOTE, "We have seen the footage."));181
+ }182
+ Ok(spreadsheet)183
+ }184
+}@@ -0,0 +1,148 @@
1
+use std::{ops::ControlFlow, time::SystemTime};2
+3
+use axum::http::{StatusCode, header};4
+use rand::Rng;5
+use serde_json::json;6
+7
+use super::{8
+ Cause, Floor, Outcome, Run,9
+ balance::{10
+ COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING, DEGLOVING_INTENSITY, FRIEND, HOT_EQ,11
+ PREMATURE_EQ, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,12
+ SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,13
+ },14
+ techniques::Training,15
+};16
+use crate::problem::Problem;17
+18
+type Interruption = Result<ControlFlow<Outcome>, Problem>;19
+20
+impl Run {21
+ /// Accidents tied to a specific technique.22
+ pub(super) fn mishaps(&mut self, training: Training, intensity: i32) -> Interruption {23
+ if training == Training::Dry && intensity >= DEGLOVING_INTENSITY && self.chance(DEGLOVING) {24
+ return Err(self.end(Cause::Degloved));25
+ }26
+ Ok(ControlFlow::Continue(()))27
+ }28
+29
+ /// Everything that can go wrong on a committed turn before the roll.30
+ pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {31
+ let odds = if self.eq <= DANGER_EQ {32
+ RUPTURE_IN_BAND33
+ } else if self.eq <= HOT_EQ {34
+ RUPTURE_WARM35
+ } else {36
+ RUPTURE_HOT37
+ };38
+ if self.chance(odds) {39
+ return Err(self.rupture());40
+ }41
+ if self.floor.has_window() && !self.items.blinds && self.chance(WINDOW) {42
+ self.shame += 30;43
+ self.filmed_at.get_or_insert_with(SystemTime::now);44
+ return Ok(ControlFlow::Break(45
+ Outcome::json(46
+ StatusCode::CREATED,47
+ json!({ "detail": "A neighbour was at the window. A recording has been created. It has been shared." }),48
+ )49
+ .header(header::LOCATION, self.path("/footage")),50
+ ));51
+ }52
+ if self.floor == Floor::LockerRoom && self.chance(FRIEND) {53
+ self.shame += 1;54
+ return Err(Problem::new(55
+ StatusCode::USE_PROXY,56
+ "friend",57
+ "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",58
+ ));59
+ }60
+ if self.conditions.peyronie && self.chance(CURVE_MISSES) {61
+ return Err(Problem::new(62
+ StatusCode::MISDIRECTED_REQUEST,63
+ "misdirected",64
+ "The technique landed slightly to the left. It was meant for a different server.",65
+ ));66
+ }67
+ if intensity <= 2 && self.eq < 50 {68
+ self.eq = (self.eq - 10).max(0);69
+ return Err(Problem::new(70
+ StatusCode::REQUEST_TIMEOUT,71
+ "lost-it",72
+ "The request took too long. It was lost.",73
+ )74
+ .header(header::CONNECTION, "close"));75
+ }76
+ if self.eq > PREMATURE_EQ {77
+ if self.chance(RUPTURE_PREMATURE) {78
+ return Err(self.rupture());79
+ }80
+ self.shame += 5;81
+ self.next_floor(false);82
+ return Err(Problem::new(83
+ StatusCode::TOO_EARLY,84
+ "premature",85
+ "The floor has ended early. Everyone involved has agreed not to discuss it.",86
+ ));87
+ }88
+ if self.eq < COLD_EQ {89
+ return Err(Problem::new(90
+ StatusCode::NOT_FOUND,91
+ "cold",92
+ "The resource exists. It is cold.",93
+ ));94
+ }95
+ Ok(ControlFlow::Continue(()))96
+ }97
+98
+ /// The enemies suspicion summons on floors without Mom.99
+ pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {100
+ let visits = u32::from(self.suspicion) + 1;101
+ let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)102
+ && self.rng.random_ratio(visits, WANDERER_VISIT);103
+ if self.suspicion < SUSPICION_LIMIT && !wanders_in {104
+ return Ok(());105
+ }106
+ match self.floor {107
+ Floor::Bathroom => {108
+ self.suspicion = 0;109
+ self.touch();110
+ if self.items.door_locks > 0 {111
+ self.items.door_locks -= 1;112
+ return Ok(());113
+ }114
+ self.here.roommate_told = true;115
+ Err(Problem::new(116
+ StatusCode::CONFLICT,117
+ "roommate",118
+ "The roommate needs the bathroom. He will mention this to people.",119
+ ))120
+ }121
+ Floor::LockerRoom => {122
+ self.suspicion = 0;123
+ self.shame += 5;124
+ self.touch();125
+ Err(Problem::new(126
+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,127
+ "gym-staff",128
+ "Gym staff have been notified. The server cannot serve this request for legal reasons.",129
+ ))130
+ }131
+ Floor::Hotel => {132
+ self.suspicion = 0;133
+ self.touch();134
+ if self.items.dnd_sign {135
+ return Ok(());136
+ }137
+ self.shame += 3;138
+ Err(Problem::new(139
+ StatusCode::UNAUTHORIZED,140
+ "housekeeping",141
+ "Housekeeping has entered. Housekeeping accepts no excuses.",142
+ )143
+ .header(header::WWW_AUTHENTICATE, "Excuse realm=\"housekeeping\""))144
+ }145
+ _ => Ok(()),146
+ }147
+ }148
+}@@ -0,0 +1,192 @@
1
+use std::collections::HashMap;2
+3
+use axum::http::{StatusCode, header};4
+use rand::Rng;5
+6
+use super::{7
+ Cause, Floor, Run,8
+ balance::{PATIENCE, VIRAL_ERRAND},9
+ parse_credentials,10
+};11
+use crate::problem::Problem;12
+13
+const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];14
+const THANKSGIVING_EXCUSES: [&str; 3] = ["on a call", "folding laundry", "praying"];15
+const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];16
+const TRUE_WHEN_PULLED: &str = "pulled a muscle";17
+18
+const BEDROOM_ERRANDS: [&str; 4] = [19
+ "She has brought laundry.",20
+ "She wants to know if you have eaten.",21
+ "She needs the wifi password.",22
+ "She would like to talk about your future.",23
+];24
+const THANKSGIVING_ERRANDS: [&str; 4] = [25
+ "Grandma would like to see you.",26
+ "Dinner is in ten minutes.",27
+ "She needs help with the folding table.",28
+ "An uncle has asked where you went.",29
+];30
+31
+fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {32
+ let (scheme, rest) = authorization.trim().split_once(' ')?;33
+ if !scheme.eq_ignore_ascii_case("digest") {34
+ return None;35
+ }36
+ Some(37
+ rest.split(',')38
+ .filter_map(|pair| {39
+ let (key, value) = pair.split_once('=')?;40
+ Some((41
+ key.trim().to_ascii_lowercase(),42
+ value.trim().trim_matches('"').to_owned(),43
+ ))44
+ })45
+ .collect(),46
+ )47
+}48
+49
+fn spoken(authorization: &str) -> String {50
+ authorization51
+ .split_once(' ')52
+ .map(|(_, rest)| rest.trim().trim_matches('"').to_ascii_lowercase())53
+ .unwrap_or_default()54
+}55
+56
+impl Run {57
+ pub fn excuses(&self) -> Vec<&'static str> {58
+ if self.floor.number() < Floor::Grandmas.number() {59
+ return EXCUSES.to_vec();60
+ }61
+ EXCUSES62
+ .into_iter()63
+ .chain(THANKSGIVING_EXCUSES)64
+ .filter(|e| !self.heard.iter().any(|h| h == e))65
+ .collect()66
+ }67
+68
+ pub(super) fn errand(&mut self, summoned: bool) -> &'static str {69
+ if summoned {70
+ return "She heard a noise.";71
+ }72
+ if self.filmed_at.is_some() && self.chance(VIRAL_ERRAND) {73
+ return "A neighbour has sent her something.";74
+ }75
+ let errands = if self.floor == Floor::Grandmas {76
+ THANKSGIVING_ERRANDS77
+ } else {78
+ BEDROOM_ERRANDS79
+ };80
+ errands[self.rng.random_range(0..errands.len())]81
+ }82
+83
+ /// Mom answers every request on the run until she has been given something she believes.84
+ pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {85
+ let Some((errand, nonce)) = self.mom.as_ref().map(|v| (v.errand, v.nonce.clone())) else {86
+ return Ok(());87
+ };88
+ let digest = self.floor == Floor::Grandmas;89
+ let challenge_value = if digest {90
+ format!("Digest realm=\"household\", nonce=\"{nonce}\"")91
+ } else {92
+ "Excuse realm=\"household\"".to_owned()93
+ };94
+ let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);95
+96
+ let Some(authorization) = authorization else {97
+ return Err(challenge(98
+ Problem::new(99
+ StatusCode::UNAUTHORIZED,100
+ "someone-at-the-door",101
+ format!("Someone is at the door. {errand}"),102
+ )103
+ .with("inventory", self.path("/inventory")),104
+ ));105
+ };106
+107
+ if TRUTHS.contains(&spoken(authorization).as_str()) {108
+ self.shame += 10;109
+ self.leave_door();110
+ return Ok(());111
+ }112
+ if parse_credentials(authorization, "basic").is_some() {113
+ return Err(self.mom_strike(challenge(Problem::new(114
+ StatusCode::UNAUTHORIZED,115
+ "basic",116
+ "Mom does not accept Basic authentication. Nobody should.",117
+ ))));118
+ }119
+120
+ let excuse = if digest {121
+ let Some(params) = digest_params(authorization) else {122
+ return Err(self.mom_strike(challenge(Problem::new(123
+ StatusCode::UNAUTHORIZED,124
+ "digest-required",125
+ "Mom has started using Digest authentication.",126
+ ))));127
+ };128
+ if params.get("nonce") != Some(&nonce) {129
+ return Err(self.mom_strike(challenge(Problem::new(130
+ StatusCode::UNAUTHORIZED,131
+ "stale-nonce",132
+ "That was a different conversation.",133
+ ))));134
+ }135
+ params.get("username").cloned().unwrap_or_default()136
+ } else {137
+ parse_credentials(authorization, "excuse")138
+ .unwrap_or_default()139
+ .to_owned()140
+ };141
+ let excuse = excuse.to_ascii_lowercase();142
+143
+ if TRUTHS.contains(&excuse.as_str()) {144
+ self.shame += 10;145
+ self.leave_door();146
+ return Ok(());147
+ }148
+ let true_now = self.conditions.pulled_muscle && excuse == TRUE_WHEN_PULLED;149
+ if digest && self.heard.contains(&excuse) && !true_now {150
+ self.shame += 3;151
+ return Err(self.mom_strike(challenge(Problem::new(152
+ StatusCode::UNAUTHORIZED,153
+ "excuse-replayed",154
+ "Mom has heard that one before. She would have preferred the truth.",155
+ ))));156
+ }157
+ if true_now || self.excuses().contains(&excuse.as_str()) {158
+ self.heard.push(excuse);159
+ self.leave_door();160
+ return Ok(());161
+ }162
+ self.shame += 3;163
+ let detail = if digest {164
+ "Mom has not heard that one before. She does not believe it. She would have preferred the truth."165
+ } else {166
+ "Mom has not heard that one before. She does not believe it."167
+ };168
+ Err(self.mom_strike(challenge(Problem::new(169
+ StatusCode::UNAUTHORIZED,170
+ "excuse-rejected",171
+ detail,172
+ ))))173
+ }174
+175
+ fn mom_strike(&mut self, problem: Problem) -> Problem {176
+ self.touch();177
+ let Some(visit) = &mut self.mom else {178
+ return problem;179
+ };180
+ visit.strikes += 1;181
+ if visit.strikes < PATIENCE {182
+ return problem;183
+ }184
+ self.end(Cause::Caught)185
+ }186
+187
+ fn leave_door(&mut self) {188
+ self.mom = None;189
+ self.suspicion = 0;190
+ self.touch();191
+ }192
+}@@ -0,0 +1,210 @@
1
+use super::balance::STARTING_ICE_PACKS;2
+3
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]4
+pub enum Item {5
+ WarmTowel,6
+ IcePack,7
+ Ruler,8
+ DoorLock,9
+ DndSign,10
+ Blinds,11
+ Pump,12
+ PumpPro,13
+ Extender,14
+ Ring,15
+ Supplement,16
+ Cage,17
+ CageKey,18
+ Spreadsheet,19
+ TeapotSpout,20
+}21
+22
+pub const SHOP: [Item; 11] = [23
+ Item::Pump,24
+ Item::PumpPro,25
+ Item::Extender,26
+ Item::Ring,27
+ Item::Supplement,28
+ Item::DoorLock,29
+ Item::DndSign,30
+ Item::Blinds,31
+ Item::Ruler,32
+ Item::IcePack,33
+ Item::CageKey,34
+];35
+36
+impl Item {37
+ const ALL: [Self; 15] = [38
+ Self::WarmTowel,39
+ Self::IcePack,40
+ Self::Ruler,41
+ Self::DoorLock,42
+ Self::DndSign,43
+ Self::Blinds,44
+ Self::Pump,45
+ Self::PumpPro,46
+ Self::Extender,47
+ Self::Ring,48
+ Self::Supplement,49
+ Self::Cage,50
+ Self::CageKey,51
+ Self::Spreadsheet,52
+ Self::TeapotSpout,53
+ ];54
+55
+ pub fn slug(self) -> &'static str {56
+ match self {57
+ Self::WarmTowel => "warm-towel",58
+ Self::IcePack => "ice-pack",59
+ Self::Ruler => "ruler",60
+ Self::DoorLock => "door-lock",61
+ Self::DndSign => "do-not-disturb-sign",62
+ Self::Blinds => "window-blinds",63
+ Self::Pump => "pump",64
+ Self::PumpPro => "pump-pro",65
+ Self::Extender => "extender",66
+ Self::Ring => "cock-ring",67
+ Self::Supplement => "supplement",68
+ Self::Cage => "chastity-cage",69
+ Self::CageKey => "cage-key",70
+ Self::Spreadsheet => "spreadsheet",71
+ Self::TeapotSpout => "teapot-spout",72
+ }73
+ }74
+75
+ pub fn parse(slug: &str) -> Option<Self> {76
+ Self::ALL.into_iter().find(|item| item.slug() == slug)77
+ }78
+79
+ pub fn description(self) -> &'static str {80
+ match self {81
+ Self::WarmTowel => {82
+ "Reusable. Satisfies the warm-up precondition. Use technique=warmup."83
+ }84
+ Self::IcePack => "Lowers EQ by 40. Use technique=ice.",85
+ Self::Ruler => "Measures honestly. The length resource now carries a Content-Length.",86
+ Self::DoorLock => "Keeps the roommate out once.",87
+ Self::DndSign => "Works on housekeeping. Mom does not read signs.",88
+ Self::Blinds => "The neighbour has a good camera. The blinds are better.",89
+ Self::Pump => "A pump.",90
+ Self::PumpPro => "Use technique=pump. Gains are temporary.",91
+ Self::Extender => "Passive gains each turn. Takes up room. Can be removed with DELETE.",92
+ Self::Ring => {93
+ "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE."94
+ }95
+ Self::Supplement => "Use technique=supplement.",96
+ Self::Cage => "Locked.",97
+ Self::CageKey => "Pump-Bro does not ask.",98
+ Self::Spreadsheet => "Charts your progress. Every line is flat.",99
+ Self::TeapotSpout => "Does nothing. Cannot be dropped.",100
+ }101
+ }102
+103
+ pub fn pitch(self) -> &'static str {104
+ match self {105
+ Self::PumpPro => "A pump, but professional.",106
+ Self::Extender => "Passive gains every turn. Takes up room.",107
+ Self::Ring => "Doubles girth gains. Pump-Bro does not discuss the risks.",108
+ Self::Supplement => "Clinically formulated. Ships from overseas.",109
+ Self::DndSign => "Housekeeping respects it.",110
+ Self::Blinds => "The neighbour has a good camera.",111
+ Self::Ruler => "Pump-Bro does not recommend it.",112
+ Self::IcePack => "Cold.",113
+ _ => self.description(),114
+ }115
+ }116
+117
+ pub fn price(self) -> Option<u32> {118
+ match self {119
+ Self::Pump => Some(10),120
+ Self::PumpPro | Self::Extender | Self::CageKey => Some(15),121
+ Self::Ring => Some(8),122
+ Self::Supplement | Self::DoorLock | Self::DndSign | Self::Blinds | Self::Ruler => {123
+ Some(5)124
+ }125
+ Self::IcePack => Some(3),126
+ Self::WarmTowel | Self::Cage | Self::Spreadsheet | Self::TeapotSpout => None,127
+ }128
+ }129
+}130
+131
+pub struct Items {132
+ pub ruler: bool,133
+ pub ice_packs: u32,134
+ pub door_locks: u32,135
+ pub dnd_sign: bool,136
+ pub blinds: bool,137
+ pub pump_pro: bool,138
+ pub extender: bool,139
+ pub ring: bool,140
+ pub supplements: u32,141
+ pub cage: bool,142
+ pub spreadsheet: bool,143
+ pub teapot_spout: bool,144
+}145
+146
+impl Default for Items {147
+ fn default() -> Self {148
+ Self {149
+ ruler: false,150
+ ice_packs: STARTING_ICE_PACKS,151
+ door_locks: 0,152
+ dnd_sign: false,153
+ blinds: false,154
+ pump_pro: false,155
+ extender: false,156
+ ring: false,157
+ supplements: 0,158
+ cage: false,159
+ spreadsheet: false,160
+ teapot_spout: false,161
+ }162
+ }163
+}164
+165
+impl Items {166
+ pub fn has(&self, item: Item) -> bool {167
+ match item {168
+ Item::WarmTowel => true,169
+ Item::IcePack => self.ice_packs > 0,170
+ Item::Ruler => self.ruler,171
+ Item::DoorLock => self.door_locks > 0,172
+ Item::DndSign => self.dnd_sign,173
+ Item::Blinds => self.blinds,174
+ Item::PumpPro => self.pump_pro,175
+ Item::Extender => self.extender,176
+ Item::Ring => self.ring,177
+ Item::Supplement => self.supplements > 0,178
+ Item::Cage => self.cage,179
+ Item::Spreadsheet => self.spreadsheet,180
+ Item::TeapotSpout => self.teapot_spout,181
+ Item::Pump | Item::CageKey => false,182
+ }183
+ }184
+185
+ pub fn owned(&self) -> Vec<Item> {186
+ Item::ALL187
+ .into_iter()188
+ .filter(|&item| self.has(item))189
+ .collect()190
+ }191
+192
+ pub fn grant(&mut self, item: Item) {193
+ match item {194
+ Item::IcePack => self.ice_packs += 1,195
+ Item::Ruler => self.ruler = true,196
+ Item::DoorLock => self.door_locks += 1,197
+ Item::DndSign => self.dnd_sign = true,198
+ Item::Blinds => self.blinds = true,199
+ Item::PumpPro => self.pump_pro = true,200
+ Item::Extender => self.extender = true,201
+ Item::Ring => self.ring = true,202
+ Item::Supplement => self.supplements += 1,203
+ Item::Cage => self.cage = true,204
+ Item::CageKey => self.cage = false,205
+ Item::Spreadsheet => self.spreadsheet = true,206
+ Item::TeapotSpout => self.teapot_spout = true,207
+ Item::WarmTowel | Item::Pump => {}208
+ }209
+ }210
+}@@ -1,677 +0,0 @@
1
-use std::{2
- fmt::Write as _,3
- time::{Duration, Instant, SystemTime},4
-};5
-6
-use axum::http::{StatusCode, header};7
-use rand::Rng;8
-use serde_json::json;9
-10
-use super::{11
- CLINIC_ALLOW, FORUM_THREAD, Floor, Grip, Lube, Outcome, REFRACTORY, Run, SERVER_TIMING,12
- SUSPICION_LIMIT, Technique, catalog::LubeRule, catalog::Profile, not_extended, round3,13
-};14
-use crate::{game::LUBRICANTS, problem::Problem};15
-16
-const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);17
-const PRIAPISM_TURNS: u8 = 5;18
-const PRIAPISM_EQ: i32 = 75;19
-const DEGLOVED: &str =20
- "The outer layer has been removed in one piece. It will not be returning. The run has ended.";21
-22
-impl Run {23
- pub fn jelq(24
- &mut self,25
- techniques: &[Technique],26
- intensity: i32,27
- lube: Option<Lube>,28
- now: Instant,29
- ) -> Result<Outcome, Problem> {30
- self.check_refractory(now)?;31
- if self.branch_pending {32
- return Ok(self.branch_offer());33
- }34
- match self.floor {35
- Floor::Forum => {36
- return Ok(Outcome::json(37
- StatusCode::SEE_OTHER,38
- json!({ "detail": "Refer to the forum." }),39
- )40
- .header(header::LOCATION, FORUM_THREAD));41
- }42
- Floor::Clinic => {43
- return Err(Problem::new(44
- StatusCode::METHOD_NOT_ALLOWED,45
- "not-in-the-clinic",46
- "JELQ is not performed in the clinic.",47
- )48
- .header(header::ALLOW, CLINIC_ALLOW));49
- }50
- _ => {}51
- }52
- if self.items.cage {53
- return Err(Problem::new(54
- StatusCode::LOCKED,55
- "caged",56
- "The cage is locked. The key is not in the box.",57
- ));58
- }59
- match *techniques {60
- [technique] => self.single(technique, intensity, lube, now),61
- [a, b] => self.combo(a, b, intensity, lube, now),62
- _ => Err(Problem::new(63
- StatusCode::BAD_REQUEST,64
- "too-many-techniques",65
- "Two techniques at most. The server has limits even if you do not.",66
- )),67
- }68
- }69
-70
- fn single(71
- &mut self,72
- technique: Technique,73
- intensity: i32,74
- lube: Option<Lube>,75
- now: Instant,76
- ) -> Result<Outcome, Problem> {77
- match technique {78
- Technique::Warmup => return self.warm_up(),79
- Technique::Ice => return self.ice(),80
- Technique::Supplement => return self.supplement(),81
- Technique::Edge => {}82
- _ => self.edges = 0,83
- }84
- self.prepare(technique, lube)?;85
- self.check_storage()?;86
- self.floor_enemy()?;87
- match technique {88
- Technique::Edge => return self.edge(now),89
- Technique::Pump => return self.pump(now),90
- _ => {}91
- }92
- let profile = technique.profile().expect("training technique");93
- self.commit(intensity, lube, now, 1);94
- if technique == Technique::Dry && intensity >= 8 && self.rng.random_ratio(1, 60) {95
- self.end("degloved", DEGLOVED);96
- return Err(Problem::new(StatusCode::GONE, "degloved", DEGLOVED));97
- }98
- if let Some(interrupted) = self.hazards(intensity)? {99
- return Ok(interrupted);100
- }101
- let result = self.roll(technique, &profile, intensity, lube);102
- Ok(self.gossip(result?))103
- }104
-105
- fn combo(106
- &mut self,107
- a: Technique,108
- b: Technique,109
- intensity: i32,110
- lube: Option<Lube>,111
- now: Instant,112
- ) -> Result<Outcome, Problem> {113
- let (Some(first), Some(second)) = (a.profile(), b.profile()) else {114
- return Err(Problem::new(115
- StatusCode::BAD_REQUEST,116
- "combo-invalid",117
- "Only training techniques can be combined.",118
- ));119
- };120
- if a == b {121
- return Err(Problem::new(122
- StatusCode::BAD_REQUEST,123
- "combo-invalid",124
- "A technique cannot be combined with itself. The server has checked.",125
- ));126
- }127
- self.edges = 0;128
- self.prepare(a, lube)?;129
- self.prepare(b, lube)?;130
- self.check_storage()?;131
- self.floor_enemy()?;132
- self.commit(intensity, lube, now, 2);133
- if let Some(interrupted) = self.hazards(intensity)? {134
- return Ok(interrupted);135
- }136
-137
- let mut statuses = Vec::new();138
- for (technique, profile) in [(a, first), (b, second)] {139
- let result = self.roll(technique, &profile, intensity, lube);140
- if self.ended.is_some() {141
- return result;142
- }143
- let status = match &result {144
- Ok(outcome) => outcome.status(),145
- Err(problem) => problem.status(),146
- };147
- statuses.push((technique, status));148
- }149
-150
- let mut xml = String::from(151
- "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",152
- );153
- for (technique, status) in statuses {154
- let _ = write!(155
- xml,156
- " <D:response>\n <D:href>/runs/{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",157
- self.id,158
- technique.name(),159
- status.as_u16(),160
- status.canonical_reason().unwrap_or_default(),161
- );162
- }163
- xml.push_str(164
- " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",165
- );166
- Ok(Outcome::raw(167
- StatusCode::MULTI_STATUS,168
- "application/xml; charset=utf-8",169
- xml,170
- ))171
- }172
-173
- fn prepare(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {174
- if technique == Technique::Uli && !self.forum_read {175
- return Err(Problem::new(176
- StatusCode::FORBIDDEN,177
- "technique-locked",178
- "This technique is discussed on the forum. You have not been to the forum.",179
- ));180
- }181
- if !self.warmed_up {182
- return Err(Problem::new(183
- StatusCode::PRECONDITION_FAILED,184
- "cold-start",185
- "Warm-up was skipped. The server has noted this.",186
- )187
- .with("technique", Technique::Warmup.name()));188
- }189
- if technique == Technique::Pump && !self.items.pump_pro {190
- return Err(Problem::new(191
- StatusCode::FAILED_DEPENDENCY,192
- "no-pump",193
- "The pump technique depends on a pump.",194
- ));195
- }196
- let Some(profile) = technique.profile() else {197
- return Ok(());198
- };199
- match (profile.lube, lube) {200
- (LubeRule::Required, None) => {201
- return Err(Problem::new(202
- StatusCode::UNSUPPORTED_MEDIA_TYPE,203
- "lubricant-required",204
- format!(205
- "The {} technique requires a lubricant. None was declared.",206
- technique.name()207
- ),208
- )209
- .with("lubricants", json!(LUBRICANTS)));210
- }211
- (LubeRule::Forbidden, Some(_)) => {212
- return Err(Problem::new(213
- StatusCode::UNSUPPORTED_MEDIA_TYPE,214
- "lubricant-declared",215
- "The dry technique was requested. A lubricant was declared.",216
- ));217
- }218
- _ => {}219
- }220
- if let Some((min, max)) = profile.foreskin {221
- self.check_foreskin(min, max)?;222
- }223
- Ok(())224
- }225
-226
- fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {227
- let Some(f) = self.foreskin else {228
- if min <= 0.0 {229
- return Ok(());230
- }231
- return Err(Problem::new(232
- StatusCode::GONE,233
- "foreskin-gone",234
- "This technique requires a foreskin. The foreskin is gone.",235
- )236
- .header(237
- header::CONTENT_LOCATION,238
- format!("/runs/{}/foreskin", self.id),239
- ));240
- };241
- if f < min {242
- return Err(Problem::new(243
- StatusCode::LENGTH_REQUIRED,244
- "foreskin-insufficient",245
- format!(246
- "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."247
- ),248
- )249
- .with("required", min)250
- .with("available", super::round2(f)));251
- }252
- if f > max {253
- return Err(Problem::new(254
- StatusCode::PAYLOAD_TOO_LARGE,255
- "foreskin-excessive",256
- format!(257
- "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."258
- ),259
- )260
- .with("maximum", max)261
- .with("present", super::round2(f)));262
- }263
- Ok(())264
- }265
-266
- fn check_storage(&self) -> Result<(), Problem> {267
- if self.items.extender && self.girth > 5.0 {268
- return Err(Problem::new(269
- StatusCode::INSUFFICIENT_STORAGE,270
- "jeans-full",271
- "The jeans are full. The extender is occupying the remaining storage.",272
- )273
- .with("extender", format!("/runs/{}/inventory/extender", self.id)));274
- }275
- Ok(())276
- }277
-278
- fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {279
- self.touch();280
- self.turn += 1;281
- self.floor_turns += 1;282
- self.realize();283
- if self.items.extender {284
- self.length += 0.004;285
- }286
- self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));287
- if self.stamina <= 0 {288
- self.stamina = 0;289
- self.refractory_until = Some(now + *REFRACTORY);290
- }291
- let chill = if self.floor == Floor::LockerRoom {292
- 8293
- } else {294
- 0295
- };296
- let wobble = self.rng.random_range(-3..=3);297
- self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);298
-299
- let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));300
- if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {301
- suspicion *= 2;302
- }303
- if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {304
- suspicion += 2;305
- }306
- let was_below = self.suspicion < SUSPICION_LIMIT;307
- self.suspicion =308
- (i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)) as u8;309
- if self.floor.has_mom() && !self.mom_present {310
- let summoned = was_below && self.suspicion == SUSPICION_LIMIT;311
- if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {312
- self.mom_present = true;313
- self.mom_strikes = 0;314
- self.mom_errand = self.errand(summoned);315
- self.nonce = self.fresh_nonce();316
- }317
- }318
-319
- if let Some(observer) = self.floor.observer()320
- && self.rng.random_ratio(1, 6)321
- {322
- self.observed = Some(observer);323
- self.shame += 1;324
- if observer == "cat" && self.rng.random_ratio(1, 5) {325
- self.towel_stolen = true;326
- }327
- }328
- if intensity >= 9 && !self.pulled_muscle && self.rng.random_ratio(1, 12) {329
- self.pulled_muscle = true;330
- self.stamina = self.stamina.min(self.max_stamina());331
- }332
- if self.items.ring && self.eq > 85 && self.rng.random_ratio(1, 10) {333
- self.priapism_turns = PRIAPISM_TURNS;334
- }335
- if self.priapism_turns > 0 {336
- self.priapism_turns -= 1;337
- self.eq = self.eq.max(PRIAPISM_EQ);338
- }339
- if self.turn == super::BRANCH_TURN {340
- self.branch_pending = true;341
- }342
- }343
-344
- fn hazards(&mut self, intensity: i32) -> Result<Option<Outcome>, Problem> {345
- let odds = match self.eq {346
- ..=70 => 250,347
- 71..=85 => 25,348
- _ => 10,349
- };350
- if self.rng.random_ratio(1, odds) {351
- return Err(self.rupture());352
- }353
- let windowed = matches!(self.floor, Floor::Bedroom | Floor::Grandmas | Floor::Hotel);354
- if windowed && !self.items.blinds && self.rng.random_ratio(1, 60) {355
- self.shame += 30;356
- self.filmed_at.get_or_insert_with(SystemTime::now);357
- return Ok(Some(358
- Outcome::json(359
- StatusCode::CREATED,360
- json!({ "detail": "A neighbour was at the window. A recording has been created. It has been shared." }),361
- )362
- .header(header::LOCATION, format!("/runs/{}/footage", self.id)),363
- ));364
- }365
- if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {366
- self.shame += 1;367
- return Err(Problem::new(368
- StatusCode::USE_PROXY,369
- "friend",370
- "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",371
- ));372
- }373
- if self.peyronie && self.rng.random_ratio(1, 3) {374
- return Err(Problem::new(375
- StatusCode::MISDIRECTED_REQUEST,376
- "misdirected",377
- "The technique landed slightly to the left. It was meant for a different server.",378
- ));379
- }380
- if intensity <= 2 && self.eq < 50 {381
- self.eq = (self.eq - 10).max(0);382
- return Err(Problem::new(383
- StatusCode::REQUEST_TIMEOUT,384
- "lost-it",385
- "The request took too long. It was lost.",386
- )387
- .header(header::CONNECTION, "close"));388
- }389
- if self.eq > 95 {390
- if self.rng.random_ratio(1, 3) {391
- return Err(self.rupture());392
- }393
- self.shame += 5;394
- self.next_floor(false);395
- return Err(Problem::new(396
- StatusCode::TOO_EARLY,397
- "premature",398
- "The floor has ended early. Everyone involved has agreed not to discuss it.",399
- )400
- .with("floor", self.floor.number()));401
- }402
- if self.eq < 30 {403
- return Err(Problem::new(404
- StatusCode::NOT_FOUND,405
- "cold",406
- "The resource exists. It is cold.",407
- )408
- .with("eq", self.eq));409
- }410
- Ok(None)411
- }412
-413
- fn roll(414
- &mut self,415
- technique: Technique,416
- profile: &Profile,417
- intensity: i32,418
- lube: Option<Lube>,419
- ) -> Result<Outcome, Problem> {420
- let natural: i32 = self.rng.random_range(1..=20);421
- let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };422
- let grip = match (self.grip, technique) {423
- (Grip::Underhand, Technique::Stretch) => 2,424
- (Grip::Underhand, Technique::Wet) => -1,425
- _ => 0,426
- };427
- let modifier = lube.map_or(0, Lube::modifier) + band + grip;428
- let dc = profile.dc + intensity / 3;429
- let total = natural + modifier;430
- let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");431
- let timed = |p: Problem| p.header(SERVER_TIMING, &timing);432
-433
- let scale = f64::from(intensity);434
- let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);435
- if self.teapot {436
- length *= 0.5;437
- girth *= 1.5;438
- }439
- if self.items.ring {440
- girth *= 2.0;441
- }442
- let rupture_eq = if self.items.ring { 80 } else { 90 };443
-444
- if natural == 1 {445
- let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);446
- if touches_foreskin && self.foreskin.is_some() {447
- self.foreskin = None;448
- self.shame += 25;449
- return Err(timed(450
- Problem::new(451
- StatusCode::GONE,452
- "foreskin-lost",453
- "The technique has removed the foreskin. It will not be returning.",454
- )455
- .header(456
- header::CONTENT_LOCATION,457
- format!("/runs/{}/foreskin", self.id),458
- ),459
- ));460
- }461
- if self.eq > 70 {462
- return Err(timed(self.rupture()));463
- }464
- if technique == Technique::Stretch && !self.teapot {465
- self.teapot = true;466
- self.items.teapot_spout = true;467
- self.shame += 5;468
- return Err(timed(Problem::new(469
- StatusCode::IM_A_TEAPOT,470
- "teapot",471
- "The subject is now short and stout. Length gains are halved for the rest of the floor.",472
- )));473
- }474
- if technique == Technique::Uli && !self.peyronie {475
- self.peyronie = true;476
- self.shame += 5;477
- return Err(timed(Problem::new(478
- StatusCode::MISDIRECTED_REQUEST,479
- "curvature",480
- "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",481
- )));482
- }483
- return Err(timed(not_extended()));484
- }485
- if natural == 2 && self.eq > 70 {486
- return Err(timed(self.rupture()));487
- }488
- if self.eq > rupture_eq && total < dc {489
- return Err(timed(self.rupture()));490
- }491
- if natural == 20 {492
- self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);493
- return Ok(Outcome::json(494
- StatusCode::PERMANENT_REDIRECT,495
- json!({496
- "detail": "Your length has moved permanently.",497
- "length": round3(length * 2.0),498
- "girth": round3(girth * 2.0),499
- }),500
- )501
- .header(header::LOCATION, format!("/runs/{}/length", self.id))502
- .header(SERVER_TIMING, timing));503
- }504
- if total < dc - 3 {505
- return Err(timed(not_extended()));506
- }507
- if total < dc {508
- return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));509
- }510
-511
- let outcome = if self.eq < 50 {512
- self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);513
- Outcome::json(514
- StatusCode::PARTIAL_CONTENT,515
- json!({516
- "detail": "Partial gains were applied. EQ was below the band.",517
- "length": round3(length / 2.0),518
- "girth": round3(girth / 2.0),519
- }),520
- )521
- } else if total >= dc + 5 {522
- self.gain(length, girth, profile.foreskin_growth);523
- Outcome::json(524
- StatusCode::OK,525
- json!({526
- "detail": "Gains were applied.",527
- "length": round3(length),528
- "girth": round3(girth),529
- }),530
- )531
- } else {532
- let eta = self.rng.random_range(1..=4);533
- self.pending.push(super::Pending {534
- due: self.turn + eta,535
- length,536
- girth,537
- });538
- self.gain(0.0, 0.0, profile.foreskin_growth);539
- Outcome::json(540
- StatusCode::ACCEPTED,541
- json!({542
- "detail": "Gains have been queued. They may arrive.",543
- "eta_turns": eta,544
- }),545
- )546
- };547
- Ok(outcome.header(SERVER_TIMING, timing))548
- }549
-550
- fn gossip(&mut self, outcome: Outcome) -> Outcome {551
- let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);552
- if !self.roommate_told || !reportable {553
- return outcome;554
- }555
- self.roommate_told = false;556
- let mut outcome = outcome;557
- outcome.status = StatusCode::ALREADY_REPORTED;558
- if let super::Payload::Json(body) = &mut outcome.payload {559
- body["detail"] = json!("The roommate has already told everyone.");560
- }561
- outcome562
- }563
-564
- fn warm_up(&mut self) -> Result<Outcome, Problem> {565
- if self.floor == Floor::Hotel && self.rng.random_ratio(1, 3) {566
- return Err(Problem::new(567
- StatusCode::SERVICE_UNAVAILABLE,568
- "service-unavailable",569
- "Service is unavailable. The server estimates next week. The server is being pessimistic.",570
- )571
- .header(572
- header::RETRY_AFTER,573
- httpdate::fmt_http_date(SystemTime::now() + WEEK),574
- ));575
- }576
- let (boost, detail) = if self.floor == Floor::Bathroom {577
- (578
- 35,579
- "Warmed up. The shower is hot. The roommate's will not be.",580
- )581
- } else if self.towel_stolen {582
- (10, "Warmed up, poorly. The cat has the towel.")583
- } else {584
- (25, "Warmed up. The towel has cooled.")585
- };586
- self.touch();587
- self.warmed_up = true;588
- self.eq = (self.eq + boost).min(100);589
- Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))590
- }591
-592
- fn ice(&mut self) -> Result<Outcome, Problem> {593
- if self.items.ice_packs == 0 {594
- return Err(Problem::new(595
- StatusCode::FAILED_DEPENDENCY,596
- "no-ice",597
- "The ice technique depends on an ice pack. There are none.",598
- ));599
- }600
- self.touch();601
- self.items.ice_packs -= 1;602
- if self.priapism_turns > 0 {603
- return Ok(Outcome::json(604
- StatusCode::OK,605
- json!({ "detail": "The ice has no effect. The condition persists. The ice pack is gone." }),606
- ));607
- }608
- self.eq = (self.eq - 40).max(0);609
- Ok(Outcome::json(610
- StatusCode::OK,611
- json!({ "detail": "EQ has been reduced. The ice pack is gone." }),612
- ))613
- }614
-615
- fn supplement(&mut self) -> Result<Outcome, Problem> {616
- if self.items.supplements == 0 {617
- return Err(Problem::new(618
- StatusCode::FAILED_DEPENDENCY,619
- "no-supplement",620
- "The supplement technique depends on a supplement.",621
- ));622
- }623
- self.items.supplements -= 1;624
- self.touch();625
- if self.rng.random_ratio(1, 5) {626
- self.priapism_turns = PRIAPISM_TURNS;627
- self.eq = self.eq.max(PRIAPISM_EQ);628
- return Ok(Outcome::json(629
- StatusCode::OK,630
- json!({ "detail": "The upstream returned a valid response. It is still returning it." }),631
- ));632
- }633
- Err(Problem::new(634
- StatusCode::BAD_GATEWAY,635
- "bad-gateway",636
- "The upstream returned an invalid response.",637
- )638
- .with("upstream", "Pump-Bro"))639
- }640
-641
- fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {642
- self.commit(5, None, now, 1);643
- self.edges += 1;644
- self.eq = (self.eq + 11).min(100);645
- self.girth += 0.004;646
- if self.edges >= 3 {647
- self.edges = 0;648
- self.stamina = 0;649
- self.refractory_until = Some(now + *REFRACTORY);650
- return Err(Problem::new(651
- StatusCode::LOOP_DETECTED,652
- "edging-loop",653
- "Edging has looped. The server detected an infinite loop and terminated it.",654
- ));655
- }656
- if let Some(interrupted) = self.hazards(5)? {657
- return Ok(interrupted);658
- }659
- Ok(Outcome::json(660
- StatusCode::OK,661
- json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),662
- ))663
- }664
-665
- fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {666
- self.commit(4, None, now, 1);667
- if let Some(interrupted) = self.hazards(4)? {668
- return Ok(interrupted);669
- }670
- self.temp_length += 0.5;671
- Ok(Outcome::json(672
- StatusCode::TEMPORARY_REDIRECT,673
- json!({ "detail": "Gains applied. They are temporary.", "length": 0.5 }),674
- )675
- .header(header::LOCATION, format!("/runs/{}/length", self.id)))676
- }677
-}@@ -1,301 +1,80 @@
1
-mod catalog;2
-mod encounters;3
-mod jelq;4
-5
-pub use catalog::{LUBRICANTS, Lube, SHOP, ShopItem, Technique};6
-pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread};1
+pub mod balance;2
+mod body;3
+mod clinic;4
+mod floors;5
+mod forum;6
+mod hazards;7
+mod household;8
+mod items;9
+mod outcome;10
+mod shop;11
+mod state;12
+mod techniques;13
+mod turn;14
+mod utility;15
+16
+pub use body::PatchOp;17
+pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};18
+pub use items::{Item, Items};19
+pub use outcome::{Outcome, Payload};20
+pub use state::{21
+ Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MomVisit, Observer, Story,22
+};23
+pub use techniques::{LUBRICANTS, Lube, Technique};724
825
use std::{9
- sync::LazyLock,26
+ hash::{DefaultHasher, Hash, Hasher},1027
time::{Duration, Instant, SystemTime},1128
};1229
13
-use axum::{14
- Json,15
- http::{HeaderName, HeaderValue, StatusCode, header},16
- response::{IntoResponse, Response},17
-};30
+use axum::http::{StatusCode, header};1831
use rand::{Rng, SeedableRng, rngs::StdRng};1932
use serde_json::{Value, json};2033
2134
use crate::problem::Problem;22
-23
-pub const MAX_STAMINA: i32 = 10;24
-pub const SUSPICION_LIMIT: u8 = 5;25
-pub const BRANCH_TURN: u32 = 10;26
-pub const FLOOR_TURNS: u32 = 6;27
-pub const STARTING_CASH: u32 = 20;28
-pub const FLOOR_ALLOWANCE: u32 = 10;29
-pub static REFRACTORY: LazyLock<Duration> = LazyLock::new(|| {30
- let seconds = std::env::var("JELQD_REFRACTORY_SECS")31
- .ok()32
- .and_then(|s| s.parse().ok())33
- .unwrap_or(30);34
- Duration::from_secs(seconds)35
-});36
-pub const SELF_REPORT_INFLATION: f64 = 1.5;37
-pub const MM_PER_INCH: f64 = 25.4;38
-pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";39
-pub const CLINIC_ALLOW: &str = "GET, HEAD, PUT, PATCH, DELETE, OPTIONS, TRACE";40
-41
-pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");42
-pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");43
-44
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]45
-pub enum Floor {46
- Bedroom,47
- Bathroom,48
- LockerRoom,49
- Grandmas,50
- Hotel,51
- Forum,52
- Clinic,53
-}54
-55
-impl Floor {56
- const ALL: [Self; 7] = [57
- Self::Bedroom,58
- Self::Bathroom,59
- Self::LockerRoom,60
- Self::Grandmas,61
- Self::Hotel,62
- Self::Forum,63
- Self::Clinic,64
- ];65
-66
- pub fn number(self) -> usize {67
- Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + 168
- }69
-70
- fn next(self) -> Option<Self> {71
- Self::ALL.get(self.number()).copied()72
- }73
-74
- pub fn name(self) -> &'static str {75
- match self {76
- Self::Bedroom => "the bedroom",77
- Self::Bathroom => "the shared bathroom",78
- Self::LockerRoom => "the gym locker room",79
- Self::Grandmas => "grandma's house for Thanksgiving",80
- Self::Hotel => "a business trip hotel",81
- Self::Forum => "the forum",82
- Self::Clinic => "the urologist's office",83
- }84
- }85
-86
- pub fn note(self) -> &'static str {87
- match self {88
- Self::Bedroom => "The door does not lock. The cat is present.",89
- Self::Bathroom => "The water is hot. The roommate showers at eight.",90
- Self::LockerRoom => {91
- "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."92
- }93
- Self::Grandmas => {94
- "The walls are thin. Mom is here, and she has been reading about Digest authentication."95
- }96
- Self::Hotel => {97
- "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."98
- }99
- Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",100
- Self::Clinic => "The urologist will see you now. He is bored.",101
- }102
- }103
-104
- fn observer(self) -> Option<&'static str> {105
- match self {106
- Self::Bedroom => Some("cat"),107
- Self::LockerRoom => Some("greg"),108
- Self::Grandmas => Some("grandma"),109
- _ => None,110
- }111
- }112
-113
- pub fn shop_open(self) -> bool {114
- matches!(self, Self::LockerRoom | Self::Hotel)115
- }116
-117
- pub fn has_mom(self) -> bool {118
- matches!(self, Self::Bedroom | Self::Grandmas)119
- }120
-}121
-122
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]123
-pub enum Grip {124
- Overhand,125
- Underhand,126
-}127
-128
-impl Grip {129
- pub fn name(self) -> &'static str {130
- match self {131
- Self::Overhand => "overhand",132
- Self::Underhand => "underhand",133
- }134
- }135
-136
- pub fn parse(protocol: &str) -> Option<Self> {137
- match protocol.to_ascii_lowercase().as_str() {138
- "overhand" => Some(Self::Overhand),139
- "underhand" => Some(Self::Underhand),140
- _ => None,141
- }142
- }143
-}144
-145
-#[derive(Default)]146
-pub struct Items {147
- pub ruler: bool,148
- pub ice_packs: u32,149
- pub door_locks: u32,150
- pub dnd_sign: bool,151
- pub pump_pro: bool,152
- pub extender: bool,153
- pub ring: bool,154
- pub supplements: u32,155
- pub cage: bool,156
- pub spreadsheet: bool,157
- pub teapot_spout: bool,158
- pub blinds: bool,159
-}160
-161
-enum Payload {162
- Empty,163
- Json(Value),164
- Raw(&'static str, Vec<u8>),165
-}166
-167
-pub struct Outcome {168
- status: StatusCode,169
- payload: Payload,170
- headers: Vec<(HeaderName, String)>,171
-}172
-173
-impl Outcome {174
- pub fn empty(status: StatusCode) -> Self {175
- Self {176
- status,177
- payload: Payload::Empty,178
- headers: Vec::new(),179
- }180
- }181
-182
- pub fn json(status: StatusCode, body: Value) -> Self {183
- Self {184
- payload: Payload::Json(body),185
- ..Self::empty(status)186
- }187
- }188
-189
- pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {190
- Self {191
- payload: Payload::Raw(content_type, body.into()),192
- ..Self::empty(status)193
- }194
- }195
-196
- pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {197
- self.headers.push((name, value.into()));198
- self199
- }200
-201
- pub fn status(&self) -> StatusCode {202
- self.status203
- }204
-}205
-206
-impl IntoResponse for Outcome {207
- fn into_response(self) -> Response {208
- let mut response = match self.payload {209
- // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.210
- Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {211
- let full = body.to_string().into_bytes();212
- let half = full.len() / 2;213
- let range = format!("bytes 0-{}/{}", half - 1, full.len());214
- (215
- self.status,216
- [217
- (header::CONTENT_TYPE, "application/json".to_owned()),218
- (header::CONTENT_RANGE, range),219
- ],220
- full[..half].to_vec(),221
- )222
- .into_response()223
- }224
- Payload::Json(body) => (self.status, Json(body)).into_response(),225
- Payload::Raw(content_type, body) => {226
- (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()227
- }228
- Payload::Empty => self.status.into_response(),229
- };230
- for (name, value) in self.headers {231
- let value = HeaderValue::from_str(&value).expect("header values are visible ascii");232
- response.headers_mut().append(name, value);233
- }234
- response235
- }236
-}237
-238
-pub struct Pending {239
- due: u32,240
- length: f64,241
- girth: f64,242
-}243
-244
-pub struct Ending {245
- pub cause: &'static str,246
- pub detail: String,247
- pub turn: u32,248
-}35
+use balance::{36
+ HONEST_TOLERANCE, MAX_STAMINA, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_CASH,37
+ STARTING_EQ,38
+};39
+use state::Pending;24940
25041
pub struct Run {42
+ /// The private id in the run's URL. Knowing it is the only authorization a run has.25143
pub id: String,252
- pub origin_etag: String,44
+ pub public_id: String,45
+ etag_salt: u64,25346
pub version: u64,25447
pub turn: u32,25548
pub floor: Floor,256
- pub floor_turns: u32,49
+ pub here: FloorState,25750
pub length: f64,258
- pub temp_length: f64,25951
pub girth: f64,26052
pub foreskin: Option<f64>,26153
pub eq: i32,26254
pub stamina: i32,26355
pub suspicion: u8,264
- pub shame_base: u32,26556
pub shame: u32,57
+ pub shame_reported: u32,58
+ pub shame_total: u32,26659
pub cash: u32,26760
pub grip: Grip,26861
pub items: Items,62
+ pub conditions: Conditions,26963
pub warmed_up: bool,270
- pub teapot: bool,271
- pub peyronie: bool,27264
pub put_applied: bool,273
- pub forum_read: bool,274
- pub branch_pending: bool,275
- pub mom_present: bool,276
- pub mom_errand: &'static str,277
- pub mom_strikes: u8,278
- pub elder_strikes: u8,65
+ pub story: Story,66
+ pub mom: Option<MomVisit>,67
+ pub heard: Vec<String>,68
+ pub elder: Elder,69
+ pub clinic: Clinic,27970
pub seeded: bool,280
- pub towel_stolen: bool,281
- pub pulled_muscle: bool,282
- pub priapism_turns: u8,28371
pub filmed_at: Option<SystemTime>,284
- pub observed: Option<&'static str>,285
- pub measured: bool,286
- pub cured: bool,287
- pub roommate_told: bool,288
- pub wifi_accepted: bool,289
- pub verified: bool,290
- pub elder_beaten: bool,291
- pub edges: u8,292
- pub drawer_searches: u8,293
- pub intake_attempts: usize,294
- pub nonce: String,295
- pub heard: Vec<String>,72
+ pub observed: Option<Observer>,73
+ pub refractory: Duration,29674
pub refractory_until: Option<Instant>,29775
pub pending: Vec<Pending>,29876
pub ended: Option<Ending>,77
+ pub last_seen: Instant,29978
rng: StdRng,30079
}30180
@@ -308,7 +87,7 @@ fn round3(x: f64) -> f64 {
30887
}30988
31089
fn honest(claimed: f64, actual: f64) -> bool {311
- (claimed - actual).abs() < 0.0690
+ (claimed - actual).abs() < HONEST_TOLERANCE31291
}31392
31493
fn not_extended() -> Problem {@@ -338,72 +117,65 @@ pub fn shaft(mm: usize) -> String {
338117
}339118
340119
impl Run {341
- pub fn new(id: String, seed: u64, shame_base: u32) -> Self {120
+ pub fn new(id: String, public_id: String, seed: u64) -> Self {342121
let mut rng = StdRng::seed_from_u64(seed);343122
let length = round2(rng.random_range(4.5..6.5));344123
let girth = round2(rng.random_range(4.0..5.0));345124
let foreskin = round2(rng.random_range(0.4..2.4));346
- let mut run = Self {125
+ let etag_salt = rng.random();126
+ Self {347127
id,348
- origin_etag: String::new(),128
+ public_id,129
+ etag_salt,349130
version: 0,350131
turn: 0,351132
floor: Floor::Bedroom,352
- floor_turns: 0,133
+ here: FloorState::default(),353134
length,354
- temp_length: 0.0,355135
girth,356136
foreskin: Some(foreskin),357
- eq: 20,137
+ eq: STARTING_EQ,358138
stamina: MAX_STAMINA,359139
suspicion: 0,360
- shame_base,361140
shame: 0,141
+ shame_reported: 0,142
+ shame_total: 0,362143
cash: STARTING_CASH,363144
grip: Grip::Overhand,364
- items: Items {365
- ice_packs: 1,366
- ..Items::default()367
- },145
+ items: Items::default(),146
+ conditions: Conditions::default(),368147
warmed_up: false,369
- teapot: false,370
- peyronie: false,371148
put_applied: false,372
- forum_read: false,373
- branch_pending: false,374
- mom_present: false,375
- mom_errand: "She heard a noise.",376
- mom_strikes: 0,377
- elder_strikes: 0,149
+ story: Story::Unopened,150
+ mom: None,151
+ heard: Vec::new(),152
+ elder: Elder::Unverified { strikes: 0 },153
+ clinic: Clinic::default(),378154
seeded: false,379
- towel_stolen: false,380
- pulled_muscle: false,381
- priapism_turns: 0,382155
filmed_at: None,383156
observed: None,384
- measured: false,385
- cured: false,386
- roommate_told: false,387
- wifi_accepted: false,388
- verified: false,389
- elder_beaten: false,390
- edges: 0,391
- drawer_searches: 0,392
- intake_attempts: 0,393
- nonce: String::new(),394
- heard: Vec::new(),157
+ refractory: Duration::from_secs(30),395158
refractory_until: None,396159
pending: Vec::new(),397160
ended: None,161
+ last_seen: Instant::now(),398162
rng,399
- };400
- run.origin_etag = run.etag();401
- run.nonce = run.fresh_nonce();402
- run163
+ }164
+ }165
+166
+ fn etag_at(&self, version: u64) -> String {167
+ let mut hasher = DefaultHasher::new();168
+ (self.etag_salt, version).hash(&mut hasher);169
+ format!("\"{:016x}\"", hasher.finish())403170
}404171
405172
pub fn etag(&self) -> String {406
- format!("\"{}-{}\"", self.id, self.version)173
+ self.etag_at(self.version)174
+ }175
+176
+ /// The ETag the run had when it was created, which the true ending asks for.177
+ pub fn origin_etag(&self) -> String {178
+ self.etag_at(0)407179
}408180
409181
pub fn touch(&mut self) {@@ -414,8 +186,16 @@ impl Run {
414186
format!("{:016x}", self.rng.random::<u64>())415187
}416188
189
+ fn chance(&mut self, odds: u32) -> bool {190
+ self.rng.random_ratio(1, odds)191
+ }192
+193
+ pub fn path(&self, sub: &str) -> String {194
+ format!("/runs/{}{sub}", self.id)195
+ }196
+417197
pub fn current_length(&self) -> f64 {418
- self.length + self.temp_length198
+ self.length + self.here.temp_length419199
}420200
421201
pub fn self_reported(&self) -> f64 {@@ -423,7 +203,15 @@ impl Run {
423203
}424204
425205
pub fn max_stamina(&self) -> i32 {426
- if self.pulled_muscle { 7 } else { MAX_STAMINA }206
+ if self.conditions.pulled_muscle {207
+ PULLED_STAMINA208
+ } else {209
+ MAX_STAMINA210
+ }211
+ }212
+213
+ pub fn forum_read(&self) -> bool {214
+ self.story == Story::ReadForum427215
}428216
429217
pub fn footage(&self) -> Option<(SystemTime, u64)> {@@ -432,163 +220,45 @@ impl Run {
432220
Some((filmed, age))433221
}434222
435
- pub fn total_shame(&self) -> u32 {436
- self.shame_base + self.shame437
- }438
-439223
pub fn status(&self) -> &'static str {440
- match (&self.ended, self.cured) {224
+ match (&self.ended, self.clinic.cured) {441225
(Some(_), _) => "gone",442226
(None, true) => "normal",443227
(None, false) => "alive",444228
}445229
}446230
231
+ /// The parts of the run that its response headers do not already carry.447232
pub fn view(&self) -> Value {448233
let conditions: Vec<&str> = [449
- (self.teapot, "short and stout"),450
- (self.peyronie, "curved"),234
+ (self.here.teapot, "short and stout"),235
+ (self.conditions.peyronie, "curved"),451236
(self.items.cage, "caged"),452
- (self.priapism_turns > 0, "priapism"),453
- (self.pulled_muscle, "pulled muscle"),237
+ (self.conditions.priapism_turns > 0, "priapism"),238
+ (self.conditions.pulled_muscle, "pulled muscle"),454239
(self.filmed_at.is_some(), "viral"),455240
]456241
.into_iter()457242
.filter_map(|(active, name)| active.then_some(name))458243
.collect();459
- let mut view = json!({460
- "id": self.id,244
+ json!({245
+ "public_id": self.public_id,461246
"status": self.status(),462247
"turn": self.turn,463
- "floor": { "number": self.floor.number(), "name": self.floor.name() },464
- "self_reported_length": round2(self.self_reported()),465
- "girth": round2(self.girth),466
- "foreskin": self.foreskin.map(round2),467
- "eq": self.eq,468
- "stamina": self.stamina,469
- "suspicion": self.suspicion,470
- "shame": self.total_shame(),471
- "cash": self.cash,248
+ "run_shame": self.shame,472249
"grip": self.grip.name(),473250
"warmed_up": self.warmed_up,474251
"ranked": !self.seeded,475252
"conditions": conditions,476
- });477
- if self.items.ruler {478
- view["length"] = json!(round2(self.current_length()));479
- }480
- view481
- }482
-483
- pub fn floor_view(&self) -> Value {484
- let id = &self.id;485
- let turns_required = match self.floor {486
- Floor::Forum | Floor::Clinic => Value::Null,487
- _ => json!(FLOOR_TURNS),488
- };489
- let mut view = json!({490
- "number": self.floor.number(),491
- "name": self.floor.name(),492
- "note": self.floor.note(),493
- "turns": self.floor_turns,494
- "turns_required": turns_required,495
- "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" },496
- });497
- match self.floor {498
- Floor::LockerRoom => {499
- view["others"] = json!([500
- { "name": "Greg", "self_reported_length": 9.5 },501
- { "name": "Dmitri", "self_reported_length": 8.75 },502
- { "name": "a man in a towel", "self_reported_length": 11.0 },503
- ]);504
- }505
- Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")),506
- Floor::Forum => {507
- view["thread"] = json!(FORUM_THREAD);508
- view["elder"] = json!(format!("/runs/{id}/elder"));509
- }510
- Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")),511
- _ => {}512
- }513
- if self.floor.shop_open() {514
- view["shop"] = json!(format!("/runs/{id}/shop"));515
- }516
- view253
+ })517254
}518255
519
- pub fn inventory(&self) -> Vec<(&'static str, &'static str)> {520
- let i = &self.items;521
- let owned = [522
- (523
- true,524
- "warm-towel",525
- "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",526
- ),527
- (528
- i.ice_packs > 0,529
- "ice-pack",530
- "Lowers EQ by 40. Use technique=ice.",531
- ),532
- (533
- i.ruler,534
- "ruler",535
- "Measures honestly. The length resource now carries a Content-Length.",536
- ),537
- (538
- i.door_locks > 0,539
- "door-lock",540
- "Keeps the roommate out once.",541
- ),542
- (543
- i.dnd_sign,544
- "do-not-disturb-sign",545
- "Works on housekeeping. Mom does not read signs.",546
- ),547
- (548
- i.pump_pro,549
- "pump-pro",550
- "Use technique=pump. Gains are temporary.",551
- ),552
- (553
- i.extender,554
- "extender",555
- "Passive gains each turn. Takes up room. Can be removed with DELETE.",556
- ),557
- (558
- i.ring,559
- "cock-ring",560
- "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.",561
- ),562
- (i.supplements > 0, "supplement", "Use technique=supplement."),563
- (i.cage, "chastity-cage", "Locked."),564
- (565
- i.spreadsheet,566
- "spreadsheet",567
- "Charts your progress. Every line is flat.",568
- ),569
- (570
- i.teapot_spout,571
- "teapot-spout",572
- "Does nothing. Cannot be dropped.",573
- ),574
- (575
- i.blinds,576
- "window-blinds",577
- "The neighbour has a good camera. The blinds are better.",578
- ),579
- ];580
- owned581
- .into_iter()582
- .filter_map(|(have, slug, description)| have.then_some((slug, description)))583
- .collect()584
- }585
-586
- pub fn end(&mut self, cause: &'static str, detail: &str) {256
+ pub fn end(&mut self, cause: Cause) -> Problem {587257
self.ended = Some(Ending {588258
cause,589
- detail: detail.into(),590259
turn: self.turn,591260
});261
+ cause.problem().with("turn", self.turn)592262
}593263
594264
fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {@@ -606,12 +276,17 @@ impl Run {
606276
}607277
self.refractory_until = None;608278
self.stamina = self.max_stamina();609
- self.eq = 20;279
+ self.eq = STARTING_EQ;610280
self.warmed_up = false;611281
self.touch();612282
Ok(())613283
}614284
285
+ fn exhaust(&mut self, now: Instant) {286
+ self.stamina = 0;287
+ self.refractory_until = Some(now + self.refractory);288
+ }289
+615290
fn realize(&mut self) {616291
let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)617292
.into_iter()@@ -632,87 +307,9 @@ impl Run {
632307
}633308
634309
fn rupture(&mut self) -> Problem {635
- let detail = if self.items.ice_packs > 0 {636
- "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."637
- } else {638
- "A vessel has exceeded its rated pressure. The run has ended."310
+ let cause = Cause::Ruptured {311
+ ice_unused: self.items.ice_packs > 0,639312
};640
- self.end("vascular-event", detail);641
- Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)642
- .with("eq_at_failure", self.eq)643
- .with("turn", self.turn)644
- }645
-646
- fn next_floor(&mut self, paid: bool) {647
- let Some(next) = self.floor.next() else {648
- return;649
- };650
- self.floor = next;651
- self.floor_turns = 0;652
- self.temp_length = 0.0;653
- self.teapot = false;654
- self.warmed_up = false;655
- self.eq = 20;656
- self.suspicion = 0;657
- self.mom_present = false;658
- self.roommate_told = false;659
- self.drawer_searches = 0;660
- self.towel_stolen = false;661
- self.edges = 0;662
- if paid {663
- self.cash += FLOOR_ALLOWANCE;664
- }665
- self.nonce = self.fresh_nonce();666
- self.touch();667
- }668
-669
- pub fn advance(&mut self) -> Result<Outcome, Problem> {670
- match self.floor {671
- Floor::Clinic => {672
- return Err(Problem::new(673
- StatusCode::CONFLICT,674
- "no-further-floor",675
- "There is no further floor. There is only the diagnosis.",676
- ));677
- }678
- Floor::Forum if !self.elder_beaten => {679
- return Err(Problem::new(680
- StatusCode::CONFLICT,681
- "elder-unaddressed",682
- "The Elder has not been addressed.",683
- )684
- .with("elder", format!("/runs/{}/elder", self.id)));685
- }686
- Floor::Forum => {}687
- _ if self.floor_turns < FLOOR_TURNS => {688
- return Err(Problem::new(689
- StatusCode::TOO_EARLY,690
- "floor-not-over",691
- format!(692
- "The floor is not over. {} turns remain.",693
- FLOOR_TURNS - self.floor_turns694
- ),695
- ));696
- }697
- _ => {}698
- }699
- if self.rng.random_ratio(1, 200) {700
- let detail =701
- "The request conflicts with the current state of the zipper. The run has ended.";702
- self.end("zipper", detail);703
- return Err(Problem::new(StatusCode::CONFLICT, "zipper", detail));704
- }705
- let reverted = self.temp_length;706
- self.next_floor(true);707
- let mut body = json!({708
- "detail": format!("You have arrived at {}.", self.floor.name()),709
- "floor": self.floor.number(),710
- "note": self.floor.note(),711
- });712
- if reverted > 0.0 {713
- body["reverted"] = json!(round2(reverted));714
- }715
- Ok(Outcome::json(StatusCode::SEE_OTHER, body)716
- .header(header::LOCATION, format!("/runs/{}/floor", self.id)))313
+ self.end(cause)717314
}718315
}@@ -0,0 +1,44 @@
1
+use axum::http::{HeaderName, StatusCode};2
+use serde_json::Value;3
+4
+pub enum Payload {5
+ Empty,6
+ Json(Value),7
+ Raw(&'static str, Vec<u8>),8
+}9
+10
+/// A successful result of a game action, before it becomes an HTTP response.11
+pub struct Outcome {12
+ pub status: StatusCode,13
+ pub payload: Payload,14
+ pub headers: Vec<(HeaderName, String)>,15
+}16
+17
+impl Outcome {18
+ pub fn empty(status: StatusCode) -> Self {19
+ Self {20
+ status,21
+ payload: Payload::Empty,22
+ headers: Vec::new(),23
+ }24
+ }25
+26
+ pub fn json(status: StatusCode, body: Value) -> Self {27
+ Self {28
+ payload: Payload::Json(body),29
+ ..Self::empty(status)30
+ }31
+ }32
+33
+ pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {34
+ Self {35
+ payload: Payload::Raw(content_type, body.into()),36
+ ..Self::empty(status)37
+ }38
+ }39
+40
+ pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {41
+ self.headers.push((name, value.into()));42
+ self43
+ }44
+}@@ -0,0 +1,109 @@
1
+use axum::http::{StatusCode, header};2
+use serde_json::{Value, json};3
+4
+use super::{Item, Outcome, Run, items::SHOP};5
+use crate::problem::Problem;6
+7
+fn pump_bro_absent() -> Problem {8
+ Problem::new(9
+ StatusCode::NOT_FOUND,10
+ "pump-bro-absent",11
+ "Pump-Bro is not here.",12
+ )13
+}14
+15
+impl Run {16
+ pub fn shop_view(&self) -> Result<Value, Problem> {17
+ if !self.floor.shop_open() {18
+ return Err(pump_bro_absent());19
+ }20
+ let stock: Vec<Value> = SHOP21
+ .iter()22
+ .filter_map(|item| {23
+ Some(json!({24
+ "item": item.slug(),25
+ "price": item.price()?,26
+ "description": item.pitch(),27
+ "href": self.path(&format!("/shop/{}", item.slug())),28
+ "method": "POST",29
+ }))30
+ })31
+ .collect();32
+ Ok(json!({ "merchant": "Pump-Bro", "stock": stock }))33
+ }34
+35
+ pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {36
+ if !self.floor.shop_open() {37
+ return Err(pump_bro_absent());38
+ }39
+ let not_for_sale = || {40
+ Problem::new(41
+ StatusCode::NOT_FOUND,42
+ "not-for-sale",43
+ "Pump-Bro does not sell that. He knows a guy.",44
+ )45
+ };46
+ let item = Item::parse(slug)47
+ .filter(|item| SHOP.contains(item))48
+ .ok_or_else(not_for_sale)?;49
+ let price = item.price().ok_or_else(not_for_sale)?;50
+ if item == Item::Pump {51
+ return Err(Problem::new(52
+ StatusCode::UPGRADE_REQUIRED,53
+ "upsell",54
+ "This pump requires an upgrade.",55
+ )56
+ .header(header::UPGRADE, "pump-pro/2.0")57
+ .with("upgrade", self.path("/shop/pump-pro")));58
+ }59
+ if self.cash < price {60
+ return Err(Problem::new(61
+ StatusCode::PAYMENT_REQUIRED,62
+ "insufficient-funds",63
+ "Payment is required. RFC 9110 has reserved this status for future use since 1997. The future is here and you cannot afford it.",64
+ )65
+ .with("price", price));66
+ }67
+ self.cash -= price;68
+ self.items.grant(item);69
+ self.touch();70
+ if item == Item::Supplement {71
+ return Err(Problem::new(72
+ StatusCode::GATEWAY_TIMEOUT,73
+ "still-shipping",74
+ "The supplement is still shipping. You have been charged. It may arrive.",75
+ ));76
+ }77
+ Ok(Outcome::json(78
+ StatusCode::CREATED,79
+ json!({ "detail": "Purchased. No refunds." }),80
+ )81
+ .header(82
+ header::LOCATION,83
+ self.path(&format!("/inventory/{}", item.slug())),84
+ ))85
+ }86
+87
+ pub fn drop_item(&mut self, item: Item) -> Result<Outcome, Problem> {88
+ match item {89
+ Item::Extender => self.items.extender = false,90
+ Item::Ring => self.items.ring = false,91
+ Item::Cage => {92
+ return Err(Problem::new(93
+ StatusCode::LOCKED,94
+ "caged",95
+ "The cage is locked.",96
+ ));97
+ }98
+ _ => {99
+ return Err(Problem::new(100
+ StatusCode::FORBIDDEN,101
+ "cannot-be-dropped",102
+ "That cannot be dropped.",103
+ ));104
+ }105
+ }106
+ self.touch();107
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))108
+ }109
+}@@ -0,0 +1,245 @@
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
+ LockerRoom = 3,11
+ Grandmas = 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::LockerRoom),26
+ Self::LockerRoom => Some(Self::Grandmas),27
+ Self::Grandmas => 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::LockerRoom => "the gym locker room",39
+ Self::Grandmas => "grandma's house for Thanksgiving",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. The roommate showers at eight.",50
+ Self::LockerRoom => {51
+ "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."52
+ }53
+ Self::Grandmas => {54
+ "The walls are thin. Mom 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::LockerRoom => Some(Observer::Greg),68
+ Self::Grandmas => Some(Observer::Grandma),69
+ _ => None,70
+ }71
+ }72
+73
+ pub fn has_mom(self) -> bool {74
+ matches!(self, Self::Bedroom | Self::Grandmas)75
+ }76
+77
+ pub fn has_window(self) -> bool {78
+ matches!(self, Self::Bedroom | Self::Grandmas | Self::Hotel)79
+ }80
+81
+ pub fn shop_open(self) -> bool {82
+ matches!(self, Self::LockerRoom | Self::Hotel)83
+ }84
+85
+ pub fn timed(self) -> bool {86
+ !matches!(self, Self::Forum | Self::Clinic)87
+ }88
+}89
+90
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]91
+pub enum Observer {92
+ Cat,93
+ Greg,94
+ Grandma,95
+}96
+97
+impl Observer {98
+ pub fn name(self) -> &'static str {99
+ match self {100
+ Self::Cat => "cat",101
+ Self::Greg => "greg",102
+ Self::Grandma => "grandma",103
+ }104
+ }105
+}106
+107
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]108
+pub enum Grip {109
+ Overhand,110
+ Underhand,111
+}112
+113
+impl Grip {114
+ pub fn name(self) -> &'static str {115
+ match self {116
+ Self::Overhand => "overhand",117
+ Self::Underhand => "underhand",118
+ }119
+ }120
+121
+ pub fn parse(protocol: &str) -> Option<Self> {122
+ match protocol.to_ascii_lowercase().as_str() {123
+ "overhand" => Some(Self::Overhand),124
+ "underhand" => Some(Self::Underhand),125
+ _ => None,126
+ }127
+ }128
+}129
+130
+/// State that belongs to the current floor and resets when you leave it.131
+#[derive(Default)]132
+pub struct FloorState {133
+ pub turns: u32,134
+ pub drawer_searches: u8,135
+ pub edges: u8,136
+ pub temp_length: f64,137
+ pub teapot: bool,138
+ pub towel_stolen: bool,139
+ pub roommate_told: bool,140
+ pub wifi_accepted: bool,141
+}142
+143
+#[derive(Default)]144
+pub struct Conditions {145
+ pub peyronie: bool,146
+ pub pulled_muscle: bool,147
+ pub priapism_turns: u8,148
+}149
+150
+pub struct MomVisit {151
+ pub errand: &'static str,152
+ pub nonce: String,153
+ pub strikes: u8,154
+}155
+156
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]157
+pub enum Elder {158
+ Unverified { strikes: u8 },159
+ Verified,160
+ Beaten,161
+}162
+163
+#[derive(Default)]164
+pub struct Clinic {165
+ pub intake_attempts: usize,166
+ pub measured: bool,167
+ pub cured: bool,168
+}169
+170
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]171
+pub enum Story {172
+ Unopened,173
+ Pending,174
+ ReadForum,175
+ ClosedTab,176
+}177
+178
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]179
+pub enum Cause {180
+ Ruptured { ice_unused: bool },181
+ Caught,182
+ Banned,183
+ Degloved,184
+ Zipper,185
+ Abandoned,186
+}187
+188
+impl Cause {189
+ pub fn slug(self) -> &'static str {190
+ match self {191
+ Self::Ruptured { .. } => "vascular-event",192
+ Self::Caught => "caught",193
+ Self::Banned => "banned",194
+ Self::Degloved => "degloved",195
+ Self::Zipper => "zipper",196
+ Self::Abandoned => "abandoned",197
+ }198
+ }199
+200
+ pub fn detail(self) -> &'static str {201
+ match self {202
+ Self::Ruptured { ice_unused: true } => {203
+ "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."204
+ }205
+ Self::Ruptured { ice_unused: false } => {206
+ "A vessel has exceeded its rated pressure. The run has ended."207
+ }208
+ Self::Caught => {209
+ "Mom has seen enough. The run has ended. Nobody will mention it at dinner."210
+ }211
+ Self::Banned => "Your account has been banned. The forum can tell. The run has ended.",212
+ Self::Degloved => {213
+ "The outer layer has been removed in one piece. It will not be returning. The run has ended."214
+ }215
+ Self::Zipper => {216
+ "The request conflicts with the current state of the zipper. The run has ended."217
+ }218
+ Self::Abandoned => "The run was abandoned.",219
+ }220
+ }221
+222
+ fn status(self) -> StatusCode {223
+ match self {224
+ Self::Ruptured { .. } => StatusCode::INTERNAL_SERVER_ERROR,225
+ Self::Caught | Self::Degloved | Self::Abandoned => StatusCode::GONE,226
+ Self::Banned => StatusCode::FORBIDDEN,227
+ Self::Zipper => StatusCode::CONFLICT,228
+ }229
+ }230
+231
+ pub fn problem(self) -> Problem {232
+ Problem::new(self.status(), self.slug(), self.detail())233
+ }234
+}235
+236
+pub struct Ending {237
+ pub cause: Cause,238
+ pub turn: u32,239
+}240
+241
+pub struct Pending {242
+ pub due: u32,243
+ pub length: f64,244
+ pub girth: f64,245
+}@@ -0,0 +1,220 @@
1
+use axum::http::StatusCode;2
+use serde_json::{Value, json};3
+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
+}62
+63
+/// Techniques that roll for gains.64
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]65
+pub enum Training {66
+ Wet,67
+ Dry,68
+ Stretch,69
+ Uli,70
+}71
+72
+/// Techniques that change EQ or spend an item without rolling.73
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]74
+pub enum Utility {75
+ Warmup,76
+ Ice,77
+ Edge,78
+ Pump,79
+ Supplement,80
+}81
+82
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]83
+pub enum Technique {84
+ Training(Training),85
+ Utility(Utility),86
+}87
+88
+#[derive(Clone, Copy)]89
+pub enum LubeRule {90
+ Required,91
+ Forbidden,92
+ Optional,93
+}94
+95
+pub struct Profile {96
+ pub dc: i32,97
+ pub length: f64,98
+ pub girth: f64,99
+ /// Minimum and maximum foreskin the technique tolerates. A minimum above zero means it is used.100
+ pub foreskin: Option<(f64, f64)>,101
+ pub foreskin_growth: f64,102
+ pub lube: LubeRule,103
+}104
+105
+impl Training {106
+ pub fn name(self) -> &'static str {107
+ match self {108
+ Self::Wet => "wet",109
+ Self::Dry => "dry",110
+ Self::Stretch => "stretch",111
+ Self::Uli => "uli",112
+ }113
+ }114
+115
+ pub fn profile(self) -> Profile {116
+ match self {117
+ Self::Wet => Profile {118
+ dc: 10,119
+ length: 0.010,120
+ girth: 0.005,121
+ foreskin: None,122
+ foreskin_growth: 0.0,123
+ lube: LubeRule::Required,124
+ },125
+ Self::Dry => Profile {126
+ dc: 12,127
+ length: 0.008,128
+ girth: 0.003,129
+ foreskin: Some((0.8, 2.0)),130
+ foreskin_growth: 0.01,131
+ lube: LubeRule::Forbidden,132
+ },133
+ Self::Stretch => Profile {134
+ dc: 11,135
+ length: 0.015,136
+ girth: 0.0,137
+ foreskin: Some((0.0, 1.8)),138
+ foreskin_growth: 0.03,139
+ lube: LubeRule::Optional,140
+ },141
+ Self::Uli => Profile {142
+ dc: 15,143
+ length: 0.002,144
+ girth: 0.020,145
+ foreskin: None,146
+ foreskin_growth: 0.0,147
+ lube: LubeRule::Optional,148
+ },149
+ }150
+ }151
+}152
+153
+impl Utility {154
+ pub fn name(self) -> &'static str {155
+ match self {156
+ Self::Warmup => "warmup",157
+ Self::Ice => "ice",158
+ Self::Edge => "edge",159
+ Self::Pump => "pump",160
+ Self::Supplement => "supplement",161
+ }162
+ }163
+164
+ fn effect(self) -> &'static str {165
+ match self {166
+ Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",167
+ Self::Ice => "EQ -40. Consumes an ice pack.",168
+ Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",169
+ Self::Pump => "Large gains, all temporary. Requires a pump.",170
+ Self::Supplement => "Consumes a supplement.",171
+ }172
+ }173
+}174
+175
+impl Technique {176
+ pub const ALL: [Self; 9] = [177
+ Self::Utility(Utility::Warmup),178
+ Self::Utility(Utility::Ice),179
+ Self::Utility(Utility::Edge),180
+ Self::Utility(Utility::Pump),181
+ Self::Utility(Utility::Supplement),182
+ Self::Training(Training::Wet),183
+ Self::Training(Training::Dry),184
+ Self::Training(Training::Stretch),185
+ Self::Training(Training::Uli),186
+ ];187
+188
+ pub fn name(self) -> &'static str {189
+ match self {190
+ Self::Training(t) => t.name(),191
+ Self::Utility(u) => u.name(),192
+ }193
+ }194
+195
+ pub fn parse(name: &str) -> Option<Self> {196
+ Self::ALL.into_iter().find(|t| t.name() == name)197
+ }198
+199
+ pub fn describe(self, forum_read: bool) -> Value {200
+ let training = match self {201
+ Self::Utility(utility) => {202
+ return json!({ "name": utility.name(), "effect": utility.effect() });203
+ }204
+ Self::Training(training) => training,205
+ };206
+ let profile = training.profile();207
+ let lubricant = match profile.lube {208
+ LubeRule::Required => "required",209
+ LubeRule::Forbidden => "forbidden",210
+ LubeRule::Optional => "optional",211
+ };212
+ json!({213
+ "name": training.name(),214
+ "dc": profile.dc,215
+ "lubricant": lubricant,216
+ "foreskin": profile.foreskin.map(|(min, max)| json!({ "min": min, "max": max })),217
+ "locked": training == Training::Uli && !forum_read,218
+ })219
+ }220
+}@@ -0,0 +1,522 @@
1
+use std::{fmt::Write as _, ops::ControlFlow, time::Instant};2
+3
+use axum::http::{StatusCode, header};4
+use rand::Rng;5
+use serde_json::json;6
+7
+use super::{8
+ Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,9
+ balance::{10
+ BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, DANGER_EQ, EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN,11
+ HOT_EQ, LOCKER_CHILL, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS, PULLED_MUSCLE,12
+ PULLED_MUSCLE_INTENSITY, RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH,13
+ SUSPICION_LIMIT,14
+ },15
+ not_extended, round3,16
+ state::{MomVisit, Observer, Pending},17
+ techniques::{LubeRule, Profile, Training, Utility},18
+};19
+use crate::{headers::SERVER_TIMING, problem::Problem};20
+21
+impl Run {22
+ pub fn jelq(23
+ &mut self,24
+ techniques: &[Technique],25
+ intensity: i32,26
+ lube: Option<Lube>,27
+ now: Instant,28
+ ) -> Result<Outcome, Problem> {29
+ self.check_refractory(now)?;30
+ if self.story == Story::Pending {31
+ return Ok(self.branch_offer());32
+ }33
+ if self.floor == Floor::Forum {34
+ return Ok(Outcome::json(35
+ StatusCode::SEE_OTHER,36
+ json!({ "detail": "Refer to the forum." }),37
+ )38
+ .header(header::LOCATION, super::FORUM_THREAD));39
+ }40
+ if self.items.cage {41
+ return Err(Problem::new(42
+ StatusCode::LOCKED,43
+ "caged",44
+ "The cage is locked. The key is not in the box.",45
+ ));46
+ }47
+ match *techniques {48
+ [Technique::Utility(utility)] => self.utility(utility, lube, now),49
+ [Technique::Training(training)] => self.single(training, intensity, lube, now),50
+ [Technique::Training(a), Technique::Training(b)] => {51
+ self.combo(a, b, intensity, lube, now)52
+ }53
+ [_, _] => Err(Problem::new(54
+ StatusCode::BAD_REQUEST,55
+ "combo-invalid",56
+ "Only training techniques can be combined.",57
+ )),58
+ _ => Err(Problem::new(59
+ StatusCode::BAD_REQUEST,60
+ "too-many-techniques",61
+ "Two techniques at most. The server has limits even if you do not.",62
+ )),63
+ }64
+ }65
+66
+ fn utility(67
+ &mut self,68
+ utility: Utility,69
+ lube: Option<Lube>,70
+ now: Instant,71
+ ) -> Result<Outcome, Problem> {72
+ match utility {73
+ Utility::Warmup => return self.warm_up(),74
+ Utility::Ice => return self.ice(),75
+ Utility::Supplement => return self.supplement(),76
+ Utility::Edge | Utility::Pump => {}77
+ }78
+ if utility == Utility::Pump {79
+ self.here.edges = 0;80
+ }81
+ self.ready(Technique::Utility(utility), lube)?;82
+ self.check_storage()?;83
+ self.floor_enemy()?;84
+ match utility {85
+ Utility::Edge => self.edge(now),86
+ _ => self.pump(now),87
+ }88
+ }89
+90
+ fn single(91
+ &mut self,92
+ training: Training,93
+ intensity: i32,94
+ lube: Option<Lube>,95
+ now: Instant,96
+ ) -> Result<Outcome, Problem> {97
+ self.here.edges = 0;98
+ self.ready(Technique::Training(training), lube)?;99
+ self.check_storage()?;100
+ self.floor_enemy()?;101
+ self.commit(intensity, lube, now, 1);102
+ if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {103
+ return Ok(interrupted);104
+ }105
+ if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {106
+ return Ok(interrupted);107
+ }108
+ let outcome = self.roll(training, intensity, lube)?;109
+ Ok(self.gossip(outcome))110
+ }111
+112
+ fn combo(113
+ &mut self,114
+ a: Training,115
+ b: Training,116
+ intensity: i32,117
+ lube: Option<Lube>,118
+ now: Instant,119
+ ) -> Result<Outcome, Problem> {120
+ if a == b {121
+ return Err(Problem::new(122
+ StatusCode::BAD_REQUEST,123
+ "combo-invalid",124
+ "A technique cannot be combined with itself. The server has checked.",125
+ ));126
+ }127
+ self.here.edges = 0;128
+ self.ready(Technique::Training(a), lube)?;129
+ self.ready(Technique::Training(b), lube)?;130
+ self.check_storage()?;131
+ self.floor_enemy()?;132
+ self.commit(intensity, lube, now, 2);133
+ for training in [a, b] {134
+ if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {135
+ return Ok(interrupted);136
+ }137
+ }138
+ if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {139
+ return Ok(interrupted);140
+ }141
+142
+ let mut statuses = Vec::new();143
+ for training in [a, b] {144
+ let result = self.roll(training, intensity, lube);145
+ if self.ended.is_some() {146
+ return result;147
+ }148
+ let status = match result {149
+ Ok(outcome) => self.gossip(outcome).status,150
+ Err(problem) => problem.status(),151
+ };152
+ statuses.push((training, status));153
+ }154
+155
+ let mut xml = String::from(156
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",157
+ );158
+ for (training, status) in statuses {159
+ let _ = write!(160
+ xml,161
+ " <D:response>\n <D:href>{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",162
+ self.path(""),163
+ training.name(),164
+ status.as_u16(),165
+ crate::problem::reason(status),166
+ );167
+ }168
+ xml.push_str(169
+ " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",170
+ );171
+ Ok(Outcome::raw(172
+ StatusCode::MULTI_STATUS,173
+ "application/xml; charset=utf-8",174
+ xml,175
+ ))176
+ }177
+178
+ fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {179
+ if technique == Technique::Training(Training::Uli) && !self.forum_read() {180
+ return Err(Problem::new(181
+ StatusCode::FORBIDDEN,182
+ "technique-locked",183
+ "This technique is discussed on the forum. You have not been to the forum.",184
+ ));185
+ }186
+ if !self.warmed_up {187
+ return Err(Problem::new(188
+ StatusCode::PRECONDITION_FAILED,189
+ "cold-start",190
+ "Warm-up was skipped. The server has noted this.",191
+ )192
+ .with("technique", Utility::Warmup.name()));193
+ }194
+ let training = match technique {195
+ Technique::Utility(Utility::Pump) if !self.items.pump_pro => {196
+ return Err(Problem::new(197
+ StatusCode::FAILED_DEPENDENCY,198
+ "no-pump",199
+ "The pump technique depends on a pump.",200
+ ));201
+ }202
+ Technique::Utility(_) => return Ok(()),203
+ Technique::Training(training) => training,204
+ };205
+ let profile = training.profile();206
+ match (profile.lube, lube) {207
+ (LubeRule::Required, None) => {208
+ return Err(Problem::new(209
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,210
+ "lubricant-required",211
+ format!(212
+ "The {} technique requires a lubricant. None was declared.",213
+ training.name()214
+ ),215
+ )216
+ .with("lubricants", json!(LUBRICANTS)));217
+ }218
+ (LubeRule::Forbidden, Some(_)) => {219
+ return Err(Problem::new(220
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,221
+ "lubricant-declared",222
+ format!(223
+ "The {} technique was requested. A lubricant was declared.",224
+ training.name()225
+ ),226
+ ));227
+ }228
+ _ => {}229
+ }230
+ match profile.foreskin {231
+ Some((min, max)) => self.check_foreskin(min, max),232
+ None => Ok(()),233
+ }234
+ }235
+236
+ fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {237
+ let Some(f) = self.foreskin else {238
+ if min <= 0.0 {239
+ return Ok(());240
+ }241
+ return Err(Problem::new(242
+ StatusCode::GONE,243
+ "foreskin-gone",244
+ "This technique requires a foreskin. The foreskin is gone.",245
+ )246
+ .header(header::CONTENT_LOCATION, self.path("/foreskin")));247
+ };248
+ if f < min {249
+ return Err(Problem::new(250
+ StatusCode::LENGTH_REQUIRED,251
+ "foreskin-insufficient",252
+ format!(253
+ "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."254
+ ),255
+ )256
+ .with("required", min));257
+ }258
+ if f > max {259
+ return Err(Problem::new(260
+ StatusCode::PAYLOAD_TOO_LARGE,261
+ "foreskin-excessive",262
+ format!(263
+ "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."264
+ ),265
+ )266
+ .with("maximum", max));267
+ }268
+ Ok(())269
+ }270
+271
+ fn check_storage(&self) -> Result<(), Problem> {272
+ if self.items.extender && self.girth > STORAGE_GIRTH {273
+ return Err(Problem::new(274
+ StatusCode::INSUFFICIENT_STORAGE,275
+ "jeans-full",276
+ "The jeans are full. The extender is occupying the remaining storage.",277
+ )278
+ .with("extender", self.path("/inventory/extender")));279
+ }280
+ Ok(())281
+ }282
+283
+ pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {284
+ self.touch();285
+ self.turn += 1;286
+ self.here.turns += 1;287
+ self.realize();288
+ if self.items.extender {289
+ self.length += EXTENDER_GAIN;290
+ }291
+ self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));292
+ if self.stamina <= 0 {293
+ self.exhaust(now);294
+ }295
+ let chill = if self.floor == Floor::LockerRoom {296
+ LOCKER_CHILL297
+ } else {298
+ 0299
+ };300
+ let wobble = self.rng.random_range(-EQ_WOBBLE..=EQ_WOBBLE);301
+ self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);302
+303
+ let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));304
+ if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {305
+ suspicion *= 2;306
+ }307
+ if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {308
+ suspicion += 2;309
+ }310
+ let was_below = self.suspicion < SUSPICION_LIMIT;311
+ self.suspicion =312
+ u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))313
+ .unwrap_or(SUSPICION_LIMIT);314
+ self.maybe_summon_mom(was_below);315
+316
+ if let Some(observer) = self.floor.observer()317
+ && self.chance(OBSERVED)318
+ {319
+ self.observed = Some(observer);320
+ self.shame += 1;321
+ if observer == Observer::Cat && self.chance(CAT_TAKES_TOWEL) {322
+ self.here.towel_stolen = true;323
+ }324
+ }325
+ if intensity >= PULLED_MUSCLE_INTENSITY326
+ && !self.conditions.pulled_muscle327
+ && self.chance(PULLED_MUSCLE)328
+ {329
+ self.conditions.pulled_muscle = true;330
+ self.stamina = self.stamina.min(self.max_stamina());331
+ }332
+ if self.items.ring && self.eq > HOT_EQ && self.chance(RING_PRIAPISM) {333
+ self.conditions.priapism_turns = PRIAPISM_TURNS;334
+ }335
+ if self.conditions.priapism_turns > 0 {336
+ self.conditions.priapism_turns -= 1;337
+ self.eq = self.eq.max(PRIAPISM_EQ);338
+ }339
+ if self.turn == BRANCH_TURN && self.story == Story::Unopened {340
+ self.story = Story::Pending;341
+ }342
+ }343
+344
+ fn maybe_summon_mom(&mut self, was_below: bool) {345
+ if !self.floor.has_mom() || self.mom.is_some() {346
+ return;347
+ }348
+ let summoned = was_below && self.suspicion == SUSPICION_LIMIT;349
+ let visits = u32::from(self.suspicion) + 1;350
+ if summoned || self.rng.random_ratio(visits, super::balance::MOM_VISIT) {351
+ let errand = self.errand(summoned);352
+ let nonce = self.fresh_nonce();353
+ self.mom = Some(MomVisit {354
+ errand,355
+ nonce,356
+ strikes: 0,357
+ });358
+ }359
+ }360
+361
+ fn roll(362
+ &mut self,363
+ training: Training,364
+ intensity: i32,365
+ lube: Option<Lube>,366
+ ) -> Result<Outcome, Problem> {367
+ let profile = training.profile();368
+ let natural: i32 = self.rng.random_range(1..=20);369
+ let band = if EQ_BAND.contains(&self.eq) {370
+ BAND_BONUS371
+ } else {372
+ 0373
+ };374
+ let grip = match (self.grip, training) {375
+ (Grip::Underhand, Training::Stretch) => 2,376
+ (Grip::Underhand, Training::Wet) => -1,377
+ _ => 0,378
+ };379
+ let modifier = lube.map_or(0, Lube::modifier) + band + grip;380
+ let dc = profile.dc + intensity / 3;381
+ let total = natural + modifier;382
+ let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");383
+ let timed = |p: Problem| p.header(SERVER_TIMING, &timing);384
+385
+ let (length, girth) = self.scaled_gains(&profile, intensity);386
+ let rupture_line = if self.items.ring {387
+ RING_RUPTURE_LINE388
+ } else {389
+ RUPTURE_LINE390
+ };391
+392
+ if natural == 1 {393
+ return Err(timed(self.critical_failure(training, &profile)));394
+ }395
+ if natural == 2 && self.eq > DANGER_EQ {396
+ return Err(timed(self.rupture()));397
+ }398
+ if self.eq > rupture_line && total < dc {399
+ return Err(timed(self.rupture()));400
+ }401
+ if natural == 20 {402
+ self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);403
+ return Ok(Outcome::json(404
+ StatusCode::PERMANENT_REDIRECT,405
+ json!({406
+ "detail": "Your length has moved permanently.",407
+ "length": round3(length * 2.0),408
+ "girth": round3(girth * 2.0),409
+ }),410
+ )411
+ .header(header::LOCATION, self.path("/length"))412
+ .header(SERVER_TIMING, timing));413
+ }414
+ if total < dc - 3 {415
+ return Err(timed(not_extended()));416
+ }417
+ if total < dc {418
+ return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));419
+ }420
+421
+ let outcome = if self.eq < *EQ_BAND.start() {422
+ self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);423
+ Outcome::json(424
+ StatusCode::PARTIAL_CONTENT,425
+ json!({426
+ "detail": "Partial gains were applied. EQ was below the band.",427
+ "length": round3(length / 2.0),428
+ "girth": round3(girth / 2.0),429
+ }),430
+ )431
+ } else if total >= dc + 5 {432
+ self.gain(length, girth, profile.foreskin_growth);433
+ Outcome::json(434
+ StatusCode::OK,435
+ json!({436
+ "detail": "Gains were applied.",437
+ "length": round3(length),438
+ "girth": round3(girth),439
+ }),440
+ )441
+ } else {442
+ let eta = self.rng.random_range(1..=4);443
+ self.pending.push(Pending {444
+ due: self.turn + eta,445
+ length,446
+ girth,447
+ });448
+ self.gain(0.0, 0.0, profile.foreskin_growth);449
+ Outcome::json(450
+ StatusCode::ACCEPTED,451
+ json!({452
+ "detail": "Gains have been queued. They may arrive.",453
+ "eta_turns": eta,454
+ }),455
+ )456
+ };457
+ Ok(outcome.header(SERVER_TIMING, timing))458
+ }459
+460
+ fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {461
+ let scale = f64::from(intensity);462
+ let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);463
+ if self.here.teapot {464
+ length *= 0.5;465
+ girth *= 1.5;466
+ }467
+ if self.items.ring {468
+ girth *= 2.0;469
+ }470
+ (length, girth)471
+ }472
+473
+ fn critical_failure(&mut self, training: Training, profile: &Profile) -> Problem {474
+ let uses_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);475
+ if uses_foreskin && self.foreskin.is_some() {476
+ self.foreskin = None;477
+ self.shame += 25;478
+ return Problem::new(479
+ StatusCode::GONE,480
+ "foreskin-lost",481
+ "The technique has removed the foreskin. It will not be returning.",482
+ )483
+ .header(header::CONTENT_LOCATION, self.path("/foreskin"));484
+ }485
+ if self.eq > DANGER_EQ {486
+ return self.rupture();487
+ }488
+ if training == Training::Stretch && !self.here.teapot {489
+ self.here.teapot = true;490
+ self.items.teapot_spout = true;491
+ self.shame += 5;492
+ return Problem::new(493
+ StatusCode::IM_A_TEAPOT,494
+ "teapot",495
+ "The subject is now short and stout. Length gains are halved for the rest of the floor.",496
+ );497
+ }498
+ if training == Training::Uli && !self.conditions.peyronie {499
+ self.conditions.peyronie = true;500
+ self.shame += 5;501
+ return Problem::new(502
+ StatusCode::MISDIRECTED_REQUEST,503
+ "curvature",504
+ "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",505
+ );506
+ }507
+ not_extended()508
+ }509
+510
+ fn gossip(&mut self, mut outcome: Outcome) -> Outcome {511
+ let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);512
+ if !self.here.roommate_told || !reportable {513
+ return outcome;514
+ }515
+ self.here.roommate_told = false;516
+ outcome.status = StatusCode::ALREADY_REPORTED;517
+ if let Payload::Json(body) = &mut outcome.payload {518
+ body["detail"] = json!("The roommate has already told everyone.");519
+ }520
+ outcome521
+ }522
+}@@ -0,0 +1,133 @@
1
+use std::{2
+ ops::ControlFlow,3
+ time::{Duration, Instant, SystemTime},4
+};5
+6
+use axum::http::{StatusCode, header};7
+use serde_json::json;8
+9
+use super::{10
+ Floor, Outcome, Run,11
+ balance::{12
+ EDGE_EQ, EDGE_GIRTH, EDGE_LOOP, HOTEL_STRESS, ICE_EQ, PRIAPISM_EQ, PRIAPISM_TURNS,13
+ PUMP_GAIN, SHOWER_EQ, SUPPLEMENT_WORKS, TOWELLESS_EQ, WARMUP_EQ,14
+ },15
+};16
+use crate::problem::Problem;17
+18
+const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);19
+20
+impl Run {21
+ pub(super) fn warm_up(&mut self) -> Result<Outcome, Problem> {22
+ if self.floor == Floor::Hotel && self.chance(HOTEL_STRESS) {23
+ return Err(Problem::new(24
+ StatusCode::SERVICE_UNAVAILABLE,25
+ "service-unavailable",26
+ "Service is unavailable. The server estimates next week. The server is being pessimistic.",27
+ )28
+ .header(29
+ header::RETRY_AFTER,30
+ httpdate::fmt_http_date(SystemTime::now() + WEEK),31
+ ));32
+ }33
+ let (boost, detail) = if self.floor == Floor::Bathroom {34
+ (35
+ SHOWER_EQ,36
+ "Warmed up. The shower is hot. The roommate's will not be.",37
+ )38
+ } else if self.here.towel_stolen {39
+ (TOWELLESS_EQ, "Warmed up, poorly. The cat has the towel.")40
+ } else {41
+ (WARMUP_EQ, "Warmed up. The towel has cooled.")42
+ };43
+ self.touch();44
+ self.warmed_up = true;45
+ self.eq = (self.eq + boost).min(100);46
+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))47
+ }48
+49
+ pub(super) fn ice(&mut self) -> Result<Outcome, Problem> {50
+ if self.items.ice_packs == 0 {51
+ return Err(Problem::new(52
+ StatusCode::FAILED_DEPENDENCY,53
+ "no-ice",54
+ "The ice technique depends on an ice pack. There are none.",55
+ ));56
+ }57
+ self.touch();58
+ self.items.ice_packs -= 1;59
+ if self.conditions.priapism_turns > 0 {60
+ return Ok(Outcome::json(61
+ StatusCode::OK,62
+ json!({ "detail": "The ice has no effect. The condition persists. The ice pack is gone." }),63
+ ));64
+ }65
+ self.eq = (self.eq - ICE_EQ).max(0);66
+ Ok(Outcome::json(67
+ StatusCode::OK,68
+ json!({ "detail": "EQ has been reduced. The ice pack is gone." }),69
+ ))70
+ }71
+72
+ pub(super) fn supplement(&mut self) -> Result<Outcome, Problem> {73
+ if self.items.supplements == 0 {74
+ return Err(Problem::new(75
+ StatusCode::FAILED_DEPENDENCY,76
+ "no-supplement",77
+ "The supplement technique depends on a supplement.",78
+ ));79
+ }80
+ self.items.supplements -= 1;81
+ self.touch();82
+ if self.chance(SUPPLEMENT_WORKS) {83
+ self.conditions.priapism_turns = PRIAPISM_TURNS;84
+ self.eq = self.eq.max(PRIAPISM_EQ);85
+ return Ok(Outcome::json(86
+ StatusCode::OK,87
+ json!({ "detail": "The upstream returned a valid response. It is still returning it." }),88
+ ));89
+ }90
+ Err(Problem::new(91
+ StatusCode::BAD_GATEWAY,92
+ "bad-gateway",93
+ "The upstream returned an invalid response.",94
+ )95
+ .with("upstream", "Pump-Bro"))96
+ }97
+98
+ pub(super) fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {99
+ self.commit(5, None, now, 1);100
+ self.here.edges += 1;101
+ self.eq = (self.eq + EDGE_EQ).min(100);102
+ self.girth += EDGE_GIRTH;103
+ if self.here.edges >= EDGE_LOOP {104
+ self.here.edges = 0;105
+ self.exhaust(now);106
+ return Err(Problem::new(107
+ StatusCode::LOOP_DETECTED,108
+ "edging-loop",109
+ "Edging has looped. The server detected an infinite loop and terminated it.",110
+ ));111
+ }112
+ if let ControlFlow::Break(interrupted) = self.hazards(5)? {113
+ return Ok(interrupted);114
+ }115
+ Ok(Outcome::json(116
+ StatusCode::OK,117
+ json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.here.edges }),118
+ ))119
+ }120
+121
+ pub(super) fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {122
+ self.commit(4, None, now, 1);123
+ if let ControlFlow::Break(interrupted) = self.hazards(4)? {124
+ return Ok(interrupted);125
+ }126
+ self.here.temp_length += PUMP_GAIN;127
+ Ok(Outcome::json(128
+ StatusCode::TEMPORARY_REDIRECT,129
+ json!({ "detail": "Gains applied. They are temporary.", "length": PUMP_GAIN }),130
+ )131
+ .header(header::LOCATION, self.path("/length")))132
+ }133
+}@@ -0,0 +1,18 @@
1
+use axum::http::HeaderName;2
+3
+pub const ACCEPT_PATCH: HeaderName = HeaderName::from_static("accept-patch");4
+pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");5
+pub const X_CASH: HeaderName = HeaderName::from_static("x-cash");6
+pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");7
+pub const X_EQ: HeaderName = HeaderName::from_static("x-eq");8
+pub const X_FLOOR: HeaderName = HeaderName::from_static("x-floor");9
+pub const X_FORESKIN: HeaderName = HeaderName::from_static("x-foreskin");10
+pub const X_FORUM_NOTE: HeaderName = HeaderName::from_static("x-forum-note");11
+pub const X_GIRTH: HeaderName = HeaderName::from_static("x-girth");12
+pub const X_OBSERVED_BY: HeaderName = HeaderName::from_static("x-observed-by");13
+pub const X_POWERED_BY: HeaderName = HeaderName::from_static("x-powered-by");14
+pub const X_SEED: HeaderName = HeaderName::from_static("x-seed");15
+pub const X_SELF_REPORTED_LENGTH: HeaderName = HeaderName::from_static("x-self-reported-length");16
+pub const X_SHAME: HeaderName = HeaderName::from_static("x-shame");17
+pub const X_STAMINA: HeaderName = HeaderName::from_static("x-stamina");18
+pub const X_SUSPICION: HeaderName = HeaderName::from_static("x-suspicion");@@ -1,1278 +0,0 @@
1
-use std::{2
- collections::HashMap,3
- convert::Infallible,4
- fmt::Write as _,5
- io::{Read as _, Write as _},6
- sync::{Arc, Mutex},7
- time::Instant,8
-};9
-10
-use axum::{11
- Json, Router,12
- body::{Body, Bytes},13
- extract::{Path, Query, Request, State},14
- http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header},15
- middleware::{self, Next},16
- response::{Html, IntoResponse, Redirect, Response},17
- routing::any,18
-};19
-use flate2::{Compression, read::GzDecoder, write::GzEncoder};20
-use hyper_util::rt::TokioIo;21
-use rand::Rng;22
-use serde::Deserialize;23
-use serde_json::{Value, json};24
-use tokio::io::AsyncWriteExt;25
-use tower::ServiceExt;26
-27
-use crate::{28
- game::{29
- ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MM_PER_INCH, PatchOp,30
- RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,31
- },32
- problem::{PROBLEM_BASE, Problem, reason},33
- secret,34
-};35
-36
-const REL_BASE: &str = "https://jelq.dev/rels/";37
-const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";38
-const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";39
-const MAX_URI: usize = 512;40
-const MAX_HEADER: usize = 1024;41
-42
-#[derive(Default)]43
-pub struct AppState {44
- runs: Mutex<HashMap<String, Run>>,45
-}46
-47
-impl AppState {48
- pub fn insert(&self, run: Run) {49
- self.runs.lock().unwrap().insert(run.id.clone(), run);50
- }51
-}52
-53
-type Shared = Arc<AppState>;54
-55
-#[derive(Clone)]56
-struct Compress;57
-58
-/// Which household hazards stand between the request and the run.59
-#[derive(Clone, Copy, PartialEq, Eq)]60
-enum Scope {61
- Home,62
- Pockets,63
-}64
-65
-pub fn app() -> Router {66
- router(Arc::default())67
-}68
-69
-pub fn router(state: Shared) -> Router {70
- let api = api(state);71
- let ui = Router::new()72
- .route("/ui", any(|| async { Redirect::permanent("/ui/") }))73
- .route("/ui/", any(ui))74
- .route("/ui/send", any(ui_send))75
- .with_state(api.clone());76
- api.merge(ui)77
-}78
-79
-fn api(state: Shared) -> Router {80
- Router::new()81
- .route("/", any(index))82
- .route("/runs", any(runs))83
- .route("/runs/{id}", any(run))84
- .route("/runs/{id}/length", any(length))85
- .route("/runs/{id}/foreskin", any(foreskin))86
- .route("/runs/{id}/inventory", any(inventory))87
- .route("/runs/{id}/inventory/{item}", any(item))88
- .route("/runs/{id}/techniques", any(techniques))89
- .route("/runs/{id}/drawer", any(drawer))90
- .route("/runs/{id}/branches/{choice}", any(branch))91
- .route("/runs/{id}/floor", any(floor))92
- .route("/runs/{id}/shop", any(shop))93
- .route("/runs/{id}/shop/{item}", any(buy))94
- .route("/runs/{id}/portal", any(portal))95
- .route("/runs/{id}/elder", any(elder))96
- .route("/runs/{id}/grip", any(grip))97
- .route("/runs/{id}/diagnosis", any(diagnosis))98
- .route("/runs/{id}/footage", any(footage))99
- .route("/forum/threads/{year}", any(thread))100
- .route("/graveyard", any(graveyard))101
- .route("/leaderboard", any(leaderboard))102
- .fallback(not_found)103
- .layer(middleware::from_fn(chrome))104
- .with_state(state)105
-}106
-107
-fn reject(request: &Request) -> Option<Problem> {108
- if request.version() < Version::HTTP_11 {109
- return Some(Problem::new(110
- StatusCode::HTTP_VERSION_NOT_SUPPORTED,111
- "pdf",112
- "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.",113
- ));114
- }115
- if request.uri().to_string().len() > MAX_URI {116
- return Some(Problem::new(117
- StatusCode::URI_TOO_LONG,118
- "uri-too-long",119
- "The URI is longer than anything else here.",120
- ));121
- }122
- if request.headers().values().any(|v| v.len() > MAX_HEADER) {123
- return Some(Problem::new(124
- StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,125
- "head-too-large",126
- "Head too large.",127
- ));128
- }129
- let expectation = request.headers().get(header::EXPECT)?;130
- if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") {131
- return None;132
- }133
- Some(Problem::new(134
- StatusCode::EXPECTATION_FAILED,135
- "expectations",136
- format!(137
- "Expect: {} cannot be met.",138
- expectation.to_str().unwrap_or("that")139
- ),140
- ))141
-}142
-143
-async fn compress(response: Response) -> Response {144
- let (mut parts, body) = response.into_parts();145
- let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {146
- return Response::from_parts(parts, Body::empty());147
- };148
- let mut encoder = GzEncoder::new(Vec::new(), Compression::default());149
- if encoder.write_all(&bytes).is_err() {150
- return Response::from_parts(parts, Body::from(bytes));151
- }152
- let Ok(gzipped) = encoder.finish() else {153
- return Response::from_parts(parts, Body::from(bytes));154
- };155
- parts156
- .headers157
- .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));158
- parts.headers.remove(header::CONTENT_LENGTH);159
- parts160
- .headers161
- .append(header::VARY, HeaderValue::from_static("Accept-Encoding"));162
- Response::from_parts(parts, Body::from(gzipped))163
-}164
-165
-async fn chrome(request: Request, next: Next) -> Response {166
- let gzip = header_str(request.headers(), header::ACCEPT_ENCODING)167
- .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));168
- let mut response = match reject(&request) {169
- Some(problem) => problem.into_response(),170
- None => next.run(request).await,171
- };172
- if gzip && response.extensions().get::<Compress>().is_some() {173
- response = compress(response).await;174
- }175
- let headers = response.headers_mut();176
- headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));177
- headers.insert("x-powered-by", HeaderValue::from_static("blood"));178
- headers179
- .entry(header::CACHE_CONTROL)180
- .or_insert(HeaderValue::from_static("no-store, private"));181
- response182
-}183
-184
-fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {185
- headers.get(name).and_then(|v| v.to_str().ok())186
-}187
-188
-fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {189
- headers190
- .get_all(header::COOKIE)191
- .iter()192
- .filter_map(|v| v.to_str().ok())193
- .flat_map(|v| v.split(';'))194
- .filter_map(|pair| pair.trim().split_once('='))195
- .find(|(key, _)| *key == name)196
- .map(|(_, value)| value)197
-}198
-199
-fn unsupported(method: &Method, allow: &'static str) -> Response {200
- if *method == Method::CONNECT {201
- return StatusCode::NOT_IMPLEMENTED.into_response();202
- }203
- let known = matches!(204
- method.as_str(),205
- "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"206
- );207
- if method.as_str() == "BREW" {208
- return Problem::new(209
- StatusCode::IM_A_TEAPOT,210
- "brew",211
- "The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.",212
- )213
- .into_response();214
- }215
- if method.as_str().eq_ignore_ascii_case("jelq") {216
- return Problem::new(217
- StatusCode::NOT_IMPLEMENTED,218
- "not-implemented",219
- format!(220
- "The server does not implement {method}. Methods are case-sensitive. JELQ is shouted for a reason."221
- ),222
- )223
- .into_response();224
- }225
- if !known {226
- return Problem::new(227
- StatusCode::NOT_IMPLEMENTED,228
- "not-implemented",229
- format!("The server does not implement {method}."),230
- )231
- .into_response();232
- }233
- Problem::new(234
- StatusCode::METHOD_NOT_ALLOWED,235
- "method-not-allowed",236
- format!("{method} is not supported here."),237
- )238
- .header(header::ALLOW, allow)239
- .into_response()240
-}241
-242
-fn options(allow: &'static str, patchable: bool) -> Response {243
- let mut response = (244
- StatusCode::OK,245
- [(header::ALLOW, allow)],246
- Json(json!({ "detail": THERAPY })),247
- )248
- .into_response();249
- if patchable {250
- response.headers_mut().insert(251
- "accept-patch",252
- HeaderValue::from_static("application/json-patch+json"),253
- );254
- }255
- response256
-}257
-258
-fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response {259
- let mut message = format!("{method} {uri} {version:?}\r\n");260
- for (name, value) in headers {261
- let _ = write!(262
- message,263
- "{name}: {}\r\n",264
- value.to_str().unwrap_or("<binary>")265
- );266
- }267
- message.push_str("\r\n");268
- (269
- StatusCode::OK,270
- [(header::CONTENT_TYPE, "message/http")],271
- message,272
- )273
- .into_response()274
-}275
-276
-fn links(run: &Run) -> String {277
- let id = &run.id;278
- let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];279
- for rel in [280
- "floor",281
- "length",282
- "foreskin",283
- "inventory",284
- "techniques",285
- "drawer",286
- "grip",287
- "diagnosis",288
- ] {289
- links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));290
- }291
- if run.floor.shop_open() {292
- links.push(format!("</runs/{id}/shop>; rel=\"{REL_BASE}shop\""));293
- }294
- links.join(", ")295
-}296
-297
-fn finish(run: &Run, mut response: Response) -> Response {298
- let status = response.status();299
- if status == StatusCode::NOT_MODIFIED {300
- return response;301
- }302
- let mut stats = vec![303
- (304
- "x-self-reported-length",305
- format!("{:.2}", run.self_reported()),306
- ),307
- ("x-girth", format!("{:.2}", run.girth)),308
- (309
- "x-foreskin",310
- run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),311
- ),312
- ("x-eq", run.eq.to_string()),313
- (314
- "x-stamina",315
- format!("{}/{}", run.stamina, run.max_stamina()),316
- ),317
- (318
- "x-suspicion",319
- format!("{}/{SUSPICION_LIMIT}", run.suspicion),320
- ),321
- ("x-shame", run.total_shame().to_string()),322
- ("x-cash", run.cash.to_string()),323
- ("x-floor", run.floor.number().to_string()),324
- ("link", links(run)),325
- ];326
- if let Some(observer) = run.observed {327
- stats.push(("x-observed-by", observer.into()));328
- }329
- if run.mom_present {330
- stats.push(("via", "1.1 mom".into()));331
- stats.push(("vary", "Mom".into()));332
- }333
- // RFC 9111 obsoleted Warning, which is why nobody heeds it.334
- if run.pulled_muscle {335
- stats.push(("warning", "199 - \"a muscle has been pulled\"".into()));336
- }337
- if run.eq > 70 && run.ended.is_none() {338
- stats.push(("warning", "199 - \"it is turning purple\"".into()));339
- }340
- stats.push((341
- "set-cookie",342
- format!(343
- "shame={}; {COOKIE_ATTRIBUTES}",344
- secret::sign("shame", &run.total_shame().to_string())345
- ),346
- ));347
- if run.items.ruler {348
- stats.push((349
- "set-cookie",350
- format!("ruler={}; {COOKIE_ATTRIBUTES}", secret::sign("ruler", "1")),351
- ));352
- }353
- let headers = response.headers_mut();354
- for (name, value) in stats {355
- headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));356
- }357
- let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;358
- if run.items.ring && !bodiless {359
- response.extensions_mut().insert(Compress);360
- }361
- response362
-}363
-364
-fn gone(run: &Run) -> Response {365
- let ending = run.ended.as_ref().expect("only called on ended runs");366
- Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")367
- .with("cause", format!("{PROBLEM_BASE}{}", ending.cause))368
- .with("turn", ending.turn)369
- .with("graveyard", "/graveyard")370
- .into_response()371
-}372
-373
-fn captive_portal(run: &Run) -> Response {374
- Problem::new(375
- StatusCode::NETWORK_AUTHENTICATION_REQUIRED,376
- "captive-portal",377
- "The network requires authentication. The hotel would like you to agree to some terms.",378
- )379
- .with("portal", format!("/runs/{}/portal", run.id))380
- .header(381
- header::LINK,382
- format!("</runs/{}/portal>; rel=\"{REL_BASE}portal\"", run.id),383
- )384
- .into_response()385
-}386
-387
-fn with_run(388
- state: &AppState,389
- id: &str,390
- headers: &HeaderMap,391
- scope: Scope,392
- handle: impl FnOnce(&mut Run) -> Response,393
-) -> Response {394
- let mut runs = state.runs.lock().unwrap();395
- let Some(run) = runs.get_mut(id) else {396
- return Problem::new(397
- StatusCode::NOT_FOUND,398
- "no-such-run",399
- format!("There is no run {id}. There may never have been."),400
- )401
- .into_response();402
- };403
- if run.ended.is_some() {404
- return gone(run);405
- }406
- run.observed = None;407
- if scope == Scope::Home {408
- if run.floor == Floor::Hotel && !run.wifi_accepted {409
- return finish(run, captive_portal(run));410
- }411
- if run.floor.has_mom()412
- && run.mom_present413
- && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))414
- {415
- return finish(run, problem.into_response());416
- }417
- }418
- let response = handle(run);419
- finish(run, response)420
-}421
-422
-fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response {423
- match result {424
- Ok(ok) => ok.into_response(),425
- Err(err) => err.into_response(),426
- }427
-}428
-429
-async fn runs(430
- State(state): State<Shared>,431
- method: Method,432
- Query(query): Query<HashMap<String, String>>,433
- headers: HeaderMap,434
-) -> Response {435
- if method != Method::POST {436
- return unsupported(&method, "POST");437
- }438
- let seeded = query.contains_key("seed");439
- let seed = match query.get("seed").map(|s| s.parse::<u64>()) {440
- None => rand::rng().random(),441
- Some(Ok(seed)) => seed,442
- Some(Err(_)) => {443
- return Problem::new(444
- StatusCode::BAD_REQUEST,445
- "bad-seed",446
- "The seed must be an unsigned 64-bit integer.",447
- )448
- .into_response();449
- }450
- };451
- let shame = cookie(&headers, "shame")452
- .and_then(|s| secret::verify("shame", s))453
- .and_then(|s| s.parse().ok())454
- .unwrap_or(0);455
- let id = format!("{:08x}", rand::rng().random::<u32>());456
- let mut run = Run::new(id.clone(), secret::mix_seed(seed), shame);457
- run.seeded = seeded;458
- let response = (459
- StatusCode::CREATED,460
- [461
- (header::LOCATION, format!("/runs/{id}")),462
- (header::ETAG, run.etag()),463
- (HeaderName::from_static("x-seed"), seed.to_string()),464
- ],465
- Json(run.view()),466
- )467
- .into_response();468
- let response = finish(&run, response);469
- state.insert(run);470
- response471
-}472
-473
-#[allow(clippy::too_many_arguments)]474
-async fn run(475
- State(state): State<Shared>,476
- Path(id): Path<String>,477
- method: Method,478
- uri: Uri,479
- version: Version,480
- Query(query): Query<HashMap<String, String>>,481
- headers: HeaderMap,482
- body: Bytes,483
-) -> Response {484
- let now = Instant::now();485
- with_run(&state, &id, &headers, Scope::Home, |run| {486
- match method.as_str() {487
- "GET" | "HEAD" => {488
- let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();489
- if accept.contains("reassurance") {490
- return Problem::new(491
- StatusCode::NOT_ACCEPTABLE,492
- "no-reassurance",493
- "The server does not produce reassurance.",494
- )495
- .into_response();496
- }497
- (498
- StatusCode::OK,499
- [(header::ETAG, run.etag())],500
- Json(run.view()),501
- )502
- .into_response()503
- }504
- "JELQ" => jelq(run, &query, &headers, now),505
- "PUT" => run.put().into_response(),506
- "PATCH" => patch(run, &headers, &body),507
- "DELETE" => {508
- run.end("abandoned", "The run was abandoned.");509
- StatusCode::NO_CONTENT.into_response()510
- }511
- "OPTIONS" => options(RUN_ALLOW, true),512
- "TRACE" => {513
- run.shame += 1;514
- trace(&method, &uri, version, &headers)515
- }516
- _ => unsupported(&method, RUN_ALLOW),517
- }518
- })519
-}520
-521
-fn jelq(522
- run: &mut Run,523
- query: &HashMap<String, String>,524
- headers: &HeaderMap,525
- now: Instant,526
-) -> Response {527
- let techniques_href = format!("/runs/{}/techniques", run.id);528
- let Some(names) = query.get("technique").filter(|n| !n.trim().is_empty()) else {529
- return Problem::new(530
- StatusCode::BAD_REQUEST,531
- "technique-required",532
- "No technique was specified.",533
- )534
- .with("techniques", techniques_href)535
- .into_response();536
- };537
- let mut techniques = Vec::new();538
- for name in names.split(',').map(str::trim).filter(|n| !n.is_empty()) {539
- let Some(technique) = Technique::parse(name) else {540
- return Problem::new(541
- StatusCode::BAD_REQUEST,542
- "unknown-technique",543
- format!("{name} is not a technique."),544
- )545
- .with("techniques", techniques_href)546
- .into_response();547
- };548
- techniques.push(technique);549
- }550
- let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {551
- None => 5,552
- Some(Ok(i)) if (1..=10).contains(&i) => i,553
- Some(_) => {554
- return Problem::new(555
- StatusCode::BAD_REQUEST,556
- "intensity-out-of-range",557
- "Intensity is measured from 1 to 10.",558
- )559
- .into_response();560
- }561
- };562
- let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) {563
- Ok(lube) => lube,564
- Err(problem) => return problem.into_response(),565
- };566
- respond(run.jelq(&techniques, intensity, lube, now))567
-}568
-569
-fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {570
- let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default();571
- if !content_type.starts_with("application/json-patch+json") {572
- return Problem::new(573
- StatusCode::UNSUPPORTED_MEDIA_TYPE,574
- "patch-format",575
- "Patches are accepted as application/json-patch+json.",576
- )577
- .header(578
- HeaderName::from_static("accept-patch"),579
- "application/json-patch+json",580
- )581
- .into_response();582
- }583
- match serde_json::from_slice::<Vec<PatchOp>>(body) {584
- Ok(ops) => respond(run.patch(&ops)),585
- Err(_) => Problem::new(586
- StatusCode::BAD_REQUEST,587
- "malformed-patch",588
- "The patch could not be parsed.",589
- )590
- .into_response(),591
- }592
-}593
-594
-fn millimetres(inches: f64) -> usize {595
- (inches * MM_PER_INCH).round() as usize596
-}597
-598
-fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {599
- let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;600
- let start: f64 = start.trim().parse().ok()?;601
- let end: f64 = match end.trim() {602
- "" => total,603
- end => end.parse().ok()?,604
- };605
- (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))606
-}607
-608
-fn length_response(run: &Run, headers: &HeaderMap) -> Response {609
- let inches = if run.items.ruler {610
- run.current_length()611
- } else {612
- run.self_reported()613
- };614
- let body = shaft(millimetres(inches));615
- let content_type = (616
- header::CONTENT_TYPE,617
- "text/plain; charset=us-ascii".to_owned(),618
- );619
- let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());620
-621
- if let Some(range) = header_str(headers, header::RANGE)622
- && range.starts_with("inches=")623
- {624
- let Some((start, end)) = parse_inch_range(range, inches) else {625
- return Problem::new(626
- StatusCode::RANGE_NOT_SATISFIABLE,627
- "range-not-satisfiable",628
- format!("The requested range is not available. {inches:.2} inches are."),629
- )630
- .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))631
- .into_response();632
- };633
- let from = millimetres(start).min(body.len() - 1);634
- let to = millimetres(end).clamp(from + 1, body.len());635
- let content_range = (636
- header::CONTENT_RANGE,637
- format!("inches {start:.2}-{end:.2}/{inches:.2}"),638
- );639
- return (640
- StatusCode::PARTIAL_CONTENT,641
- [content_type, accept_ranges, content_range],642
- body[from..to].to_owned(),643
- )644
- .into_response();645
- }646
-647
- if run.items.ruler {648
- return (StatusCode::OK, [content_type, accept_ranges], body).into_response();649
- }650
- let chunks: Vec<Result<Bytes, Infallible>> = body651
- .into_bytes()652
- .chunks(16)653
- .map(|c| Ok(Bytes::copy_from_slice(c)))654
- .collect();655
- let body = Body::from_stream(futures_util::stream::iter(chunks));656
- (StatusCode::OK, [content_type, accept_ranges], body).into_response()657
-}658
-659
-async fn length(660
- State(state): State<Shared>,661
- Path(id): Path<String>,662
- method: Method,663
- headers: HeaderMap,664
-) -> Response {665
- const ALLOW: &str = "GET, HEAD, OPTIONS";666
- with_run(&state, &id, &headers, Scope::Home, |run| {667
- match method.as_str() {668
- "GET" | "HEAD" => length_response(run, &headers),669
- "OPTIONS" => options(ALLOW, false),670
- _ => unsupported(&method, ALLOW),671
- }672
- })673
-}674
-675
-async fn foreskin(676
- State(state): State<Shared>,677
- Path(id): Path<String>,678
- method: Method,679
- headers: HeaderMap,680
-) -> Response {681
- const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";682
- with_run(&state, &id, &headers, Scope::Home, |run| {683
- match method.as_str() {684
- "GET" | "HEAD" => match run.foreskin {685
- Some(f) => (686
- StatusCode::OK,687
- [(header::ETAG, run.etag())],688
- Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),689
- )690
- .into_response(),691
- None => Problem::new(692
- StatusCode::GONE,693
- "foreskin-gone",694
- "The foreskin is gone. It will not be returning.",695
- )696
- .into_response(),697
- },698
- "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),699
- "OPTIONS" => options(ALLOW, false),700
- _ => unsupported(&method, ALLOW),701
- }702
- })703
-}704
-705
-async fn inventory(706
- State(state): State<Shared>,707
- Path(id): Path<String>,708
- method: Method,709
- headers: HeaderMap,710
-) -> Response {711
- const ALLOW: &str = "GET, HEAD, OPTIONS";712
- with_run(&state, &id, &headers, Scope::Pockets, |run| {713
- match method.as_str() {714
- "GET" | "HEAD" => {715
- let listed: Vec<Value> = run716
- .inventory()717
- .into_iter()718
- .map(|(name, _)| {719
- json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) })720
- })721
- .collect();722
- Json(json!({723
- "items": listed,724
- "ice_packs": run.items.ice_packs,725
- "cash": run.cash,726
- "excuses": run.excuses(),727
- }))728
- .into_response()729
- }730
- "OPTIONS" => options(ALLOW, false),731
- _ => unsupported(&method, ALLOW),732
- }733
- })734
-}735
-736
-async fn item(737
- State(state): State<Shared>,738
- Path((id, name)): Path<(String, String)>,739
- method: Method,740
- headers: HeaderMap,741
-) -> Response {742
- const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";743
- with_run(&state, &id, &headers, Scope::Pockets, |run| {744
- let Some((name, description)) = run.inventory().into_iter().find(|(n, _)| *n == name)745
- else {746
- return Problem::new(747
- StatusCode::NOT_FOUND,748
- "no-such-item",749
- "You do not have that.",750
- )751
- .into_response();752
- };753
- match method.as_str() {754
- "GET" | "HEAD" => {755
- Json(json!({ "name": name, "description": description })).into_response()756
- }757
- "DELETE" => respond(run.drop_item(name)),758
- "OPTIONS" => options(ALLOW, false),759
- _ => unsupported(&method, ALLOW),760
- }761
- })762
-}763
-764
-async fn techniques(765
- State(state): State<Shared>,766
- Path(id): Path<String>,767
- method: Method,768
- headers: HeaderMap,769
-) -> Response {770
- const ALLOW: &str = "GET, HEAD, OPTIONS";771
- with_run(&state, &id, &headers, Scope::Home, |run| {772
- match method.as_str() {773
- "GET" | "HEAD" => {774
- let listed: Vec<Value> = Technique::ALL775
- .into_iter()776
- .map(|t| t.describe(run.forum_read))777
- .collect();778
- Json(json!({779
- "usage": format!("JELQ /runs/{}?technique=<name>[,<name>]&intensity=<1-10>", run.id),780
- "lubricant": "Declared with Content-Type.",781
- "lubricants": LUBRICANTS,782
- "eq_band": [50, 70],783
- "techniques": listed,784
- }))785
- .into_response()786
- }787
- "OPTIONS" => options(ALLOW, false),788
- _ => unsupported(&method, ALLOW),789
- }790
- })791
-}792
-793
-async fn drawer(794
- State(state): State<Shared>,795
- Path(id): Path<String>,796
- method: Method,797
- headers: HeaderMap,798
-) -> Response {799
- with_run(&state, &id, &headers, Scope::Home, |run| {800
- match method.as_str() {801
- "POST" => respond(run.search_drawer()),802
- "OPTIONS" => options("POST, OPTIONS", false),803
- _ => unsupported(&method, "POST, OPTIONS"),804
- }805
- })806
-}807
-808
-async fn branch(809
- State(state): State<Shared>,810
- Path((id, choice)): Path<(String, String)>,811
- method: Method,812
- headers: HeaderMap,813
-) -> Response {814
- with_run(&state, &id, &headers, Scope::Home, |run| {815
- match method.as_str() {816
- "POST" => respond(run.choose(&choice)),817
- "OPTIONS" => options("POST, OPTIONS", false),818
- _ => unsupported(&method, "POST, OPTIONS"),819
- }820
- })821
-}822
-823
-async fn floor(824
- State(state): State<Shared>,825
- Path(id): Path<String>,826
- method: Method,827
- headers: HeaderMap,828
-) -> Response {829
- const ALLOW: &str = "GET, HEAD, POST, OPTIONS";830
- with_run(&state, &id, &headers, Scope::Pockets, |run| {831
- match method.as_str() {832
- "GET" | "HEAD" => Json(run.floor_view()).into_response(),833
- "POST" => respond(run.advance()),834
- "OPTIONS" => options(ALLOW, false),835
- _ => unsupported(&method, ALLOW),836
- }837
- })838
-}839
-840
-async fn shop(841
- State(state): State<Shared>,842
- Path(id): Path<String>,843
- method: Method,844
- headers: HeaderMap,845
-) -> Response {846
- const ALLOW: &str = "GET, HEAD, OPTIONS";847
- with_run(&state, &id, &headers, Scope::Home, |run| {848
- match method.as_str() {849
- "GET" | "HEAD" => respond(run.shop_view().map(Json)),850
- "OPTIONS" => options(ALLOW, false),851
- _ => unsupported(&method, ALLOW),852
- }853
- })854
-}855
-856
-async fn buy(857
- State(state): State<Shared>,858
- Path((id, item)): Path<(String, String)>,859
- method: Method,860
- headers: HeaderMap,861
-) -> Response {862
- with_run(&state, &id, &headers, Scope::Home, |run| {863
- match method.as_str() {864
- "POST" => respond(run.buy(&item)),865
- "OPTIONS" => options("POST, OPTIONS", false),866
- _ => unsupported(&method, "POST, OPTIONS"),867
- }868
- })869
-}870
-871
-async fn portal(872
- State(state): State<Shared>,873
- Path(id): Path<String>,874
- method: Method,875
- headers: HeaderMap,876
-) -> Response {877
- const ALLOW: &str = "GET, HEAD, POST, OPTIONS";878
- with_run(&state, &id, &headers, Scope::Pockets, |run| {879
- match method.as_str() {880
- "GET" | "HEAD" => Json(json!({881
- "terms": "By connecting you agree that the hotel is not responsible for anything that happens in this room, and that it does not want to know.",882
- "accept": { "href": format!("/runs/{}/portal", run.id), "method": "POST" },883
- }))884
- .into_response(),885
- "POST" => respond(run.accept_terms()),886
- "OPTIONS" => options(ALLOW, false),887
- _ => unsupported(&method, ALLOW),888
- }889
- })890
-}891
-892
-async fn elder(893
- State(state): State<Shared>,894
- Path(id): Path<String>,895
- method: Method,896
- headers: HeaderMap,897
-) -> Response {898
- const ALLOW: &str = "GET, HEAD, OPTIONS";899
- with_run(&state, &id, &headers, Scope::Home, |run| {900
- match method.as_str() {901
- "GET" | "HEAD" => respond(run.elder(902
- header_str(&headers, header::PROXY_AUTHORIZATION),903
- header_str(&headers, header::IF_MODIFIED_SINCE),904
- )),905
- "OPTIONS" => options(ALLOW, false),906
- _ => unsupported(&method, ALLOW),907
- }908
- })909
-}910
-911
-fn requested_grip(headers: &HeaderMap) -> Option<Grip> {912
- let connection = header_str(headers, header::CONNECTION)?.to_ascii_lowercase();913
- if !connection.split(',').any(|token| token.trim() == "upgrade") {914
- return None;915
- }916
- header_str(headers, header::UPGRADE)?917
- .split(',')918
- .find_map(|protocol| Grip::parse(protocol.trim()))919
-}920
-921
-async fn grip(922
- State(state): State<Shared>,923
- Path(id): Path<String>,924
- mut request: Request,925
-) -> Response {926
- const ALLOW: &str = "GET, HEAD, OPTIONS";927
- let method = request.method().clone();928
- let headers = request.headers().clone();929
- let wanted = requested_grip(&headers);930
- let upgrade = wanted.map(|_| hyper::upgrade::on(&mut request));931
- let detail = match header_str(&headers, header::UPGRADE) {932
- Some(protocol) if wanted.is_none() => {933
- format!("The server does not know the {protocol} grip. Grips are protocols.")934
- }935
- _ => "Grips are protocols. Change grip by upgrading the connection.".to_owned(),936
- };937
- let response = with_run(&state, &id, &headers, Scope::Home, |run| {938
- match (method.as_str(), wanted) {939
- ("GET" | "HEAD", Some(grip)) => {940
- run.set_grip(grip);941
- (942
- StatusCode::SWITCHING_PROTOCOLS,943
- [944
- (header::UPGRADE, grip.name()),945
- (header::CONNECTION, "upgrade"),946
- ],947
- )948
- .into_response()949
- }950
- ("GET" | "HEAD", None) => (951
- StatusCode::OK,952
- [(header::UPGRADE, "overhand, underhand")],953
- Json(json!({954
- "grip": run.grip.name(),955
- "detail": detail,956
- })),957
- )958
- .into_response(),959
- ("OPTIONS", _) => options(ALLOW, false),960
- _ => unsupported(&method, ALLOW),961
- }962
- });963
- if response.status() == StatusCode::SWITCHING_PROTOCOLS964
- && let (Some(upgrade), Some(grip)) = (upgrade, wanted)965
- {966
- tokio::spawn(async move {967
- if let Ok(upgraded) = upgrade.await {968
- let mut io = TokioIo::new(upgraded);969
- let line = format!("You are now jelqing {}.\r\n", grip.name());970
- let _ = io.write_all(line.as_bytes()).await;971
- let _ = io.shutdown().await;972
- }973
- });974
- }975
- response976
-}977
-978
-async fn diagnosis(979
- State(state): State<Shared>,980
- Path(id): Path<String>,981
- method: Method,982
- headers: HeaderMap,983
-) -> Response {984
- const ALLOW: &str = "GET, HEAD, OPTIONS";985
- with_run(&state, &id, &headers, Scope::Pockets, |run| {986
- match method.as_str() {987
- "GET" | "HEAD" => respond(run.diagnose(988
- header_str(&headers, header::AUTHORIZATION),989
- header_str(&headers, header::IF_NONE_MATCH),990
- )),991
- "OPTIONS" => options(ALLOW, false),992
- _ => unsupported(&method, ALLOW),993
- }994
- })995
-}996
-997
-async fn footage(998
- State(state): State<Shared>,999
- Path(id): Path<String>,1000
- method: Method,1001
- headers: HeaderMap,1002
-) -> Response {1003
- const ALLOW: &str = "GET, HEAD, OPTIONS";1004
- with_run(&state, &id, &headers, Scope::Pockets, |run| {1005
- match method.as_str() {1006
- "GET" | "HEAD" => {1007
- let Some((filmed, age)) = run.footage() else {1008
- return Problem::new(1009
- StatusCode::NOT_FOUND,1010
- "no-footage",1011
- "There is no footage. Yet.",1012
- )1013
- .into_response();1014
- };1015
- (1016
- [1017
- (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_owned()),1018
- (header::AGE, age.to_string()),1019
- (header::LAST_MODIFIED, httpdate::fmt_http_date(filmed)),1020
- ],1021
- Json(json!({1022
- "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",1023
- "views": 1 + age * 37,1024
- })),1025
- )1026
- .into_response()1027
- }1028
- "DELETE" => Problem::new(1029
- StatusCode::METHOD_NOT_ALLOWED,1030
- "the-internet",1031
- "The footage has been cached by intermediaries. It cannot be deleted.",1032
- )1033
- .header(header::ALLOW, ALLOW)1034
- .into_response(),1035
- "OPTIONS" => options(ALLOW, false),1036
- _ => unsupported(&method, ALLOW),1037
- }1038
- })1039
-}1040
-1041
-async fn thread(Path(year): Path<String>, method: Method) -> Response {1042
- if method != Method::GET && method != Method::HEAD {1043
- return unsupported(&method, "GET, HEAD");1044
- }1045
- match year.parse::<u32>() {1046
- Ok(2009) => (1047
- [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],1048
- Json(forum_thread()),1049
- )1050
- .into_response(),1051
- Ok(y) if y > 2009 => Problem::new(1052
- StatusCode::NOT_FOUND,1053
- "no-thread",1054
- "Nobody has posted since 2009.",1055
- )1056
- .with("thread", FORUM_THREAD)1057
- .into_response(),1058
- _ => Problem::new(1059
- StatusCode::NOT_FOUND,1060
- "no-thread",1061
- "The forum did not exist yet. The problem did.",1062
- )1063
- .with("thread", FORUM_THREAD)1064
- .into_response(),1065
- }1066
-}1067
-1068
-async fn graveyard(State(state): State<Shared>, method: Method) -> Response {1069
- if method != Method::GET && method != Method::HEAD {1070
- return unsupported(&method, "GET, HEAD");1071
- }1072
- let runs = state.runs.lock().unwrap();1073
- let dead: Vec<Value> = runs1074
- .values()1075
- .filter_map(|run| {1076
- let ending = run.ended.as_ref()?;1077
- Some(json!({1078
- "id": run.id,1079
- "cause": format!("{PROBLEM_BASE}{}", ending.cause),1080
- "detail": ending.detail,1081
- "turn": ending.turn,1082
- }))1083
- })1084
- .collect();1085
- Json(json!({ "runs": dead })).into_response()1086
-}1087
-1088
-async fn leaderboard(1089
- State(state): State<Shared>,1090
- method: Method,1091
- Query(query): Query<HashMap<String, String>>,1092
- headers: HeaderMap,1093
-) -> Response {1094
- if method != Method::GET && method != Method::HEAD {1095
- return unsupported(&method, "GET, HEAD");1096
- }1097
- let truthful = match query.get("sort").map(String::as_str) {1098
- None | Some("self-reported-length") => false,1099
- Some("content-length") => match cookie(&headers, "ruler") {1100
- Some(ruler) if secret::verify("ruler", ruler) == Some("1") => true,1101
- Some(_) => {1102
- return Problem::new(1103
- StatusCode::BAD_REQUEST,1104
- "exaggeration",1105
- "The server has measured. Lying is not a technique.",1106
- )1107
- .with("cookie", "ruler")1108
- .into_response();1109
- }1110
- None => {1111
- return Problem::new(1112
- StatusCode::FORBIDDEN,1113
- "ruler-required",1114
- "Sorting by the truth requires a Ruler.",1115
- )1116
- .into_response();1117
- }1118
- },1119
- Some(_) => {1120
- return Problem::new(1121
- StatusCode::BAD_REQUEST,1122
- "unknown-sort",1123
- "The leaderboard sorts by self-reported-length or content-length.",1124
- )1125
- .into_response();1126
- }1127
- };1128
- let include_seeded = query.get("seeded").is_some_and(|s| s == "include");1129
- let runs = state.runs.lock().unwrap();1130
- let mut entries: Vec<&Run> = runs1131
- .values()1132
- .filter(|run| include_seeded || !run.seeded)1133
- .collect();1134
- let key = |r: &Run| {1135
- if truthful {1136
- r.current_length()1137
- } else {1138
- r.self_reported()1139
- }1140
- };1141
- entries.sort_by(|a, b| key(b).total_cmp(&key(a)));1142
- let entries: Vec<Value> = entries1143
- .into_iter()1144
- .map(|run| {1145
- let mut entry = json!({1146
- "id": run.id,1147
- "status": run.status(),1148
- "floor": run.floor.number(),1149
- "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,1150
- "ranked": !run.seeded,1151
- "viral": run.filmed_at.is_some(),1152
- });1153
- if truthful {1154
- entry["content_length"] = json!(millimetres(run.current_length()));1155
- }1156
- entry1157
- })1158
- .collect();1159
- Json(json!({ "entries": entries })).into_response()1160
-}1161
-1162
-async fn index(method: Method, headers: HeaderMap) -> Response {1163
- if method != Method::GET && method != Method::HEAD {1164
- return unsupported(&method, "GET, HEAD");1165
- }1166
- let wants_html =1167
- header_str(&headers, header::ACCEPT).is_some_and(|accept| accept.contains("text/html"));1168
- if wants_html {1169
- return (1170
- StatusCode::SEE_OTHER,1171
- [(header::LOCATION, "/ui/"), (header::VARY, "Accept")],1172
- )1173
- .into_response();1174
- }1175
- (1176
- [(header::VARY, "Accept")],1177
- Json(json!({1178
- "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",1179
- "start": { "href": "/runs", "method": "POST" },1180
- "leaderboard": "/leaderboard",1181
- "graveyard": "/graveyard",1182
- "forum": FORUM_THREAD,1183
- "ui": "/ui/",1184
- })),1185
- )1186
- .into_response()1187
-}1188
-1189
-async fn ui(method: Method) -> Response {1190
- if method != Method::GET && method != Method::HEAD {1191
- return unsupported(&method, "GET, HEAD");1192
- }1193
- Html(include_str!("ui.html")).into_response()1194
-}1195
-1196
-#[derive(Deserialize)]1197
-struct Envelope {1198
- method: String,1199
- path: String,1200
- #[serde(default)]1201
- headers: Vec<(String, String)>,1202
- #[serde(default)]1203
- body: String,1204
-}1205
-1206
-fn bad_envelope(detail: &str) -> Response {1207
- Problem::new(StatusCode::BAD_REQUEST, "bad-envelope", detail).into_response()1208
-}1209
-1210
-// Browsers hide 3xx responses from fetch and refuse TRACE, Upgrade, and Expect, so the page1211
-// replays requests in-process to show the player exactly what the API said.1212
-async fn ui_send(State(api): State<Router>, headers: HeaderMap, body: Bytes) -> Response {1213
- let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {1214
- return bad_envelope("The envelope could not be parsed.");1215
- };1216
- if !envelope.path.starts_with('/') || envelope.path.starts_with("/ui") {1217
- return bad_envelope("The path must be an API path.");1218
- }1219
- let Ok(method) = Method::from_bytes(envelope.method.as_bytes()) else {1220
- return bad_envelope("That is not a method.");1221
- };1222
- let mut request = Request::builder().method(method).uri(&envelope.path);1223
- for (name, value) in envelope.headers.iter().filter(|(_, v)| !v.is_empty()) {1224
- request = request.header(name, value);1225
- }1226
- for cookie in headers.get_all(header::COOKIE) {1227
- request = request.header(header::COOKIE, cookie);1228
- }1229
- let Ok(request) = request.body(Body::from(envelope.body)) else {1230
- return bad_envelope("The request could not be built. A header may be malformed.");1231
- };1232
- let Ok(response) = api.oneshot(request).await;1233
- let (parts, body) = response.into_parts();1234
- let mut bytes = axum::body::to_bytes(body, 1 << 20)1235
- .await1236
- .unwrap_or_default()1237
- .to_vec();1238
- if parts.headers.get(header::CONTENT_ENCODING) == Some(&HeaderValue::from_static("gzip")) {1239
- let mut decoded = Vec::new();1240
- if GzDecoder::new(bytes.as_slice())1241
- .read_to_end(&mut decoded)1242
- .is_ok()1243
- {1244
- bytes = decoded;1245
- }1246
- }1247
- let listed: Vec<(String, String)> = parts1248
- .headers1249
- .iter()1250
- .map(|(name, value)| {1251
- (1252
- name.to_string(),1253
- value.to_str().unwrap_or("<binary>").to_owned(),1254
- )1255
- })1256
- .collect();1257
- let mut response = Json(json!({1258
- "status": parts.status.as_u16(),1259
- "reason": reason(parts.status),1260
- "headers": listed,1261
- "body": String::from_utf8_lossy(&bytes),1262
- }))1263
- .into_response();1264
- for cookie in parts.headers.get_all(header::SET_COOKIE) {1265
- response1266
- .headers_mut()1267
- .append(header::SET_COOKIE, cookie.clone());1268
- }1269
- response1270
-}1271
-1272
-async fn not_found() -> Problem {1273
- Problem::new(1274
- StatusCode::NOT_FOUND,1275
- "not-found",1276
- "The resource does not exist. This is not about you.",1277
- )1278
-}@@ -0,0 +1,90 @@
1
+use std::{collections::HashMap, time::Instant};2
+3
+use axum::{4
+ body::Bytes,5
+ extract::{FromRequest, FromRequestParts, Query, RawPathParams, Request},6
+ http::{HeaderMap, HeaderName, Method, StatusCode, Uri, Version, header},7
+ response::{IntoResponse, Response},8
+};9
+use hyper::upgrade::OnUpgrade;10
+11
+use crate::problem::Problem;12
+13
+const BODY_LIMIT: usize = 64 * 1024;14
+15
+/// Everything a handler may look at, gathered once per request.16
+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>,26
+}27
+28
+impl Ctx {29
+ pub fn header(&self, name: HeaderName) -> Option<&str> {30
+ self.headers.get(name).and_then(|v| v.to_str().ok())31
+ }32
+33
+ pub fn cookie(&self, name: &str) -> Option<&str> {34
+ self.headers35
+ .get_all(header::COOKIE)36
+ .iter()37
+ .filter_map(|v| v.to_str().ok())38
+ .flat_map(|v| v.split(';'))39
+ .filter_map(|pair| pair.trim().split_once('='))40
+ .find(|(key, _)| *key == name)41
+ .map(|(_, value)| value)42
+ }43
+44
+ pub fn param(&self, name: &str) -> &str {45
+ self.params.get(name).map_or("", String::as_str)46
+ }47
+48
+ pub fn query(&self, name: &str) -> Option<&str> {49
+ self.query.get(name).map(String::as_str)50
+ }51
+}52
+53
+impl<S: Send + Sync> FromRequest<S> for Ctx {54
+ type Rejection = Response;55
+56
+ async fn from_request(mut request: Request, state: &S) -> Result<Self, Self::Rejection> {57
+ let upgrade = request.extensions_mut().remove::<OnUpgrade>();58
+ let (mut parts, body) = request.into_parts();59
+ let params = RawPathParams::from_request_parts(&mut parts, state)60
+ .await61
+ .map(|raw| {62
+ raw.iter()63
+ .map(|(k, v)| (k.to_owned(), v.to_owned()))64
+ .collect()65
+ })66
+ .unwrap_or_default();67
+ let query = Query::<HashMap<String, String>>::try_from_uri(&parts.uri)68
+ .map(|q| q.0)69
+ .unwrap_or_default();70
+ let body = axum::body::to_bytes(body, BODY_LIMIT).await.map_err(|_| {71
+ Problem::new(72
+ StatusCode::PAYLOAD_TOO_LARGE,73
+ "body-too-large",74
+ "The request body is larger than anything the server is prepared to receive.",75
+ )76
+ .into_response()77
+ })?;78
+ Ok(Self {79
+ method: parts.method,80
+ uri: parts.uri,81
+ version: parts.version,82
+ headers: parts.headers,83
+ query,84
+ params,85
+ body,86
+ now: Instant::now(),87
+ upgrade,88
+ })89
+ }90
+}@@ -0,0 +1,219 @@
1
+//! Resources that do not belong to a single run.2
+3
+use axum::{4
+ Json,5
+ http::{StatusCode, header},6
+ response::{IntoResponse, Response},7
+};8
+use rand::Rng;9
+use serde_json::{Value, json};10
+11
+use super::{12
+ Shared,13
+ ctx::Ctx,14
+ resource::{Resource, Verb},15
+ run,16
+};17
+use crate::{18
+ game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread},19
+ headers::X_SEED,20
+ problem::{PROBLEM_BASE, Problem},21
+};22
+23
+pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);24
+pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);25
+pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);26
+pub static GRAVEYARD: Resource<Shared> = Resource::new(&[Verb::new("GET", graveyard)]);27
+pub static LEADERBOARD: Resource<Shared> = Resource::new(&[Verb::new("GET", leaderboard)]);28
+29
+fn index(_: &mut Shared, ctx: &Ctx) -> Response {30
+ let wants_html = ctx31
+ .header(header::ACCEPT)32
+ .is_some_and(|accept| accept.contains("text/html"));33
+ if wants_html {34
+ return (35
+ StatusCode::SEE_OTHER,36
+ [(header::LOCATION, "/ui/"), (header::VARY, "Accept")],37
+ )38
+ .into_response();39
+ }40
+ (41
+ [(header::VARY, "Accept")],42
+ Json(json!({43
+ "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",44
+ "start": { "href": "/runs", "method": "POST" },45
+ "leaderboard": "/leaderboard",46
+ "graveyard": "/graveyard",47
+ "forum": FORUM_THREAD,48
+ "ui": "/ui/",49
+ })),50
+ )51
+ .into_response()52
+}53
+54
+fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {55
+ let seeded = ctx.query("seed").is_some();56
+ let seed = match ctx.query("seed").map(str::parse::<u64>) {57
+ None => rand::rng().random(),58
+ Some(Ok(seed)) => seed,59
+ Some(Err(_)) => {60
+ return Problem::new(61
+ StatusCode::BAD_REQUEST,62
+ "bad-seed",63
+ "The seed must be an unsigned 64-bit integer.",64
+ )65
+ .into_response();66
+ }67
+ };68
+ let config = &state.config;69
+ let mut runs = state.runs();70
+ runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);71
+ if runs.len() >= config.max_runs {72
+ return Problem::new(73
+ StatusCode::SERVICE_UNAVAILABLE,74
+ "server-full",75
+ "The server is full. Everyone is in there.",76
+ )77
+ .header(header::RETRY_AFTER, "600")78
+ .into_response();79
+ }80
+ let id = loop {81
+ let id = format!("{:032x}", rand::rng().random::<u128>());82
+ if !runs.contains_key(&id) {83
+ break id;84
+ }85
+ };86
+ let public_id = format!("{:08x}", rand::rng().random::<u32>());87
+ let mut run = Run::new(id.clone(), public_id, config.secret.mix_seed(seed));88
+ run.seeded = seeded;89
+ run.refractory = config.refractory;90
+ run.last_seen = ctx.now;91
+ let response = (92
+ StatusCode::CREATED,93
+ [94
+ (header::LOCATION, run.path("")),95
+ (header::ETAG, run.etag()),96
+ (X_SEED, seed.to_string()),97
+ ],98
+ Json(run.view()),99
+ )100
+ .into_response();101
+ let response = run::finish(&mut run, ctx, &config.secret, response);102
+ runs.insert(id, run);103
+ response104
+}105
+106
+fn thread(_: &mut Shared, ctx: &Ctx) -> Response {107
+ match ctx.param("year").parse::<u32>() {108
+ Ok(2009) => (109
+ [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],110
+ Json(forum_thread()),111
+ )112
+ .into_response(),113
+ Ok(year) if year > 2009 => Problem::new(114
+ StatusCode::NOT_FOUND,115
+ "no-thread",116
+ "Nobody has posted since 2009.",117
+ )118
+ .with("thread", FORUM_THREAD)119
+ .into_response(),120
+ _ => Problem::new(121
+ StatusCode::NOT_FOUND,122
+ "no-thread",123
+ "The forum did not exist yet. The problem did.",124
+ )125
+ .with("thread", FORUM_THREAD)126
+ .into_response(),127
+ }128
+}129
+130
+fn graveyard(state: &mut Shared, _: &Ctx) -> Response {131
+ let runs = state.runs();132
+ let dead: Vec<Value> = runs133
+ .values()134
+ .filter_map(|run| {135
+ let ending = run.ended.as_ref()?;136
+ Some(json!({137
+ "public_id": run.public_id,138
+ "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),139
+ "detail": ending.cause.detail(),140
+ "turn": ending.turn,141
+ }))142
+ })143
+ .collect();144
+ Json(json!({ "runs": dead })).into_response()145
+}146
+147
+fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {148
+ let truthful = match ctx.query("sort") {149
+ None | Some("self-reported-length") => false,150
+ Some("content-length") => match ctx.cookie("ruler") {151
+ Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => true,152
+ Some(_) => {153
+ return Problem::new(154
+ StatusCode::BAD_REQUEST,155
+ "exaggeration",156
+ "The server has measured. Lying is not a technique.",157
+ )158
+ .with("cookie", "ruler")159
+ .into_response();160
+ }161
+ None => {162
+ return Problem::new(163
+ StatusCode::FORBIDDEN,164
+ "ruler-required",165
+ "Sorting by the truth requires a Ruler.",166
+ )167
+ .into_response();168
+ }169
+ },170
+ Some(_) => {171
+ return Problem::new(172
+ StatusCode::BAD_REQUEST,173
+ "unknown-sort",174
+ "The leaderboard sorts by self-reported-length or content-length.",175
+ )176
+ .into_response();177
+ }178
+ };179
+ let include_seeded = ctx.query("seeded") == Some("include");180
+ let runs = state.runs();181
+ let mut entries: Vec<&Run> = runs182
+ .values()183
+ .filter(|run| include_seeded || !run.seeded)184
+ .collect();185
+ let key = |run: &Run| {186
+ if truthful {187
+ run.current_length()188
+ } else {189
+ run.self_reported()190
+ }191
+ };192
+ entries.sort_by(|a, b| key(b).total_cmp(&key(a)));193
+ let entries: Vec<Value> = entries194
+ .into_iter()195
+ .map(|run| {196
+ let mut entry = json!({197
+ "public_id": run.public_id,198
+ "status": run.status(),199
+ "floor": run.floor.number(),200
+ "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,201
+ "ranked": !run.seeded,202
+ "viral": run.filmed_at.is_some(),203
+ });204
+ if truthful {205
+ entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());206
+ }207
+ entry208
+ })209
+ .collect();210
+ Json(json!({ "entries": entries })).into_response()211
+}212
+213
+pub async fn not_found() -> Problem {214
+ Problem::new(215
+ StatusCode::NOT_FOUND,216
+ "not-found",217
+ "The resource does not exist. This is not about you.",218
+ )219
+}@@ -0,0 +1,97 @@
1
+use std::io::Write as _;2
+3
+use axum::{4
+ body::Body,5
+ extract::Request,6
+ http::{HeaderValue, StatusCode, Version, header},7
+ middleware::Next,8
+ response::{IntoResponse, Response},9
+};10
+use flate2::{Compression, write::GzEncoder};11
+12
+use crate::{headers::X_POWERED_BY, problem::Problem};13
+14
+const MAX_URI: usize = 512;15
+const MAX_HEADER: usize = 1024;16
+17
+/// Marks a response for gzip, which the cock ring asks for.18
+#[derive(Clone)]19
+pub struct Compress;20
+21
+fn reject(request: &Request) -> Option<Problem> {22
+ if request.version() < Version::HTTP_11 {23
+ return Some(Problem::new(24
+ StatusCode::HTTP_VERSION_NOT_SUPPORTED,25
+ "pdf",26
+ "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.",27
+ ));28
+ }29
+ if request.uri().to_string().len() > MAX_URI {30
+ return Some(Problem::new(31
+ StatusCode::URI_TOO_LONG,32
+ "uri-too-long",33
+ "The URI is longer than anything else here.",34
+ ));35
+ }36
+ if request.headers().values().any(|v| v.len() > MAX_HEADER) {37
+ return Some(Problem::new(38
+ StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,39
+ "head-too-large",40
+ "Head too large.",41
+ ));42
+ }43
+ let expectation = request.headers().get(header::EXPECT)?;44
+ if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") {45
+ return None;46
+ }47
+ Some(Problem::new(48
+ StatusCode::EXPECTATION_FAILED,49
+ "expectations",50
+ format!(51
+ "Expect: {} cannot be met.",52
+ expectation.to_str().unwrap_or("that")53
+ ),54
+ ))55
+}56
+57
+async fn compress(response: Response) -> Response {58
+ let (mut parts, body) = response.into_parts();59
+ let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {60
+ return Response::from_parts(parts, Body::empty());61
+ };62
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());63
+ let gzipped = encoder.write_all(&bytes).and_then(|()| encoder.finish());64
+ let Ok(gzipped) = gzipped else {65
+ return Response::from_parts(parts, Body::from(bytes));66
+ };67
+ parts68
+ .headers69
+ .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));70
+ parts.headers.remove(header::CONTENT_LENGTH);71
+ parts72
+ .headers73
+ .append(header::VARY, HeaderValue::from_static("Accept-Encoding"));74
+ Response::from_parts(parts, Body::from(gzipped))75
+}76
+77
+pub async fn chrome(request: Request, next: Next) -> Response {78
+ let gzip = request79
+ .headers()80
+ .get(header::ACCEPT_ENCODING)81
+ .and_then(|v| v.to_str().ok())82
+ .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));83
+ let mut response = match reject(&request) {84
+ Some(problem) => problem.into_response(),85
+ None => next.run(request).await,86
+ };87
+ if gzip && response.extensions().get::<Compress>().is_some() {88
+ response = compress(response).await;89
+ }90
+ let headers = response.headers_mut();91
+ headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));92
+ headers.insert(X_POWERED_BY, HeaderValue::from_static("blood"));93
+ headers94
+ .entry(header::CACHE_CONTROL)95
+ .or_insert(HeaderValue::from_static("no-store, private"));96
+ response97
+}@@ -0,0 +1,113 @@
1
+mod ctx;2
+mod meta;3
+mod middleware;4
+mod resource;5
+mod response;6
+mod run;7
+mod runs;8
+mod ui;9
+10
+use std::{11
+ collections::HashMap,12
+ sync::{Arc, Mutex, MutexGuard, PoisonError},13
+};14
+15
+use axum::{16
+ Router,17
+ extract::State,18
+ response::Redirect,19
+ routing::{MethodRouter, any},20
+};21
+22
+use crate::{config::Config, game::Run};23
+use ctx::Ctx;24
+use resource::Resource;25
+use run::RunResource;26
+27
+pub struct AppState {28
+ runs: Mutex<HashMap<String, Run>>,29
+ config: Config,30
+}31
+32
+impl AppState {33
+ pub fn new(config: Config) -> Self {34
+ Self {35
+ runs: Mutex::default(),36
+ config,37
+ }38
+ }39
+40
+ /// A panic mid-request must not take every other run down with it, so a poisoned lock is recovered.41
+ fn runs(&self) -> MutexGuard<'_, HashMap<String, Run>> {42
+ self.runs.lock().unwrap_or_else(PoisonError::into_inner)43
+ }44
+45
+ #[cfg(test)]46
+ pub(crate) fn insert(&self, run: Run) {47
+ self.runs().insert(run.id.clone(), run);48
+ }49
+}50
+51
+impl Default for AppState {52
+ fn default() -> Self {53
+ Self::new(Config::default())54
+ }55
+}56
+57
+type Shared = Arc<AppState>;58
+59
+pub fn app(config: Config) -> Router {60
+ router(Arc::new(AppState::new(config)))61
+}62
+63
+pub fn router(state: Shared) -> Router {64
+ let api = api(state);65
+ let ui = Router::new()66
+ .route("/ui", any(|| async { Redirect::permanent("/ui/") }))67
+ .route("/ui/", any(ui::page))68
+ .route("/ui/send", any(ui::send))69
+ .with_state(api.clone());70
+ api.merge(ui)71
+}72
+73
+fn global(resource: &'static Resource<Shared>) -> MethodRouter<Shared> {74
+ any(75
+ move |State(mut state): State<Shared>, ctx: Ctx| async move {76
+ resource.dispatch(&mut state, &ctx)77
+ },78
+ )79
+}80
+81
+fn scoped(resource: &'static RunResource) -> MethodRouter<Shared> {82
+ any(83
+ move |State(state): State<Shared>, ctx: Ctx| async move { run::serve(&state, ctx, resource) },84
+ )85
+}86
+87
+fn api(state: Shared) -> Router {88
+ Router::new()89
+ .route("/", global(&meta::INDEX))90
+ .route("/runs", global(&meta::RUNS))91
+ .route("/runs/{id}", scoped(&runs::RUN))92
+ .route("/runs/{id}/length", scoped(&runs::LENGTH))93
+ .route("/runs/{id}/foreskin", scoped(&runs::FORESKIN))94
+ .route("/runs/{id}/inventory", scoped(&runs::INVENTORY))95
+ .route("/runs/{id}/inventory/{item}", scoped(&runs::ITEM))96
+ .route("/runs/{id}/techniques", scoped(&runs::TECHNIQUES))97
+ .route("/runs/{id}/drawer", scoped(&runs::DRAWER))98
+ .route("/runs/{id}/branches/{choice}", scoped(&runs::BRANCH))99
+ .route("/runs/{id}/floor", scoped(&runs::FLOOR))100
+ .route("/runs/{id}/shop", scoped(&runs::SHOP))101
+ .route("/runs/{id}/shop/{item}", scoped(&runs::BUY))102
+ .route("/runs/{id}/portal", scoped(&runs::PORTAL))103
+ .route("/runs/{id}/elder", scoped(&runs::ELDER))104
+ .route("/runs/{id}/grip", scoped(&runs::GRIP))105
+ .route("/runs/{id}/diagnosis", scoped(&runs::DIAGNOSIS))106
+ .route("/runs/{id}/footage", scoped(&runs::FOOTAGE))107
+ .route("/forum/threads/{year}", global(&meta::THREAD))108
+ .route("/graveyard", global(&meta::GRAVEYARD))109
+ .route("/leaderboard", global(&meta::LEADERBOARD))110
+ .fallback(meta::not_found)111
+ .layer(axum::middleware::from_fn(middleware::chrome))112
+ .with_state(state)113
+}@@ -0,0 +1,141 @@
1
+use axum::{2
+ Json,3
+ http::{HeaderValue, Method, StatusCode, header},4
+ response::{IntoResponse, Response},5
+};6
+use serde_json::json;7
+8
+use super::ctx::Ctx;9
+use crate::{headers::ACCEPT_PATCH, problem::Problem};10
+11
+const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";12
+13
+pub type Handler<T> = fn(&mut T, &Ctx) -> Response;14
+15
+/// A predicate for when a method is available, and the refusal when it is not.16
+type Guard<T> = (fn(&T) -> bool, &'static str);17
+18
+pub struct Verb<T: 'static> {19
+ method: &'static str,20
+ handle: Handler<T>,21
+ guard: Option<Guard<T>>,22
+}23
+24
+impl<T> Verb<T> {25
+ pub const fn new(method: &'static str, handle: Handler<T>) -> Self {26
+ Self {27
+ method,28
+ handle,29
+ guard: None,30
+ }31
+ }32
+33
+ /// Makes the method available only while `available` holds, refusing with `refusal` otherwise.34
+ pub const fn only_when(self, available: fn(&T) -> bool, refusal: &'static str) -> Self {35
+ Self {36
+ guard: Some((available, refusal)),37
+ ..self38
+ }39
+ }40
+41
+ fn available(&self, target: &T) -> bool {42
+ self.guard.is_none_or(|(available, _)| available(target))43
+ }44
+}45
+46
+/// The methods a resource supports. `Allow`, `OPTIONS` and `405` are all derived from this list.47
+pub struct Resource<T: 'static> {48
+ pub verbs: &'static [Verb<T>],49
+ pub accept_patch: bool,50
+}51
+52
+impl<T> Resource<T> {53
+ pub const fn new(verbs: &'static [Verb<T>]) -> Self {54
+ Self {55
+ verbs,56
+ accept_patch: false,57
+ }58
+ }59
+60
+ pub fn allow(&self, target: &T) -> String {61
+ let mut methods = Vec::new();62
+ for verb in self.verbs.iter().filter(|verb| verb.available(target)) {63
+ methods.push(verb.method);64
+ if verb.method == "GET" {65
+ methods.push("HEAD");66
+ }67
+ }68
+ methods.push("OPTIONS");69
+ methods.join(", ")70
+ }71
+72
+ pub fn dispatch(&self, target: &mut T, ctx: &Ctx) -> Response {73
+ let requested = ctx.method.as_str();74
+ let lookup = if requested == "HEAD" {75
+ "GET"76
+ } else {77
+ requested78
+ };79
+ match self.verbs.iter().find(|verb| verb.method == lookup) {80
+ Some(verb) if verb.available(target) => (verb.handle)(target, ctx),81
+ Some(verb) => {82
+ let refusal = verb.guard.map_or("", |(_, refusal)| refusal);83
+ method_not_allowed(refusal.to_owned(), &self.allow(target))84
+ }85
+ None if requested == "OPTIONS" => options(&self.allow(target), self.accept_patch),86
+ None => unsupported(&ctx.method, &self.allow(target)),87
+ }88
+ }89
+}90
+91
+fn method_not_allowed(detail: String, allow: &str) -> Response {92
+ Problem::new(StatusCode::METHOD_NOT_ALLOWED, "method-not-allowed", detail)93
+ .header(header::ALLOW, allow)94
+ .into_response()95
+}96
+97
+pub fn unsupported(method: &Method, allow: &str) -> Response {98
+ let name = method.as_str();99
+ match name {100
+ "CONNECT" => StatusCode::NOT_IMPLEMENTED.into_response(),101
+ "BREW" => Problem::new(102
+ StatusCode::IM_A_TEAPOT,103
+ "brew",104
+ "The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.",105
+ )106
+ .into_response(),107
+ "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ" => {108
+ method_not_allowed(format!("{method} is not supported here."), allow)109
+ }110
+ _ if name.eq_ignore_ascii_case("jelq") => Problem::new(111
+ StatusCode::NOT_IMPLEMENTED,112
+ "not-implemented",113
+ format!(114
+ "The server does not implement {method}. Methods are case-sensitive. JELQ is shouted for a reason."115
+ ),116
+ )117
+ .into_response(),118
+ _ => Problem::new(119
+ StatusCode::NOT_IMPLEMENTED,120
+ "not-implemented",121
+ format!("The server does not implement {method}."),122
+ )123
+ .into_response(),124
+ }125
+}126
+127
+fn options(allow: &str, accept_patch: bool) -> Response {128
+ let mut response = (129
+ StatusCode::OK,130
+ [(header::ALLOW, allow)],131
+ Json(json!({ "detail": THERAPY })),132
+ )133
+ .into_response();134
+ if accept_patch {135
+ response.headers_mut().insert(136
+ ACCEPT_PATCH,137
+ HeaderValue::from_static("application/json-patch+json"),138
+ );139
+ }140
+ response141
+}@@ -0,0 +1,48 @@
1
+use axum::{2
+ Json,3
+ http::{HeaderValue, StatusCode, header},4
+ response::{IntoResponse, Response},5
+};6
+7
+use crate::game::{Outcome, Payload};8
+9
+impl IntoResponse for Outcome {10
+ fn into_response(self) -> Response {11
+ let mut response = match self.payload {12
+ // 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
+ let full = body.to_string().into_bytes();15
+ let half = full.len() / 2;16
+ let range = format!("bytes 0-{}/{}", half.saturating_sub(1), full.len());17
+ (18
+ self.status,19
+ [20
+ (header::CONTENT_TYPE, "application/json".to_owned()),21
+ (header::CONTENT_RANGE, range),22
+ ],23
+ full[..half].to_vec(),24
+ )25
+ .into_response()26
+ }27
+ Payload::Json(body) => (self.status, Json(body)).into_response(),28
+ Payload::Raw(content_type, body) => {29
+ (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()30
+ }31
+ Payload::Empty => self.status.into_response(),32
+ };33
+ let headers = response.headers_mut();34
+ for (name, value) in self.headers {35
+ if let Ok(value) = HeaderValue::from_str(&value) {36
+ headers.append(name, value);37
+ }38
+ }39
+ response40
+ }41
+}42
+43
+pub fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response {44
+ match result {45
+ Ok(ok) => ok.into_response(),46
+ Err(err) => err.into_response(),47
+ }48
+}@@ -0,0 +1,205 @@
1
+//! Plumbing shared by every resource under `/runs/{id}`.2
+3
+use axum::{4
+ http::{HeaderName, HeaderValue, StatusCode, header},5
+ response::{IntoResponse, Response},6
+};7
+use hyper_util::rt::TokioIo;8
+use tokio::io::AsyncWriteExt;9
+10
+use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource};11
+use crate::{12
+ game::{Floor, Run, balance::SUSPICION_LIMIT},13
+ headers::{14
+ X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_OBSERVED_BY, X_SELF_REPORTED_LENGTH, X_SHAME,15
+ X_STAMINA, X_SUSPICION,16
+ },17
+ problem::{PROBLEM_BASE, Problem},18
+ secret::Secret,19
+};20
+21
+pub const REL_BASE: &str = "https://jelq.dev/rels/";22
+const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";23
+const LINKED: [&str; 8] = [24
+ "floor",25
+ "length",26
+ "foreskin",27
+ "inventory",28
+ "techniques",29
+ "drawer",30
+ "grip",31
+ "diagnosis",32
+];33
+34
+/// Which household hazards stand between a request and the run.35
+#[derive(Clone, Copy, PartialEq, Eq)]36
+pub enum Scope {37
+ Home,38
+ Pockets,39
+}40
+41
+pub struct RunResource {42
+ pub scope: Scope,43
+ pub resource: Resource<Run>,44
+}45
+46
+/// What the server writes over the connection after a `101`.47
+#[derive(Clone)]48
+pub struct Greeting(pub String);49
+50
+pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {51
+ let mut runs = state.runs();52
+ let Some(run) = runs.get_mut(ctx.param("id")) else {53
+ return Problem::new(54
+ StatusCode::NOT_FOUND,55
+ "no-such-run",56
+ "There is no such run. There may never have been.",57
+ )58
+ .into_response();59
+ };60
+ if let Some(ending) = &run.ended {61
+ return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")62
+ .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug()))63
+ .with("turn", ending.turn)64
+ .with("graveyard", "/graveyard")65
+ .into_response();66
+ }67
+ run.last_seen = ctx.now;68
+ run.observed = None;69
+ let response =70
+ gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));71
+ let response = finish(run, &ctx, &state.config.secret, response);72
+ drop(runs);73
+ greet(response, &mut ctx)74
+}75
+76
+fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {77
+ if scope == Scope::Pockets {78
+ return None;79
+ }80
+ if run.floor == Floor::Hotel && !run.here.wifi_accepted {81
+ let portal = run.path("/portal");82
+ return Some(83
+ Problem::new(84
+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED,85
+ "captive-portal",86
+ "The network requires authentication. The hotel would like you to agree to some terms.",87
+ )88
+ .with("portal", portal.as_str())89
+ .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\""))90
+ .into_response(),91
+ );92
+ }93
+ run.answer_door(ctx.header(header::AUTHORIZATION))94
+ .err()95
+ .map(IntoResponse::into_response)96
+}97
+98
+fn links(run: &Run) -> String {99
+ let mut links = vec![format!("<{}>; rel=\"{REL_BASE}run\"", run.path(""))];100
+ let shop = run.floor.shop_open().then_some("shop");101
+ for rel in LINKED.into_iter().chain(shop) {102
+ links.push(format!(103
+ "<{}>; rel=\"{REL_BASE}{rel}\"",104
+ run.path(&format!("/{rel}"))105
+ ));106
+ }107
+ links.join(", ")108
+}109
+110
+/// Shame lives in a signed cookie so it follows the player across runs. Each response adds this111
+/// run's new shame to whatever total the client presents, so two runs at once both count.112
+fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 {113
+ let presented = ctx114
+ .cookie("shame")115
+ .and_then(|signed| secret.verify("shame", signed))116
+ .and_then(|total| total.parse::<u32>().ok());117
+ let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported);118
+ run.shame_reported = run.shame;119
+ run.shame_total = total;120
+ total121
+}122
+123
+pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {124
+ let status = response.status();125
+ if status == StatusCode::NOT_MODIFIED {126
+ return response;127
+ }128
+ let shame = settle_shame(run, ctx, secret);129
+ let mut stats: Vec<(HeaderName, String)> = vec![130
+ (131
+ X_SELF_REPORTED_LENGTH,132
+ format!("{:.2}", run.self_reported()),133
+ ),134
+ (X_GIRTH, format!("{:.2}", run.girth)),135
+ (136
+ X_FORESKIN,137
+ run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),138
+ ),139
+ (X_EQ, run.eq.to_string()),140
+ (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),141
+ (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),142
+ (X_SHAME, shame.to_string()),143
+ (X_CASH, run.cash.to_string()),144
+ (X_FLOOR, run.floor.number().to_string()),145
+ (header::LINK, links(run)),146
+ ];147
+ if let Some(observer) = run.observed {148
+ stats.push((X_OBSERVED_BY, observer.name().into()));149
+ }150
+ if run.mom.is_some() {151
+ stats.push((header::VIA, "1.1 mom".into()));152
+ stats.push((header::VARY, "Mom".into()));153
+ }154
+ // RFC 9111 obsoleted Warning, which is why nobody heeds it.155
+ if run.conditions.pulled_muscle {156
+ stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));157
+ }158
+ if run.eq > 70 && run.ended.is_none() {159
+ stats.push((header::WARNING, "199 - \"it is turning purple\"".into()));160
+ }161
+ stats.push((162
+ header::SET_COOKIE,163
+ format!(164
+ "shame={}; {COOKIE_ATTRIBUTES}",165
+ secret.sign("shame", &shame.to_string())166
+ ),167
+ ));168
+ if run.items.ruler {169
+ stats.push((170
+ header::SET_COOKIE,171
+ format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")),172
+ ));173
+ }174
+ let headers = response.headers_mut();175
+ for (name, value) in stats {176
+ if let Ok(value) = HeaderValue::from_str(&value) {177
+ headers.append(name, value);178
+ }179
+ }180
+ let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;181
+ if run.items.ring && !bodiless {182
+ response.extensions_mut().insert(Compress);183
+ }184
+ response185
+}186
+187
+fn greet(response: Response, ctx: &mut Ctx) -> Response {188
+ if response.status() != StatusCode::SWITCHING_PROTOCOLS {189
+ return response;190
+ }191
+ let (Some(Greeting(text)), Some(upgrade)) = (192
+ response.extensions().get::<Greeting>().cloned(),193
+ ctx.upgrade.take(),194
+ ) else {195
+ return response;196
+ };197
+ tokio::spawn(async move {198
+ if let Ok(upgraded) = upgrade.await {199
+ let mut io = TokioIo::new(upgraded);200
+ let _ = io.write_all(text.as_bytes()).await;201
+ let _ = io.shutdown().await;202
+ }203
+ });204
+ response205
+}@@ -0,0 +1,488 @@
1
+//! The resources under `/runs/{id}`.2
+3
+use std::{convert::Infallible, fmt::Write as _};4
+5
+use axum::{6
+ Json,7
+ body::{Body, Bytes},8
+ http::{StatusCode, header},9
+ response::{IntoResponse, Response},10
+};11
+use serde_json::{Value, json};12
+13
+use super::{14
+ ctx::Ctx,15
+ resource::{Resource, Verb},16
+ response::respond,17
+ run::{Greeting, RunResource, Scope},18
+};19
+use crate::{20
+ game::{21
+ Cause, Floor, Grip, Item, LUBRICANTS, Lube, PatchOp, Run, Technique,22
+ balance::{EQ_BAND, MM_PER_INCH},23
+ shaft,24
+ },25
+ headers::ACCEPT_PATCH,26
+ problem::Problem,27
+};28
+29
+const fn home(verbs: &'static [Verb<Run>]) -> RunResource {30
+ RunResource {31
+ scope: Scope::Home,32
+ resource: Resource::new(verbs),33
+ }34
+}35
+36
+const fn pockets(verbs: &'static [Verb<Run>]) -> RunResource {37
+ RunResource {38
+ scope: Scope::Pockets,39
+ resource: Resource::new(verbs),40
+ }41
+}42
+43
+fn not_in_clinic(run: &Run) -> bool {44
+ run.floor != Floor::Clinic45
+}46
+47
+fn never(_: &Run) -> bool {48
+ false49
+}50
+51
+fn unreachable_handler(_: &mut Run, _: &Ctx) -> Response {52
+ StatusCode::METHOD_NOT_ALLOWED.into_response()53
+}54
+55
+pub static RUN: RunResource = RunResource {56
+ scope: Scope::Home,57
+ resource: Resource {58
+ verbs: &[59
+ Verb::new("GET", get_run),60
+ Verb::new("JELQ", jelq)61
+ .only_when(not_in_clinic, "JELQ is not performed in the clinic."),62
+ Verb::new("PUT", put_run),63
+ Verb::new("PATCH", patch_run),64
+ Verb::new("DELETE", delete_run),65
+ Verb::new("TRACE", trace_run),66
+ ],67
+ accept_patch: true,68
+ },69
+};70
+pub static LENGTH: RunResource = home(&[Verb::new("GET", get_length)]);71
+pub static FORESKIN: RunResource = home(&[72
+ Verb::new("GET", get_foreskin),73
+ Verb::new("DELETE", delete_foreskin),74
+]);75
+pub static INVENTORY: RunResource = pockets(&[Verb::new("GET", get_inventory)]);76
+pub static ITEM: RunResource =77
+ pockets(&[Verb::new("GET", get_item), Verb::new("DELETE", delete_item)]);78
+pub static TECHNIQUES: RunResource = home(&[Verb::new("GET", get_techniques)]);79
+pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);80
+pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);81
+pub static FLOOR: RunResource =82
+ pockets(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);83
+pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);84
+pub static BUY: RunResource = home(&[Verb::new("POST", buy)]);85
+pub static PORTAL: RunResource = pockets(&[86
+ Verb::new("GET", get_portal),87
+ Verb::new("POST", accept_portal),88
+]);89
+pub static ELDER: RunResource = home(&[Verb::new("GET", get_elder)]);90
+pub static GRIP: RunResource = home(&[Verb::new("GET", get_grip)]);91
+pub static DIAGNOSIS: RunResource = pockets(&[Verb::new("GET", get_diagnosis)]);92
+pub static FOOTAGE: RunResource = pockets(&[93
+ Verb::new("GET", get_footage),94
+ Verb::new("DELETE", unreachable_handler).only_when(95
+ never,96
+ "The footage has been cached by intermediaries. It cannot be deleted.",97
+ ),98
+]);99
+100
+fn get_run(run: &mut Run, ctx: &Ctx) -> Response {101
+ if ctx102
+ .header(header::ACCEPT)103
+ .is_some_and(|accept| accept.contains("reassurance"))104
+ {105
+ return Problem::new(106
+ StatusCode::NOT_ACCEPTABLE,107
+ "no-reassurance",108
+ "The server does not produce reassurance.",109
+ )110
+ .into_response();111
+ }112
+ (113
+ StatusCode::OK,114
+ [(header::ETAG, run.etag())],115
+ Json(run.view()),116
+ )117
+ .into_response()118
+}119
+120
+fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {121
+ let required = || {122
+ Problem::new(123
+ StatusCode::BAD_REQUEST,124
+ "technique-required",125
+ "No technique was specified.",126
+ )127
+ .with("techniques", run.path("/techniques"))128
+ };129
+ let names = ctx130
+ .query("technique")131
+ .filter(|n| !n.trim().is_empty())132
+ .ok_or_else(required)?;133
+ names134
+ .split(',')135
+ .map(str::trim)136
+ .filter(|n| !n.is_empty())137
+ .map(|name| {138
+ Technique::parse(name).ok_or_else(|| {139
+ Problem::new(140
+ StatusCode::BAD_REQUEST,141
+ "unknown-technique",142
+ format!("{name} is not a technique."),143
+ )144
+ .with("techniques", run.path("/techniques"))145
+ })146
+ })147
+ .collect()148
+}149
+150
+fn parse_intensity(ctx: &Ctx) -> Result<i32, Problem> {151
+ match ctx.query("intensity").map(str::parse::<i32>) {152
+ None => Ok(5),153
+ Some(Ok(i)) if (1..=10).contains(&i) => Ok(i),154
+ Some(_) => Err(Problem::new(155
+ StatusCode::BAD_REQUEST,156
+ "intensity-out-of-range",157
+ "Intensity is measured from 1 to 10.",158
+ )),159
+ }160
+}161
+162
+fn jelq(run: &mut Run, ctx: &Ctx) -> Response {163
+ let parsed = parse_techniques(run, ctx).and_then(|techniques| {164
+ let intensity = parse_intensity(ctx)?;165
+ let lube = Lube::from_content_type(ctx.header(header::CONTENT_TYPE))?;166
+ Ok((techniques, intensity, lube))167
+ });168
+ match parsed {169
+ Ok((techniques, intensity, lube)) => {170
+ respond(run.jelq(&techniques, intensity, lube, ctx.now))171
+ }172
+ Err(problem) => problem.into_response(),173
+ }174
+}175
+176
+fn put_run(run: &mut Run, _: &Ctx) -> Response {177
+ run.put().into_response()178
+}179
+180
+fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {181
+ let is_patch = ctx182
+ .header(header::CONTENT_TYPE)183
+ .is_some_and(|ct| ct.starts_with("application/json-patch+json"));184
+ if !is_patch {185
+ return Problem::new(186
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,187
+ "patch-format",188
+ "Patches are accepted as application/json-patch+json.",189
+ )190
+ .header(ACCEPT_PATCH, "application/json-patch+json")191
+ .into_response();192
+ }193
+ match serde_json::from_slice::<Vec<PatchOp>>(&ctx.body) {194
+ Ok(ops) => respond(run.patch(&ops)),195
+ Err(_) => Problem::new(196
+ StatusCode::BAD_REQUEST,197
+ "malformed-patch",198
+ "The patch could not be parsed.",199
+ )200
+ .into_response(),201
+ }202
+}203
+204
+fn delete_run(run: &mut Run, _: &Ctx) -> Response {205
+ run.end(Cause::Abandoned);206
+ StatusCode::NO_CONTENT.into_response()207
+}208
+209
+fn trace_run(run: &mut Run, ctx: &Ctx) -> Response {210
+ run.shame += 1;211
+ let mut message = format!("{} {} {:?}\r\n", ctx.method, ctx.uri, ctx.version);212
+ for (name, value) in &ctx.headers {213
+ let _ = write!(214
+ message,215
+ "{name}: {}\r\n",216
+ value.to_str().unwrap_or("<binary>")217
+ );218
+ }219
+ message.push_str("\r\n");220
+ (221
+ StatusCode::OK,222
+ [(header::CONTENT_TYPE, "message/http")],223
+ message,224
+ )225
+ .into_response()226
+}227
+228
+fn millimetres(inches: f64) -> usize {229
+ (inches * MM_PER_INCH).round() as usize230
+}231
+232
+fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {233
+ let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;234
+ let start: f64 = start.trim().parse().ok()?;235
+ let end: f64 = match end.trim() {236
+ "" => total,237
+ end => end.parse().ok()?,238
+ };239
+ (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))240
+}241
+242
+fn get_length(run: &mut Run, ctx: &Ctx) -> Response {243
+ let inches = if run.items.ruler {244
+ run.current_length()245
+ } else {246
+ run.self_reported()247
+ };248
+ let body = shaft(millimetres(inches));249
+ let content_type = (250
+ header::CONTENT_TYPE,251
+ "text/plain; charset=us-ascii".to_owned(),252
+ );253
+ let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());254
+255
+ if let Some(range) = ctx.header(header::RANGE)256
+ && range.starts_with("inches=")257
+ {258
+ let Some((start, end)) = parse_inch_range(range, inches) else {259
+ return Problem::new(260
+ StatusCode::RANGE_NOT_SATISFIABLE,261
+ "range-not-satisfiable",262
+ format!("The requested range is not available. {inches:.2} inches are."),263
+ )264
+ .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))265
+ .into_response();266
+ };267
+ let from = millimetres(start).min(body.len() - 1);268
+ let to = millimetres(end).clamp(from + 1, body.len());269
+ let content_range = (270
+ header::CONTENT_RANGE,271
+ format!("inches {start:.2}-{end:.2}/{inches:.2}"),272
+ );273
+ return (274
+ StatusCode::PARTIAL_CONTENT,275
+ [content_type, accept_ranges, content_range],276
+ body[from..to].to_owned(),277
+ )278
+ .into_response();279
+ }280
+281
+ if run.items.ruler {282
+ return (StatusCode::OK, [content_type, accept_ranges], body).into_response();283
+ }284
+ let chunks: Vec<Result<Bytes, Infallible>> = body285
+ .into_bytes()286
+ .chunks(16)287
+ .map(|c| Ok(Bytes::copy_from_slice(c)))288
+ .collect();289
+ let body = Body::from_stream(futures_util::stream::iter(chunks));290
+ (StatusCode::OK, [content_type, accept_ranges], body).into_response()291
+}292
+293
+fn get_foreskin(run: &mut Run, _: &Ctx) -> Response {294
+ if run.foreskin.is_none() {295
+ return Problem::new(296
+ StatusCode::GONE,297
+ "foreskin-gone",298
+ "The foreskin is gone. It will not be returning.",299
+ )300
+ .into_response();301
+ }302
+ (StatusCode::NO_CONTENT, [(header::ETAG, run.etag())]).into_response()303
+}304
+305
+fn delete_foreskin(run: &mut Run, ctx: &Ctx) -> Response {306
+ respond(run.delete_foreskin(ctx.header(header::IF_MATCH)))307
+}308
+309
+fn get_inventory(run: &mut Run, _: &Ctx) -> Response {310
+ let items: Vec<Value> = run311
+ .items312
+ .owned()313
+ .into_iter()314
+ .map(|item| {315
+ json!({316
+ "name": item.slug(),317
+ "href": run.path(&format!("/inventory/{}", item.slug())),318
+ })319
+ })320
+ .collect();321
+ Json(json!({322
+ "items": items,323
+ "ice_packs": run.items.ice_packs,324
+ "supplements": run.items.supplements,325
+ "excuses": run.excuses(),326
+ }))327
+ .into_response()328
+}329
+330
+fn owned_item(run: &Run, ctx: &Ctx) -> Result<Item, Problem> {331
+ Item::parse(ctx.param("item"))332
+ .filter(|&item| run.items.has(item))333
+ .ok_or_else(|| {334
+ Problem::new(335
+ StatusCode::NOT_FOUND,336
+ "no-such-item",337
+ "You do not have that.",338
+ )339
+ })340
+}341
+342
+fn get_item(run: &mut Run, ctx: &Ctx) -> Response {343
+ match owned_item(run, ctx) {344
+ Ok(item) => {345
+ Json(json!({ "name": item.slug(), "description": item.description() })).into_response()346
+ }347
+ Err(problem) => problem.into_response(),348
+ }349
+}350
+351
+fn delete_item(run: &mut Run, ctx: &Ctx) -> Response {352
+ match owned_item(run, ctx) {353
+ Ok(item) => respond(run.drop_item(item)),354
+ Err(problem) => problem.into_response(),355
+ }356
+}357
+358
+fn get_techniques(run: &mut Run, _: &Ctx) -> Response {359
+ let listed: Vec<Value> = Technique::ALL360
+ .into_iter()361
+ .map(|t| t.describe(run.forum_read()))362
+ .collect();363
+ Json(json!({364
+ "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),365
+ "lubricant": "Declared with Content-Type.",366
+ "lubricants": LUBRICANTS,367
+ "eq_band": [EQ_BAND.start(), EQ_BAND.end()],368
+ "techniques": listed,369
+ }))370
+ .into_response()371
+}372
+373
+fn search_drawer(run: &mut Run, _: &Ctx) -> Response {374
+ respond(run.search_drawer())375
+}376
+377
+fn choose_branch(run: &mut Run, ctx: &Ctx) -> Response {378
+ respond(run.choose(ctx.param("choice")))379
+}380
+381
+fn get_show full diff (262144 bytes truncated)