atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 782069860236

tarball

atagen <boss@atagen.co>2026-10-10 15:15 UTC

38 files changed+4874-4108
jelqd: restructure into typed modules and close the review's correctness gaps

Run ids were the only authorization yet the leaderboard and graveyard published them, so anyone could play or delete a listed run. Runs now have a 128-bit private id in their URL and a separate public id for listings, idle runs are evicted, and a poisoned lock no longer takes the server down. A per-resource method table derives Allow, OPTIONS and 405, which fixes the clinic advertising JELQ, and items are an enum so a shop purchase can no longer fall through to removing the cage. Bodies no longer repeat what the stat headers already say.
Modified file: flake.nix+1 additions−1 deletions
@@ -22,7 +22,7 @@
2222 ./Cargo.toml
2323 ./Cargo.lock
2424 ./src
25- ./tests
25+
2626 ];
2727 };
2828 in
Added file: src/config.rs+38 additions
@@ -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+ ..defaults
36+ }
37+ }
38+}
Added file: src/game/balance.rs+68 additions
@@ -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;
Added file: src/game/body.rs+121 additions
@@ -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+}
Deleted file: src/game/catalog.rs−253 deletions
@@ -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 = p
179- .foreskin
180- .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-];
Added file: src/game/clinic.rs+73 additions
@@ -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+}
Deleted file: src/game/encounters.rs−773 deletions
@@ -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,no
33-2007-06-11,7.50,5.50,wet,feeling fuller,no
34-2008-01-02,7.50,5.50,dry,new routine,no
35-2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
36-2009-06-14,7.50,5.50,wet,progress is slow but real,no
37-";
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- EXCUSES
94- .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 = authorization
121- .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> = SHOP
369- .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_since
686- .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-}
Added file: src/game/floors.rs+193 additions
@@ -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.turns
52+ ),
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+ view
103+ }
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+}
Added file: src/game/forum.rs+184 additions
@@ -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,no
18+2007-06-11,7.50,5.50,wet,feeling fuller,no
19+2008-01-02,7.50,5.50,dry,new routine,no
20+2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
21+2009-06-14,7.50,5.50,wet,progress is slow but real,no
22+";
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_since
167+ .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+}
Added file: src/game/hazards.rs+148 additions
@@ -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_BAND
33+ } else if self.eq <= HOT_EQ {
34+ RUPTURE_WARM
35+ } else {
36+ RUPTURE_HOT
37+ };
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+}
Added file: src/game/household.rs+192 additions
@@ -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+ authorization
51+ .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+ EXCUSES
62+ .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_ERRANDS
77+ } else {
78+ BEDROOM_ERRANDS
79+ };
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+}
Added file: src/game/items.rs+210 additions
@@ -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::ALL
187+ .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+}
Deleted file: src/game/jelq.rs−677 deletions
@@ -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- 8
293- } else {
294- 0
295- };
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- outcome
562- }
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-}
Modified file: src/game/mod.rs+116 additions−519 deletions
@@ -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) + 1
68- }
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- self
199- }
200-
201- pub fn status(&self) -> StatusCode {
202- self.status
203- }
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- response
235- }
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.06
90+ (claimed - actual).abs() < HONEST_TOLERANCE
31291 }
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- run
163+ }
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_length
198+ self.length + self.here.temp_length
419199 }
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_STAMINA
208+ } else {
209+ MAX_STAMINA
210+ }
211+ }
212+
213+ pub fn forum_read(&self) -> bool {
214+ self.story == Story::ReadForum
427215 }
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.shame
437- }
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- view
481- }
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- view
253+ })
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- owned
581- .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_turns
694- ),
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 }
Added file: src/game/outcome.rs+44 additions
@@ -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+ self
43+ }
44+}
Added file: src/game/shop.rs+109 additions
@@ -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> = SHOP
21+ .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+}
Added file: src/game/state.rs+245 additions
@@ -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 u8
20+ }
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+}
Added file: src/game/techniques.rs+220 additions
@@ -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+}
Added file: src/game/turn.rs+522 additions
@@ -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_CHILL
297+ } else {
298+ 0
299+ };
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_INTENSITY
326+ && !self.conditions.pulled_muscle
327+ && 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_BONUS
371+ } else {
372+ 0
373+ };
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_LINE
388+ } else {
389+ RUPTURE_LINE
390+ };
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+ outcome
521+ }
522+}
Added file: src/game/utility.rs+133 additions
@@ -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+}
Added file: src/headers.rs+18 additions
@@ -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");
Deleted file: src/http.rs−1278 deletions
@@ -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- parts
156- .headers
157- .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
158- parts.headers.remove(header::CONTENT_LENGTH);
159- parts
160- .headers
161- .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- headers
179- .entry(header::CACHE_CONTROL)
180- .or_insert(HeaderValue::from_static("no-store, private"));
181- response
182-}
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- headers
190- .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- response
256-}
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- response
362-}
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_present
413- && 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- response
471-}
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 usize
596-}
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>> = body
651- .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> = run
716- .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::ALL
775- .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_PROTOCOLS
964- && 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- response
976-}
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> = runs
1074- .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> = runs
1131- .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> = entries
1143- .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- entry
1157- })
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 page
1211-// 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- .await
1236- .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)> = parts
1248- .headers
1249- .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- response
1266- .headers_mut()
1267- .append(header::SET_COOKIE, cookie.clone());
1268- }
1269- response
1270-}
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-}
Added file: src/http/ctx.rs+90 additions
@@ -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.headers
35+ .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+ .await
61+ .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+}
Added file: src/http/meta.rs+219 additions
@@ -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 = ctx
31+ .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+ response
104+}
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> = runs
133+ .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> = runs
182+ .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> = entries
194+ .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+ entry
208+ })
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+}
Added file: src/http/middleware.rs+97 additions
@@ -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+ parts
68+ .headers
69+ .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
70+ parts.headers.remove(header::CONTENT_LENGTH);
71+ parts
72+ .headers
73+ .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 = request
79+ .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+ headers
94+ .entry(header::CACHE_CONTROL)
95+ .or_insert(HeaderValue::from_static("no-store, private"));
96+ response
97+}
Added file: src/http/mod.rs+113 additions
@@ -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+}
Added file: src/http/resource.rs+141 additions
@@ -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+ ..self
38+ }
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+ requested
78+ };
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+ response
141+}
Added file: src/http/response.rs+48 additions
@@ -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+ response
40+ }
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+}
Added file: src/http/run.rs+205 additions
@@ -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 this
111+/// 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 = ctx
114+ .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+ total
121+}
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+ response
185+}
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+ response
205+}
Added file: src/http/runs.rs+488 additions
@@ -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::Clinic
45+}
46+
47+fn never(_: &Run) -> bool {
48+ false
49+}
50+
51+fn unreachable_handler(_: &mut Run, _: &Ctx) -> Response {
52+ StatusCode::METHOD_NOT_ALLOWED.into_response()
53+}
54+
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 ctx
102+ .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 = ctx
130+ .query("technique")
131+ .filter(|n| !n.trim().is_empty())
132+ .ok_or_else(required)?;
133+ names
134+ .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 = ctx
182+ .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 usize
230+}
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>> = body
285+ .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> = run
311+ .items
312+ .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::ALL
360+ .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_floor(run: &mut Run, _: &Ctx) -> Response {
382+ Json(run.floor_view()).into_response()
383+}
384+
385+fn leave_floor(run: &mut Run, _: &Ctx) -> Response {
386+ respond(run.advance())
387+}
388+
389+fn get_shop(run: &mut Run, _: &Ctx) -> Response {
390+ respond(run.shop_view().map(Json))
391+}
392+
393+fn buy(run: &mut Run, ctx: &Ctx) -> Response {
394+ respond(run.buy(ctx.param("item")))
395+}
396+
397+fn get_portal(run: &mut Run, _: &Ctx) -> Response {
398+ Json(json!({
399+ "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.",
400+ "accept": { "href": run.path("/portal"), "method": "POST" },
401+ }))
402+ .into_response()
403+}
404+
405+fn accept_portal(run: &mut Run, _: &Ctx) -> Response {
406+ respond(run.accept_terms())
407+}
408+
409+fn get_elder(run: &mut Run, ctx: &Ctx) -> Response {
410+ respond(run.elder(
411+ ctx.header(header::PROXY_AUTHORIZATION),
412+ ctx.header(header::IF_MODIFIED_SINCE),
413+ ))
414+}
415+
416+fn requested_grip(ctx: &Ctx) -> Option<Grip> {
417+ let connection = ctx.header(header::CONNECTION)?.to_ascii_lowercase();
418+ if !connection.split(',').any(|token| token.trim() == "upgrade") {
419+ return None;
420+ }
421+ ctx.header(header::UPGRADE)?
422+ .split(',')
423+ .find_map(|protocol| Grip::parse(protocol.trim()))
424+}
425+
426+fn get_grip(run: &mut Run, ctx: &Ctx) -> Response {
427+ if let Some(grip) = requested_grip(ctx) {
428+ run.set_grip(grip);
429+ let mut response = (
430+ StatusCode::SWITCHING_PROTOCOLS,
431+ [
432+ (header::UPGRADE, grip.name()),
433+ (header::CONNECTION, "upgrade"),
434+ ],
435+ )
436+ .into_response();
437+ response.extensions_mut().insert(Greeting(format!(
438+ "You are now jelqing {}.\r\n",
439+ grip.name()
440+ )));
441+ return response;
442+ }
443+ let detail = match ctx.header(header::UPGRADE) {
444+ Some(protocol) => {
445+ format!("The server does not know the {protocol} grip. Grips are protocols.")
446+ }
447+ None => "Grips are protocols. Change grip by upgrading the connection.".to_owned(),
448+ };
449+ (
450+ StatusCode::OK,
451+ [(header::UPGRADE, "overhand, underhand")],
452+ Json(json!({ "grip": run.grip.name(), "detail": detail })),
453+ )
454+ .into_response()
455+}
456+
457+fn get_diagnosis(run: &mut Run, ctx: &Ctx) -> Response {
458+ respond(run.diagnose(
459+ ctx.header(header::AUTHORIZATION),
460+ ctx.header(header::IF_NONE_MATCH),
461+ ))
462+}
463+
464+fn get_footage(run: &mut Run, _: &Ctx) -> Response {
465+ let Some((filmed, age)) = run.footage() else {
466+ return Problem::new(
467+ StatusCode::NOT_FOUND,
468+ "no-footage",
469+ "There is no footage. Yet.",
470+ )
471+ .into_response();
472+ };
473+ (
474+ [
475+ (
476+ header::CACHE_CONTROL,
477+ "public, max-age=31536000, immutable".to_owned(),
478+ ),
479+ (header::AGE, age.to_string()),
480+ (header::LAST_MODIFIED, httpdate::fmt_http_date(filmed)),
481+ ],
482+ Json(json!({
483+ "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",
484+ "views": 1 + age * 37,
485+ })),
486+ )
487+ .into_response()
488+}
Added file: src/http/ui.rs+105 additions
@@ -0,0 +1,105 @@
1+use std::io::Read as _;
2+
3+use axum::{
4+ Json, Router,
5+ body::{Body, Bytes},
6+ extract::{Request, State},
7+ http::{HeaderMap, HeaderValue, Method, StatusCode, header},
8+ response::{Html, IntoResponse, Response},
9+};
10+use flate2::read::GzDecoder;
11+use serde::Deserialize;
12+use serde_json::json;
13+use tower::ServiceExt;
14+
15+use super::resource::unsupported;
16+use crate::problem::{Problem, reason};
17+
18+const RESPONSE_LIMIT: usize = 1 << 20;
19+
20+#[derive(Deserialize)]
21+struct Envelope {
22+ method: String,
23+ path: String,
24+ #[serde(default)]
25+ headers: Vec<(String, String)>,
26+ #[serde(default)]
27+ body: String,
28+}
29+
30+pub async fn page(method: Method) -> Response {
31+ if method != Method::GET && method != Method::HEAD {
32+ return unsupported(&method, "GET, HEAD, OPTIONS");
33+ }
34+ Html(include_str!("../ui.html")).into_response()
35+}
36+
37+fn bad_envelope(detail: &str) -> Response {
38+ Problem::new(StatusCode::BAD_REQUEST, "bad-envelope", detail).into_response()
39+}
40+
41+fn decode(headers: &HeaderMap, bytes: Bytes) -> Vec<u8> {
42+ let gzipped = headers.get(header::CONTENT_ENCODING) == Some(&HeaderValue::from_static("gzip"));
43+ let mut decoded = Vec::new();
44+ if gzipped
45+ && GzDecoder::new(bytes.as_ref())
46+ .read_to_end(&mut decoded)
47+ .is_ok()
48+ {
49+ return decoded;
50+ }
51+ bytes.to_vec()
52+}
53+
54+// Browsers hide 3xx responses from fetch and refuse TRACE, Upgrade, and Expect, so the page
55+// replays requests in-process to show the player exactly what the API said.
56+pub async fn send(State(api): State<Router>, headers: HeaderMap, body: Bytes) -> Response {
57+ let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
58+ return bad_envelope("The envelope could not be parsed.");
59+ };
60+ if !envelope.path.starts_with('/') || envelope.path.starts_with("/ui") {
61+ return bad_envelope("The path must be an API path.");
62+ }
63+ let Ok(method) = Method::from_bytes(envelope.method.as_bytes()) else {
64+ return bad_envelope("That is not a method.");
65+ };
66+ let mut request = Request::builder().method(method).uri(&envelope.path);
67+ for (name, value) in envelope.headers.iter().filter(|(_, v)| !v.is_empty()) {
68+ request = request.header(name, value);
69+ }
70+ for cookie in headers.get_all(header::COOKIE) {
71+ request = request.header(header::COOKIE, cookie);
72+ }
73+ let Ok(request) = request.body(Body::from(envelope.body)) else {
74+ return bad_envelope("The request could not be built. A header may be malformed.");
75+ };
76+ let Ok(response) = api.oneshot(request).await;
77+ let (parts, body) = response.into_parts();
78+ let bytes = axum::body::to_bytes(body, RESPONSE_LIMIT)
79+ .await
80+ .unwrap_or_default();
81+ let shown = decode(&parts.headers, bytes);
82+ let listed: Vec<(String, String)> = parts
83+ .headers
84+ .iter()
85+ .map(|(name, value)| {
86+ (
87+ name.to_string(),
88+ value.to_str().unwrap_or("<binary>").to_owned(),
89+ )
90+ })
91+ .collect();
92+ let mut response = Json(json!({
93+ "status": parts.status.as_u16(),
94+ "reason": reason(parts.status),
95+ "headers": listed,
96+ "body": String::from_utf8_lossy(&shown),
97+ }))
98+ .into_response();
99+ for cookie in parts.headers.get_all(header::SET_COOKIE) {
100+ response
101+ .headers_mut()
102+ .append(header::SET_COOKIE, cookie.clone());
103+ }
104+ response
105+}
Modified file: src/lib.rs+10 additions−4 deletions
@@ -1,6 +1,12 @@
1-pub mod game;
2-pub mod http;
3-pub mod problem;
4-pub mod secret;
1+mod config;
2+mod game;
3+mod headers;
4+mod http;
5+mod problem;
6+mod secret;
57
8+#[cfg(test)]
9+mod tests;
10+
11+pub use config::Config;
612 pub use http::{AppState, app, router};
Modified file: src/main.rs+4 additions−4 deletions
@@ -1,7 +1,7 @@
11 #[tokio::main]
22 async fn main() -> std::io::Result<()> {
3- let addr = std::env::var("JELQD_ADDR").unwrap_or_else(|_| "127.0.0.1:6969".into());
4- let listener = tokio::net::TcpListener::bind(&addr).await?;
5- eprintln!("jelqd listening on http://{addr}");
6- axum::serve(listener, jelqd::app()).await
3+ let config = jelqd::Config::from_env();
4+ let listener = tokio::net::TcpListener::bind(&config.addr).await?;
5+ eprintln!("jelqd listening on http://{}", config.addr);
6+ axum::serve(listener, jelqd::app(config)).await
77 }
Modified file: src/problem.rs+3 additions−2 deletions
@@ -39,8 +39,9 @@ impl Problem {
3939 }
4040
4141 pub fn header(mut self, name: HeaderName, value: impl AsRef<str>) -> Self {
42- let value = HeaderValue::from_str(value.as_ref()).expect("header values are visible ascii");
43- self.0.headers.append(name, value);
42+ if let Ok(value) = HeaderValue::from_str(value.as_ref()) {
43+ self.0.headers.append(name, value);
44+ }
4445 self
4546 }
4647 }
Modified file: src/secret.rs+33 additions−27 deletions
@@ -1,19 +1,9 @@
1-use std::{fmt::Write as _, sync::LazyLock};
1+use std::fmt::Write as _;
22
33 use hmac::{Hmac, Mac};
44 use sha2::Sha256;
55
6-static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {
7- std::env::var("JELQD_SECRET")
8- .map(String::into_bytes)
9- .unwrap_or_else(|_| rand::random::<[u8; 32]>().to_vec())
10-});
11-
12-fn mac(message: &str) -> Hmac<Sha256> {
13- let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac accepts any key length");
14- mac.update(message.as_bytes());
15- mac
16-}
6+pub struct Secret(Vec<u8>);
177
188 fn hex(bytes: &[u8]) -> String {
199 bytes.iter().fold(String::new(), |mut out, b| {
@@ -32,21 +22,37 @@ fn unhex(text: &str) -> Option<Vec<u8>> {
3222 .collect()
3323 }
3424
35-pub fn sign(name: &str, value: &str) -> String {
36- let tag = mac(&format!("{name}={value}")).finalize().into_bytes();
37- format!("{value}.{}", hex(&tag))
38-}
25+impl Secret {
26+ pub fn new(key: Vec<u8>) -> Self {
27+ Self(key)
28+ }
3929
40-pub fn verify<'a>(name: &str, signed: &'a str) -> Option<&'a str> {
41- let (value, tag) = signed.rsplit_once('.')?;
42- mac(&format!("{name}={value}"))
43- .verify_slice(&unhex(tag)?)
44- .ok()?;
45- Some(value)
46-}
30+ pub fn random() -> Self {
31+ Self(rand::random::<[u8; 32]>().to_vec())
32+ }
4733
48-/// Mixes the server secret into a seed, so a public seed and public source cannot predict rolls offline.
49-pub fn mix_seed(seed: u64) -> u64 {
50- let tag = mac(&format!("seed={seed}")).finalize().into_bytes();
51- u64::from_le_bytes(tag[..8].try_into().expect("sha256 is longer than 8 bytes"))
34+ fn mac(&self, message: &str) -> Hmac<Sha256> {
35+ let mut mac = Hmac::<Sha256>::new_from_slice(&self.0).expect("hmac accepts any key length");
36+ mac.update(message.as_bytes());
37+ mac
38+ }
39+
40+ pub fn sign(&self, name: &str, value: &str) -> String {
41+ let tag = self.mac(&format!("{name}={value}")).finalize().into_bytes();
42+ format!("{value}.{}", hex(&tag))
43+ }
44+
45+ pub fn verify<'a>(&self, name: &str, signed: &'a str) -> Option<&'a str> {
46+ let (value, tag) = signed.rsplit_once('.')?;
47+ self.mac(&format!("{name}={value}"))
48+ .verify_slice(&unhex(tag)?)
49+ .ok()?;
50+ Some(value)
51+ }
52+
53+ /// Mixes the secret into a seed, so a public seed and public source cannot predict rolls offline.
54+ pub fn mix_seed(&self, seed: u64) -> u64 {
55+ let tag = self.mac(&format!("seed={seed}")).finalize().into_bytes();
56+ u64::from_le_bytes(tag[..8].try_into().expect("sha256 is longer than 8 bytes"))
57+ }
5258 }
Added file: src/tests.rs+682 additions
@@ -0,0 +1,682 @@
1+use std::{sync::Arc, time::Duration};
2+
3+use axum::{
4+ Router,
5+ body::{Body, to_bytes},
6+ http::{Method, Request, StatusCode, Version, header},
7+ response::Response,
8+};
9+use serde_json::Value;
10+use tower::ServiceExt;
11+
12+use crate::{
13+ AppState, Config,
14+ game::{Floor, MomVisit, Run},
15+ router,
16+};
17+
18+async fn send(
19+ app: &Router,
20+ method: &str,
21+ uri: &str,
22+ headers: &[(&str, &str)],
23+ body: &str,
24+) -> Response {
25+ let mut request = Request::builder()
26+ .method(Method::from_bytes(method.as_bytes()).unwrap())
27+ .uri(uri);
28+ for (name, value) in headers {
29+ request = request.header(*name, *value);
30+ }
31+ app.clone()
32+ .oneshot(request.body(Body::from(body.to_owned())).unwrap())
33+ .await
34+ .unwrap()
35+}
36+
37+async fn json(response: Response) -> Value {
38+ serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
39+}
40+
41+async fn text(response: Response) -> String {
42+ String::from_utf8(
43+ to_bytes(response.into_body(), usize::MAX)
44+ .await
45+ .unwrap()
46+ .to_vec(),
47+ )
48+ .unwrap()
49+}
50+
51+fn header<'a>(response: &'a Response, name: &str) -> &'a str {
52+ response.headers()[name].to_str().unwrap()
53+}
54+
55+fn fixture() -> Run {
56+ Run::new("cafebabe".into(), "cafe".into(), 69)
57+}
58+
59+fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
60+ let state = Arc::new(AppState::default());
61+ let mut run = fixture();
62+ run.floor = floor;
63+ setup(&mut run);
64+ state.insert(run);
65+ router(state)
66+}
67+
68+fn seeded(floor: Floor) -> (Router, String) {
69+ let state = Arc::new(AppState::default());
70+ let mut run = fixture();
71+ run.floor = floor;
72+ let etag = run.origin_etag();
73+ state.insert(run);
74+ (router(state), etag)
75+}
76+
77+fn app() -> Router {
78+ crate::app(Config::default())
79+}
80+
81+fn mom_at_the_door(run: &mut Run) {
82+ run.mom = Some(MomVisit {
83+ errand: "She heard a noise.",
84+ nonce: "abc".into(),
85+ strikes: 0,
86+ });
87+}
88+
89+#[tokio::test]
90+async fn creating_a_run_returns_location_and_etag() {
91+ let response = send(&app(), "POST", "/runs?seed=1", &[], "").await;
92+ assert_eq!(response.status(), StatusCode::CREATED);
93+ assert!(header(&response, "location").starts_with("/runs/"));
94+ assert!(response.headers().contains_key(header::ETAG));
95+ assert_eq!(header(&response, "server"), "jelqd/0.6.9");
96+}
97+
98+#[tokio::test]
99+async fn bodies_do_not_repeat_the_headers() {
100+ let (app, _) = seeded(Floor::Bedroom);
101+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
102+ assert!(response.headers().contains_key("x-eq"));
103+ let body = json(response).await;
104+ for stat in [
105+ "eq",
106+ "girth",
107+ "foreskin",
108+ "stamina",
109+ "cash",
110+ "self_reported_length",
111+ ] {
112+ assert!(body.get(stat).is_none(), "{stat} is already a header");
113+ }
114+}
115+
116+#[tokio::test]
117+async fn jelqing_cold_fails_the_precondition() {
118+ let (app, _) = seeded(Floor::Bedroom);
119+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
120+ assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
121+ assert_eq!(
122+ header(&response, "content-type"),
123+ "application/problem+json"
124+ );
125+}
126+
127+#[tokio::test]
128+async fn wet_without_lube_is_unsupported_media() {
129+ let (app, _) = seeded(Floor::Bedroom);
130+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
131+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
132+ assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
133+ let images = send(
134+ &app,
135+ "JELQ",
136+ "/runs/cafebabe?technique=wet",
137+ &[("content-type", "image/jpeg")],
138+ "",
139+ )
140+ .await;
141+ assert_eq!(json(images).await["detail"], "No.");
142+}
143+
144+#[tokio::test]
145+async fn unknown_methods_are_not_implemented() {
146+ let (app, _) = seeded(Floor::Bedroom);
147+ let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
148+ assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
149+ let teapot = send(&app, "BREW", "/runs/cafebabe", &[], "").await;
150+ assert_eq!(teapot.status(), StatusCode::IM_A_TEAPOT);
151+}
152+
153+#[tokio::test]
154+async fn the_clinic_does_not_advertise_jelq() {
155+ let (app, _) = seeded(Floor::Clinic);
156+ let options = send(&app, "OPTIONS", "/runs/cafebabe", &[], "").await;
157+ assert!(!header(&options, "allow").contains("JELQ"));
158+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
159+ assert_eq!(jelq.status(), StatusCode::METHOD_NOT_ALLOWED);
160+ assert!(!header(&jelq, "allow").contains("JELQ"));
161+ let (bedroom, _) = seeded(Floor::Bedroom);
162+ let options = send(&bedroom, "OPTIONS", "/runs/cafebabe", &[], "").await;
163+ assert!(header(&options, "allow").contains("JELQ"));
164+}
165+
166+#[tokio::test]
167+async fn patch_only_accepts_the_truth() {
168+ let (app, _) = seeded(Floor::Bedroom);
169+ let patch = [("content-type", "application/json-patch+json")];
170+ let replace = send(
171+ &app,
172+ "PATCH",
173+ "/runs/cafebabe",
174+ &patch,
175+ r#"[{"op":"replace","path":"/length","value":12}]"#,
176+ )
177+ .await;
178+ assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
179+ let lie = send(
180+ &app,
181+ "PATCH",
182+ "/runs/cafebabe",
183+ &patch,
184+ r#"[{"op":"test","path":"/length","value":40}]"#,
185+ )
186+ .await;
187+ assert_eq!(
188+ json(lie).await["type"],
189+ "https://jelq.dev/problems/exaggeration"
190+ );
191+}
192+
193+#[tokio::test]
194+async fn deleting_the_foreskin_requires_certainty() {
195+ let (app, _) = seeded(Floor::Bedroom);
196+ let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
197+ assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
198+ let look = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
199+ assert_eq!(look.status(), StatusCode::NO_CONTENT);
200+ let etag = header(&look, "etag").to_owned();
201+ let response = send(
202+ &app,
203+ "DELETE",
204+ "/runs/cafebabe/foreskin",
205+ &[("if-match", &etag)],
206+ "",
207+ )
208+ .await;
209+ assert_eq!(response.status(), StatusCode::NO_CONTENT);
210+ let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
211+ assert_eq!(response.status(), StatusCode::GONE);
212+}
213+
214+#[tokio::test]
215+async fn length_is_chunked_until_the_ruler() {
216+ let (app, _) = seeded(Floor::Bedroom);
217+ let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
218+ assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
219+ let found = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
220+ assert_eq!(found.status(), StatusCode::FOUND);
221+ let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
222+ let declared: usize = header(&truth, "content-length").parse().unwrap();
223+ let body = text(truth).await;
224+ assert_eq!(body.len(), declared);
225+ assert!(body.starts_with("8=") && body.ends_with('D'));
226+}
227+
228+#[tokio::test]
229+async fn length_supports_inch_ranges() {
230+ let (app, _) = seeded(Floor::Bedroom);
231+ let response = send(
232+ &app,
233+ "GET",
234+ "/runs/cafebabe/length",
235+ &[("range", "inches=0-1")],
236+ "",
237+ )
238+ .await;
239+ assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
240+ assert!(header(&response, "content-range").starts_with("inches 0.00-1.00/"));
241+ assert_eq!(text(response).await.len(), 25);
242+ let absurd = send(
243+ &app,
244+ "GET",
245+ "/runs/cafebabe/length",
246+ &[("range", "inches=40-50")],
247+ "",
248+ )
249+ .await;
250+ assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
251+}
252+
253+#[tokio::test]
254+async fn the_clinic_escalates_toward_honesty() {
255+ let (app, _) = seeded(Floor::Clinic);
256+ let mut hints = Vec::new();
257+ for _ in 0..3 {
258+ let response = send(
259+ &app,
260+ "GET",
261+ "/runs/cafebabe/diagnosis",
262+ &[("authorization", "Excuse \"changing\"")],
263+ "",
264+ )
265+ .await;
266+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
267+ hints.push(header(&response, "www-authenticate").to_owned());
268+ }
269+ assert!(hints[2].contains("Be honest."));
270+}
271+
272+#[tokio::test]
273+async fn the_true_ending_is_not_modified() {
274+ let (early, _) = seeded(Floor::Bedroom);
275+ let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
276+ assert_eq!(response.status(), StatusCode::TOO_EARLY);
277+
278+ let (app, etag) = seeded(Floor::Clinic);
279+ assert!(
280+ !etag.contains("cafebabe"),
281+ "the origin ETag must not be derivable from the id"
282+ );
283+ let response = send(
284+ &app,
285+ "GET",
286+ "/runs/cafebabe/diagnosis",
287+ &[
288+ ("authorization", "Bearer honesty"),
289+ ("if-none-match", &etag),
290+ ],
291+ "",
292+ )
293+ .await;
294+ assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
295+ assert_eq!(header(&response, "x-diagnosis"), "normal");
296+}
297+
298+#[tokio::test]
299+async fn mom_accepts_a_known_excuse() {
300+ let app = seeded_with(Floor::Bedroom, mom_at_the_door);
301+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
302+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
303+ assert_eq!(header(&response, "via"), "1.1 mom");
304+ let response = send(
305+ &app,
306+ "GET",
307+ "/runs/cafebabe",
308+ &[("authorization", "Excuse \"homework\"")],
309+ "",
310+ )
311+ .await;
312+ assert_eq!(response.status(), StatusCode::OK);
313+}
314+
315+#[tokio::test]
316+async fn mom_runs_out_of_patience() {
317+ let app = seeded_with(Floor::Bedroom, mom_at_the_door);
318+ let lie = [("authorization", "Excuse \"it is for science\"")];
319+ for _ in 0..2 {
320+ let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
321+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
322+ }
323+ let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
324+ assert_eq!(caught.status(), StatusCode::GONE);
325+ assert_eq!(
326+ json(caught).await["type"],
327+ "https://jelq.dev/problems/caught"
328+ );
329+}
330+
331+#[tokio::test]
332+async fn mom_remembers_excuses_by_thanksgiving() {
333+ let app = seeded_with(Floor::Grandmas, |run| {
334+ mom_at_the_door(run);
335+ run.heard.push("homework".into());
336+ });
337+ let basic = send(
338+ &app,
339+ "GET",
340+ "/runs/cafebabe",
341+ &[("authorization", "Excuse \"changing\"")],
342+ "",
343+ )
344+ .await;
345+ assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
346+ assert!(header(&basic, "www-authenticate").starts_with("Digest"));
347+ let replay = send(
348+ &app,
349+ "GET",
350+ "/runs/cafebabe",
351+ &[(
352+ "authorization",
353+ "Digest username=\"homework\", nonce=\"abc\"",
354+ )],
355+ "",
356+ )
357+ .await;
358+ assert_eq!(
359+ json(replay).await["type"],
360+ "https://jelq.dev/problems/excuse-replayed"
361+ );
362+ let fresh = send(
363+ &app,
364+ "GET",
365+ "/runs/cafebabe",
366+ &[(
367+ "authorization",
368+ "Digest username=\"praying\", nonce=\"abc\"",
369+ )],
370+ "",
371+ )
372+ .await;
373+ assert_eq!(fresh.status(), StatusCode::OK);
374+}
375+
376+#[tokio::test]
377+async fn a_pulled_muscle_is_a_true_excuse() {
378+ let app = seeded_with(Floor::Grandmas, |run| {
379+ mom_at_the_door(run);
380+ run.conditions.pulled_muscle = true;
381+ run.heard.push("pulled a muscle".into());
382+ });
383+ let response = send(
384+ &app,
385+ "GET",
386+ "/runs/cafebabe",
387+ &[(
388+ "authorization",
389+ "Digest username=\"pulled a muscle\", nonce=\"abc\"",
390+ )],
391+ "",
392+ )
393+ .await;
394+ assert_eq!(response.status(), StatusCode::OK);
395+ assert!(header(&response, "x-stamina").ends_with("/7"));
396+}
397+
398+#[tokio::test]
399+async fn floors_end_when_they_end() {
400+ let app = seeded_with(Floor::Bedroom, |_| {});
401+ let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
402+ assert_eq!(early.status(), StatusCode::TOO_EARLY);
403+ let app = seeded_with(Floor::Bedroom, |run| run.here.turns = 6);
404+ let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
405+ assert!(
406+ next.status() == StatusCode::SEE_OTHER || next.status() == StatusCode::CONFLICT,
407+ "either the floor changed or the zipper did"
408+ );
409+}
410+
411+#[tokio::test]
412+async fn pump_bro_upsells() {
413+ let closed = seeded_with(Floor::Bedroom, |_| {});
414+ let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
415+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
416+
417+ let app = seeded_with(Floor::LockerRoom, |_| {});
418+ let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
419+ assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
420+ assert_eq!(header(&pump, "upgrade"), "pump-pro/2.0");
421+ let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
422+ assert_eq!(pro.status(), StatusCode::CREATED);
423+ let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
424+ assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
425+}
426+
427+#[tokio::test]
428+async fn only_the_key_opens_the_cage() {
429+ let app = seeded_with(Floor::LockerRoom, |run| run.items.cage = true);
430+ send(&app, "POST", "/runs/cafebabe/shop/ice-pack", &[], "").await;
431+ let still = send(
432+ &app,
433+ "GET",
434+ "/runs/cafebabe/inventory/chastity-cage",
435+ &[],
436+ "",
437+ )
438+ .await;
439+ assert_eq!(still.status(), StatusCode::OK);
440+ send(&app, "POST", "/runs/cafebabe/shop/cage-key", &[], "").await;
441+ let gone = send(
442+ &app,
443+ "GET",
444+ "/runs/cafebabe/inventory/chastity-cage",
445+ &[],
446+ "",
447+ )
448+ .await;
449+ assert_eq!(gone.status(), StatusCode::NOT_FOUND);
450+}
451+
452+#[tokio::test]
453+async fn combos_are_multi_status_xml() {
454+ let app = seeded_with(Floor::Bathroom, |run| {
455+ run.foreskin = Some(1.0);
456+ run.items.blinds = true;
457+ });
458+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
459+ let response = send(
460+ &app,
461+ "JELQ",
462+ "/runs/cafebabe?technique=wet,stretch",
463+ &[("content-type", "application/baby-oil")],
464+ "",
465+ )
466+ .await;
467+ assert_eq!(response.status(), StatusCode::MULTI_STATUS);
468+ assert!(text(response).await.contains("<D:multistatus"));
469+}
470+
471+#[tokio::test]
472+async fn the_elder_is_stale() {
473+ let app = seeded_with(Floor::Forum, |run| run.length = 5.0);
474+ let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
475+ assert_eq!(
476+ unverified.status(),
477+ StatusCode::PROXY_AUTHENTICATION_REQUIRED
478+ );
479+ let lie = send(
480+ &app,
481+ "GET",
482+ "/runs/cafebabe/elder",
483+ &[("proxy-authorization", "Ruler 9")],
484+ "",
485+ )
486+ .await;
487+ assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
488+ let csv = send(
489+ &app,
490+ "GET",
491+ "/runs/cafebabe/elder",
492+ &[("proxy-authorization", "Ruler 5.0")],
493+ "",
494+ )
495+ .await;
496+ assert_eq!(csv.status(), StatusCode::OK);
497+ let stale = send(
498+ &app,
499+ "GET",
500+ "/runs/cafebabe/elder",
501+ &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
502+ "",
503+ )
504+ .await;
505+ assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
506+}
507+
508+#[tokio::test]
509+async fn the_displayed_length_is_accepted_as_honest() {
510+ let app = seeded_with(Floor::Forum, |run| run.length = 5.2496);
511+ let response = send(
512+ &app,
513+ "GET",
514+ "/runs/cafebabe/elder",
515+ &[("proxy-authorization", "Ruler 5.25")],
516+ "",
517+ )
518+ .await;
519+ assert_eq!(response.status(), StatusCode::OK);
520+}
521+
522+#[tokio::test]
523+async fn the_hotel_has_a_captive_portal() {
524+ let app = seeded_with(Floor::Hotel, |_| {});
525+ let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
526+ assert_eq!(
527+ blocked.status(),
528+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED
529+ );
530+ let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
531+ assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
532+ let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
533+ assert_eq!(online.status(), StatusCode::OK);
534+}
535+
536+#[tokio::test]
537+async fn the_internet_never_forgets() {
538+ let app = seeded_with(Floor::Bedroom, |run| {
539+ run.filmed_at = Some(std::time::SystemTime::now());
540+ });
541+ let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
542+ assert_eq!(footage.status(), StatusCode::OK);
543+ assert_eq!(
544+ header(&footage, "cache-control"),
545+ "public, max-age=31536000, immutable"
546+ );
547+ assert!(footage.headers().contains_key(header::AGE));
548+ let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
549+ assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
550+ assert!(!header(&delete, "allow").contains("DELETE"));
551+}
552+
553+#[tokio::test]
554+async fn forged_ruler_cookies_are_lies() {
555+ let forged = send(
556+ &app(),
557+ "GET",
558+ "/leaderboard?sort=content-length",
559+ &[("cookie", "ruler=1")],
560+ "",
561+ )
562+ .await;
563+ assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
564+}
565+
566+#[tokio::test]
567+async fn listings_never_reveal_the_private_id() {
568+ let app = app();
569+ let created = send(&app, "POST", "/runs", &[], "").await;
570+ let location = header(&created, "location").to_owned();
571+ let board = text(send(&app, "GET", "/leaderboard", &[], "").await).await;
572+ let private = location.trim_start_matches("/runs/");
573+ assert!(!board.contains(private));
574+ let entry: Value = serde_json::from_str(&board).unwrap();
575+ let public = entry["entries"][0]["public_id"].as_str().unwrap();
576+ let guess = send(&app, "DELETE", &format!("/runs/{public}"), &[], "").await;
577+ assert_eq!(guess.status(), StatusCode::NOT_FOUND);
578+}
579+
580+#[tokio::test]
581+async fn seeded_runs_are_unranked() {
582+ let app = app();
583+ send(&app, "POST", "/runs?seed=7", &[], "").await;
584+ let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
585+ assert_eq!(board["entries"].as_array().unwrap().len(), 0);
586+ let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
587+ assert_eq!(all["entries"][0]["ranked"], false);
588+}
589+
590+#[tokio::test]
591+async fn idle_runs_are_evicted_and_the_server_has_a_limit() {
592+ let forgetful = crate::app(Config {
593+ idle_ttl: Duration::ZERO,
594+ ..Config::default()
595+ });
596+ let first = send(&forgetful, "POST", "/runs", &[], "").await;
597+ let location = header(&first, "location").to_owned();
598+ send(&forgetful, "POST", "/runs", &[], "").await;
599+ let gone = send(&forgetful, "GET", &location, &[], "").await;
600+ assert_eq!(gone.status(), StatusCode::NOT_FOUND);
601+
602+ let tiny = crate::app(Config {
603+ max_runs: 1,
604+ ..Config::default()
605+ });
606+ send(&tiny, "POST", "/runs", &[], "").await;
607+ let full = send(&tiny, "POST", "/runs", &[], "").await;
608+ assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
609+}
610+
611+#[tokio::test]
612+async fn shame_from_two_runs_adds_up() {
613+ let state = Arc::new(AppState::default());
614+ state.insert(fixture());
615+ state.insert(Run::new("deadbeef".into(), "dead".into(), 7));
616+ let app = router(state);
617+ let first = send(&app, "TRACE", "/runs/cafebabe", &[], "").await;
618+ assert_eq!(header(&first, "x-shame"), "1");
619+ let cookie = header(&first, "set-cookie")
620+ .split(';')
621+ .next()
622+ .unwrap()
623+ .to_owned();
624+ let second = send(&app, "TRACE", "/runs/deadbeef", &[("cookie", &cookie)], "").await;
625+ assert_eq!(header(&second, "x-shame"), "2");
626+}
627+
628+#[tokio::test]
629+async fn the_root_negotiates() {
630+ let app = app();
631+ let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
632+ assert_eq!(browser.status(), StatusCode::SEE_OTHER);
633+ assert_eq!(header(&browser, "location"), "/ui/");
634+ let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
635+ assert_eq!(json(curl).await["ui"], "/ui/");
636+ let page = send(&app, "GET", "/ui/", &[], "").await;
637+ assert_eq!(page.status(), StatusCode::OK);
638+}
639+
640+#[tokio::test]
641+async fn the_ui_replays_what_browsers_cannot_send() {
642+ let app = app();
643+ let envelope =
644+ r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
645+ let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
646+ assert_eq!(traced["status"], 405);
647+ let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
648+ let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
649+ assert_eq!(redirected["status"], 303);
650+ assert_eq!(redirected["reason"], "See Other");
651+}
652+
653+#[tokio::test]
654+async fn the_ui_decodes_gzip_for_display() {
655+ let app = seeded_with(Floor::Bedroom, |run| run.items.ring = true);
656+ let envelope =
657+ r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
658+ let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
659+ assert!(
660+ shown["body"]
661+ .as_str()
662+ .unwrap()
663+ .contains("\"public_id\":\"cafe\"")
664+ );
665+ let encoding = shown["headers"]
666+ .as_array()
667+ .unwrap()
668+ .iter()
669+ .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
670+ assert!(encoding);
671+}
672+
673+#[tokio::test]
674+async fn http_1_0_gets_the_pdf_treatment() {
675+ let request = Request::builder()
676+ .uri("/graveyard")
677+ .version(Version::HTTP_10)
678+ .body(Body::empty())
679+ .unwrap();
680+ let response = app().oneshot(request).await.unwrap();
681+ assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
682+}
Modified file: src/ui.html+1 additions
@@ -3,6 +3,7 @@
33 <head>
44 <meta charset="utf-8">
55 <title>jelqd</title>
6+<style>pre { white-space: pre-wrap; } pre, li, p { overflow-wrap: anywhere; }</style>
67 </head>
78 <body>
89
Deleted file: tests/http.rs−570 deletions
@@ -1,570 +0,0 @@
1-use std::sync::Arc;
2-
3-use axum::{
4- Router,
5- body::{Body, to_bytes},
6- http::{Method, Request, StatusCode, header},
7- response::Response,
8-};
9-use jelqd::{
10- AppState,
11- game::{Floor, Run},
12- router,
13-};
14-use serde_json::Value;
15-use tower::ServiceExt;
16-
17-async fn send(
18- app: &Router,
19- method: &str,
20- uri: &str,
21- headers: &[(&str, &str)],
22- body: &str,
23-) -> Response {
24- let mut request = Request::builder()
25- .method(Method::from_bytes(method.as_bytes()).unwrap())
26- .uri(uri);
27- for (name, value) in headers {
28- request = request.header(*name, *value);
29- }
30- app.clone()
31- .oneshot(request.body(Body::from(body.to_owned())).unwrap())
32- .await
33- .unwrap()
34-}
35-
36-async fn json(response: Response) -> Value {
37- serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
38-}
39-
40-fn seeded(floor: Floor) -> (Router, String) {
41- let state = Arc::new(AppState::default());
42- let mut run = Run::new("cafebabe".into(), 69, 0);
43- run.floor = floor;
44- let etag = run.origin_etag.clone();
45- state.insert(run);
46- (router(state), etag)
47-}
48-
49-#[tokio::test]
50-async fn creating_a_run_returns_location_and_etag() {
51- let app = jelqd::app();
52- let response = send(&app, "POST", "/runs?seed=1", &[], "").await;
53- assert_eq!(response.status(), StatusCode::CREATED);
54- assert!(response.headers().contains_key(header::LOCATION));
55- assert!(response.headers().contains_key(header::ETAG));
56- assert_eq!(response.headers()[header::SERVER], "jelqd/0.6.9");
57-}
58-
59-#[tokio::test]
60-async fn jelqing_cold_fails_the_precondition() {
61- let (app, _) = seeded(Floor::Bedroom);
62- let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
63- assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
64- assert_eq!(
65- response.headers()[header::CONTENT_TYPE],
66- "application/problem+json"
67- );
68-}
69-
70-#[tokio::test]
71-async fn wet_without_lube_is_unsupported_media() {
72- let (app, _) = seeded(Floor::Bedroom);
73- send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
74- let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
75- assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
76- let images = send(
77- &app,
78- "JELQ",
79- "/runs/cafebabe?technique=wet",
80- &[("content-type", "image/jpeg")],
81- "",
82- )
83- .await;
84- assert_eq!(json(images).await["detail"], "No.");
85-}
86-
87-#[tokio::test]
88-async fn unknown_methods_are_not_implemented() {
89- let (app, _) = seeded(Floor::Bedroom);
90- let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
91- assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
92-}
93-
94-#[tokio::test]
95-async fn patch_only_accepts_the_truth() {
96- let (app, _) = seeded(Floor::Bedroom);
97- let patch = [("content-type", "application/json-patch+json")];
98- let replace = send(
99- &app,
100- "PATCH",
101- "/runs/cafebabe",
102- &patch,
103- r#"[{"op":"replace","path":"/length","value":12}]"#,
104- )
105- .await;
106- assert_eq!(replace.status(), StatusCode::UNPROCESSABLE_ENTITY);
107- let lie = send(
108- &app,
109- "PATCH",
110- "/runs/cafebabe",
111- &patch,
112- r#"[{"op":"test","path":"/length","value":40}]"#,
113- )
114- .await;
115- assert_eq!(
116- json(lie).await["type"],
117- "https://jelq.dev/problems/exaggeration"
118- );
119-}
120-
121-#[tokio::test]
122-async fn deleting_the_foreskin_requires_certainty() {
123- let (app, _) = seeded(Floor::Bedroom);
124- let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
125- assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
126- let etag = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "")
127- .await
128- .headers()[header::ETAG]
129- .to_str()
130- .unwrap()
131- .to_owned();
132- let response = send(
133- &app,
134- "DELETE",
135- "/runs/cafebabe/foreskin",
136- &[("if-match", &etag)],
137- "",
138- )
139- .await;
140- assert_eq!(response.status(), StatusCode::NO_CONTENT);
141- let response = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "").await;
142- assert_eq!(response.status(), StatusCode::GONE);
143-}
144-
145-#[tokio::test]
146-async fn length_is_chunked_until_the_ruler() {
147- let (app, _) = seeded(Floor::Bedroom);
148- let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
149- assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
150- let found = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
151- assert_eq!(found.status(), StatusCode::FOUND);
152- let truth = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
153- let declared: usize = truth.headers()[header::CONTENT_LENGTH]
154- .to_str()
155- .unwrap()
156- .parse()
157- .unwrap();
158- let body = to_bytes(truth.into_body(), usize::MAX).await.unwrap();
159- assert_eq!(body.len(), declared);
160- assert!(body.starts_with(b"8=") && body.ends_with(b"D"));
161-}
162-
163-#[tokio::test]
164-async fn the_clinic_escalates_toward_honesty() {
165- let (app, _) = seeded(Floor::Clinic);
166- let mut hints = Vec::new();
167- for _ in 0..3 {
168- let response = send(
169- &app,
170- "GET",
171- "/runs/cafebabe/diagnosis",
172- &[("authorization", "Excuse \"changing\"")],
173- "",
174- )
175- .await;
176- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
177- hints.push(
178- response.headers()[header::WWW_AUTHENTICATE]
179- .to_str()
180- .unwrap()
181- .to_owned(),
182- );
183- }
184- assert!(hints[2].contains("Be honest."));
185-}
186-
187-#[tokio::test]
188-async fn the_true_ending_is_not_modified() {
189- let (app, etag) = seeded(Floor::Clinic);
190- let early = seeded(Floor::Bedroom).0;
191- let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
192- assert_eq!(response.status(), StatusCode::TOO_EARLY);
193-
194- let response = send(
195- &app,
196- "GET",
197- "/runs/cafebabe/diagnosis",
198- &[
199- ("authorization", "Bearer honesty"),
200- ("if-none-match", &etag),
201- ],
202- "",
203- )
204- .await;
205- assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
206- assert_eq!(response.headers()["x-diagnosis"], "normal");
207-}
208-
209-#[tokio::test]
210-async fn mom_accepts_a_known_excuse() {
211- let state = Arc::new(AppState::default());
212- let mut run = Run::new("cafebabe".into(), 69, 0);
213- run.mom_present = true;
214- state.insert(run);
215- let app = router(state);
216- let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
217- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
218- assert_eq!(response.headers()["via"], "1.1 mom");
219- let response = send(
220- &app,
221- "GET",
222- "/runs/cafebabe",
223- &[("authorization", "Excuse \"homework\"")],
224- "",
225- )
226- .await;
227- assert_eq!(response.status(), StatusCode::OK);
228-}
229-
230-fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {
231- let state = Arc::new(AppState::default());
232- let mut run = Run::new("cafebabe".into(), 69, 0);
233- run.floor = floor;
234- setup(&mut run);
235- state.insert(run);
236- router(state)
237-}
238-
239-#[tokio::test]
240-async fn floors_end_when_they_end() {
241- let app = seeded_with(Floor::Bedroom, |_| {});
242- let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
243- assert_eq!(early.status(), StatusCode::TOO_EARLY);
244- let app = seeded_with(Floor::Bedroom, |run| run.floor_turns = 6);
245- let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
246- assert_eq!(next.status(), StatusCode::SEE_OTHER);
247- assert_eq!(next.headers()["x-floor"], "2");
248-}
249-
250-#[tokio::test]
251-async fn pump_bro_upsells() {
252- let closed = seeded_with(Floor::Bedroom, |_| {});
253- let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;
254- assert_eq!(response.status(), StatusCode::NOT_FOUND);
255-
256- let app = seeded_with(Floor::LockerRoom, |_| {});
257- let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;
258- assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);
259- assert_eq!(pump.headers()[header::UPGRADE], "pump-pro/2.0");
260- let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;
261- assert_eq!(pro.status(), StatusCode::CREATED);
262- let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;
263- assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);
264-}
265-
266-#[tokio::test]
267-async fn combos_are_multi_status_xml() {
268- let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
269- send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
270- let response = send(
271- &app,
272- "JELQ",
273- "/runs/cafebabe?technique=wet,stretch",
274- &[("content-type", "application/baby-oil")],
275- "",
276- )
277- .await;
278- assert_eq!(response.status(), StatusCode::MULTI_STATUS);
279- let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
280- assert!(String::from_utf8_lossy(&body).contains("<D:multistatus"));
281-}
282-
283-#[tokio::test]
284-async fn the_elder_is_stale() {
285- let app = seeded_with(Floor::Forum, |run| run.length = 5.0);
286- let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;
287- assert_eq!(
288- unverified.status(),
289- StatusCode::PROXY_AUTHENTICATION_REQUIRED
290- );
291- let lie = send(
292- &app,
293- "GET",
294- "/runs/cafebabe/elder",
295- &[("proxy-authorization", "Ruler 9")],
296- "",
297- )
298- .await;
299- assert_eq!(lie.status(), StatusCode::BAD_REQUEST);
300- let csv = send(
301- &app,
302- "GET",
303- "/runs/cafebabe/elder",
304- &[("proxy-authorization", "Ruler 5.0")],
305- "",
306- )
307- .await;
308- assert_eq!(csv.status(), StatusCode::OK);
309- let stale = send(
310- &app,
311- "GET",
312- "/runs/cafebabe/elder",
313- &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],
314- "",
315- )
316- .await;
317- assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);
318- let exit = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
319- assert_eq!(exit.status(), StatusCode::SEE_OTHER);
320-}
321-
322-#[tokio::test]
323-async fn the_hotel_has_a_captive_portal() {
324- let app = seeded_with(Floor::Hotel, |_| {});
325- let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;
326- assert_eq!(
327- blocked.status(),
328- StatusCode::NETWORK_AUTHENTICATION_REQUIRED
329- );
330- let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;
331- assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
332- let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;
333- assert_eq!(online.status(), StatusCode::OK);
334-}
335-
336-#[tokio::test]
337-async fn mom_remembers_excuses_by_thanksgiving() {
338- let app = seeded_with(Floor::Grandmas, |run| {
339- run.mom_present = true;
340- run.nonce = "abc".into();
341- run.heard.push("homework".into());
342- });
343- let basic = send(
344- &app,
345- "GET",
346- "/runs/cafebabe",
347- &[("authorization", "Excuse \"changing\"")],
348- "",
349- )
350- .await;
351- assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);
352- assert!(
353- basic.headers()[header::WWW_AUTHENTICATE]
354- .to_str()
355- .unwrap()
356- .starts_with("Digest")
357- );
358- let replay = send(
359- &app,
360- "GET",
361- "/runs/cafebabe",
362- &[(
363- "authorization",
364- "Digest username=\"homework\", nonce=\"abc\"",
365- )],
366- "",
367- )
368- .await;
369- assert_eq!(
370- json(replay).await["type"],
371- "https://jelq.dev/problems/excuse-replayed"
372- );
373- let fresh = send(
374- &app,
375- "GET",
376- "/runs/cafebabe",
377- &[(
378- "authorization",
379- "Digest username=\"praying\", nonce=\"abc\"",
380- )],
381- "",
382- )
383- .await;
384- assert_eq!(fresh.status(), StatusCode::OK);
385-}
386-
387-#[tokio::test]
388-async fn length_supports_inch_ranges() {
389- let app = seeded_with(Floor::Bedroom, |_| {});
390- let response = send(
391- &app,
392- "GET",
393- "/runs/cafebabe/length",
394- &[("range", "inches=0-1")],
395- "",
396- )
397- .await;
398- assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
399- assert!(
400- response.headers()[header::CONTENT_RANGE]
401- .to_str()
402- .unwrap()
403- .starts_with("inches 0.00-1.00/")
404- );
405- let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
406- assert_eq!(body.len(), 25);
407- let absurd = send(
408- &app,
409- "GET",
410- "/runs/cafebabe/length",
411- &[("range", "inches=40-50")],
412- "",
413- )
414- .await;
415- assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
416-}
417-
418-#[tokio::test]
419-async fn http_1_0_gets_the_pdf_treatment() {
420- let request = Request::builder()
421- .uri("/graveyard")
422- .version(axum::http::Version::HTTP_10)
423- .body(Body::empty())
424- .unwrap();
425- let response = jelqd::app().oneshot(request).await.unwrap();
426- assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);
427-}
428-
429-#[tokio::test]
430-async fn the_displayed_length_is_accepted_as_honest() {
431- let app = seeded_with(Floor::Forum, |run| run.length = 5.2496);
432- let response = send(
433- &app,
434- "GET",
435- "/runs/cafebabe/elder",
436- &[("proxy-authorization", "Ruler 5.25")],
437- "",
438- )
439- .await;
440- assert_eq!(response.status(), StatusCode::OK);
441-}
442-
443-#[tokio::test]
444-async fn mom_runs_out_of_patience() {
445- let app = seeded_with(Floor::Bedroom, |run| run.mom_present = true);
446- let lie = [("authorization", "Excuse \"it is for science\"")];
447- for _ in 0..2 {
448- let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
449- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
450- }
451- let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;
452- assert_eq!(caught.status(), StatusCode::GONE);
453- assert_eq!(
454- json(caught).await["type"],
455- "https://jelq.dev/problems/caught"
456- );
457-}
458-
459-#[tokio::test]
460-async fn forged_ruler_cookies_are_lies() {
461- let app = jelqd::app();
462- let forged = send(
463- &app,
464- "GET",
465- "/leaderboard?sort=content-length",
466- &[("cookie", "ruler=1")],
467- "",
468- )
469- .await;
470- assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
471-}
472-
473-#[tokio::test]
474-async fn seeded_runs_are_unranked() {
475- let app = jelqd::app();
476- send(&app, "POST", "/runs?seed=7", &[], "").await;
477- let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;
478- assert_eq!(board["entries"].as_array().unwrap().len(), 0);
479- let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;
480- assert_eq!(all["entries"][0]["ranked"], false);
481-}
482-
483-#[tokio::test]
484-async fn the_root_negotiates() {
485- let app = jelqd::app();
486- let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;
487- assert_eq!(browser.status(), StatusCode::SEE_OTHER);
488- assert_eq!(browser.headers()[header::LOCATION], "/ui/");
489- let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;
490- assert_eq!(json(curl).await["ui"], "/ui/");
491- let page = send(&app, "GET", "/ui/", &[], "").await;
492- assert_eq!(page.status(), StatusCode::OK);
493-}
494-
495-#[tokio::test]
496-async fn the_ui_replays_what_browsers_cannot_send() {
497- let app = jelqd::app();
498- let envelope =
499- r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;
500- let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
501- assert_eq!(traced["status"], 405);
502- let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;
503- let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
504- assert_eq!(redirected["status"], 303);
505- assert_eq!(redirected["reason"], "See Other");
506-}
507-
508-#[tokio::test]
509-async fn the_internet_never_forgets() {
510- let app = seeded_with(Floor::Bedroom, |run| {
511- run.filmed_at = Some(std::time::SystemTime::now());
512- });
513- let footage = send(&app, "GET", "/runs/cafebabe/footage", &[], "").await;
514- assert_eq!(footage.status(), StatusCode::OK);
515- assert_eq!(
516- footage.headers()[header::CACHE_CONTROL],
517- "public, max-age=31536000, immutable"
518- );
519- assert!(footage.headers().contains_key(header::AGE));
520- let delete = send(&app, "DELETE", "/runs/cafebabe/footage", &[], "").await;
521- assert_eq!(delete.status(), StatusCode::METHOD_NOT_ALLOWED);
522-}
523-
524-#[tokio::test]
525-async fn a_pulled_muscle_is_a_true_excuse() {
526- let app = seeded_with(Floor::Grandmas, |run| {
527- run.mom_present = true;
528- run.nonce = "abc".into();
529- run.pulled_muscle = true;
530- run.heard.push("pulled a muscle".into());
531- });
532- let response = send(
533- &app,
534- "GET",
535- "/runs/cafebabe",
536- &[(
537- "authorization",
538- "Digest username=\"pulled a muscle\", nonce=\"abc\"",
539- )],
540- "",
541- )
542- .await;
543- assert_eq!(response.status(), StatusCode::OK);
544- assert!(
545- response.headers()["x-stamina"]
546- .to_str()
547- .unwrap()
548- .ends_with("/7")
549- );
550-}
551-
552-#[tokio::test]
553-async fn the_ui_decodes_gzip_for_display() {
554- let app = seeded_with(Floor::Bedroom, |run| run.items.ring = true);
555- let envelope =
556- r#"{"method": "GET", "path": "/runs/cafebabe", "headers": [["Accept-Encoding", "gzip"]]}"#;
557- let shown = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;
558- assert!(
559- shown["body"]
560- .as_str()
561- .unwrap()
562- .contains("\"id\":\"cafebabe\"")
563- );
564- let encoding = shown["headers"]
565- .as_array()
566- .unwrap()
567- .iter()
568- .any(|h| h[0] == "content-encoding" && h[1] == "gzip");
569- assert!(encoding);
570-}

show truncated diff