atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit dfa38cf4cd4b

tarball

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

11 files changed+3042-1221
jelqd: add floors 2 through 7, the shop, and the remaining status codes

Suspicion now summons a different enemy per floor, so one meter drives Mom, the roommate, gym staff, and housekeeping. Mom, the roommate, and housekeeping also visit at random so careful players cannot avoid them entirely. Grip changes are real HTTP/1.1 upgrades via hyper, and the cock ring gzips responses only when the client sends Accept-Encoding.
Modified file: Cargo.lock+53 additions
@@ -3,6 +3,12 @@
33 version = 4
44
55 [[package]]
6+name = "adler2"
7+version = "2.0.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10+
11+[[package]]
612 name = "atomic-waker"
713 version = "1.1.2"
814 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -73,6 +79,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
7379 checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
7480
7581 [[package]]
82+name = "crc32fast"
83+version = "1.5.2"
84+source = "registry+https://github.com/rust-lang/crates.io-index"
85+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
86+dependencies = [
87+ "cfg-if",
88+]
89+
90+[[package]]
91+name = "flate2"
92+version = "1.1.10"
93+source = "registry+https://github.com/rust-lang/crates.io-index"
94+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
95+dependencies = [
96+ "crc32fast",
97+ "miniz_oxide",
98+ "zlib-rs",
99+]
100+
101+[[package]]
76102 name = "form_urlencoded"
77103 version = "1.2.2"
78104 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -219,7 +245,11 @@ name = "jelqd"
219245 version = "0.6.9"
220246 dependencies = [
221247 "axum",
248+ "flate2",
222249 "futures-util",
250+ "httpdate",
251+ "hyper",
252+ "hyper-util",
223253 "rand",
224254 "serde",
225255 "serde_json",
@@ -258,6 +288,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
258288 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
259289
260290 [[package]]
291+name = "miniz_oxide"
292+version = "0.9.1"
293+source = "registry+https://github.com/rust-lang/crates.io-index"
294+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
295+dependencies = [
296+ "adler2",
297+ "simd-adler32",
298+]
299+
300+[[package]]
261301 name = "mio"
262302 version = "1.2.4"
263303 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -421,6 +461,12 @@ dependencies = [
421461 ]
422462
423463 [[package]]
464+name = "simd-adler32"
465+version = "0.3.10"
466+source = "registry+https://github.com/rust-lang/crates.io-index"
467+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
468+
469+[[package]]
424470 name = "slab"
425471 version = "0.4.12"
426472 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -476,6 +522,7 @@ version = "1.53.2"
476522 source = "registry+https://github.com/rust-lang/crates.io-index"
477523 checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
478524 dependencies = [
525+ "bytes",
479526 "libc",
480527 "mio",
481528 "pin-project-lite",
@@ -606,6 +653,12 @@ dependencies = [
606653 ]
607654
608655 [[package]]
656+name = "zlib-rs"
657+version = "0.6.8"
658+source = "registry+https://github.com/rust-lang/crates.io-index"
659+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
660+
661+[[package]]
609662 name = "zmij"
610663 version = "1.0.23"
611664 source = "registry+https://github.com/rust-lang/crates.io-index"
Modified file: Cargo.toml+5 additions−1 deletions
@@ -7,11 +7,15 @@ license = "MIT"
77
88 [dependencies]
99 axum = "0.8"
10+flate2 = "1"
1011 futures-util = "0.3"
12+httpdate = "1"
13+hyper = { version = "1", features = ["http1", "server"] }
14+hyper-util = { version = "0.1", features = ["tokio"] }
1115 rand = "0.9"
1216 serde = { version = "1", features = ["derive"] }
1317 serde_json = "1"
14-tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
18+tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }
1519
1620 [dev-dependencies]
1721 tower = { version = "0.5", features = ["util"] }
Modified file: flake.nix+5 additions−1 deletions
@@ -55,7 +55,11 @@
5555
5656 devShells.default = craneLib.devShell {
5757 checks = self.checks.${system};
58- packages = [ pkgs.rust-analyzer ];
58+ packages = [
59+ pkgs.rust-analyzer
60+ pkgs.nushell
61+ pkgs.curl
62+ ];
5963 };
6064
6165 formatter = pkgs.nixfmt;
Deleted file: src/game.rs−1028 deletions
@@ -1,1028 +0,0 @@
1-use std::time::{Duration, Instant};
2-
3-use axum::{
4- Json,
5- body::Body,
6- http::{HeaderName, HeaderValue, StatusCode, header},
7- response::{IntoResponse, Response},
8-};
9-use rand::{Rng, SeedableRng, rngs::StdRng};
10-use serde::Deserialize;
11-use serde_json::{Value, json};
12-
13-use crate::problem::Problem;
14-
15-pub const MAX_STAMINA: i32 = 10;
16-pub const SUSPICION_LIMIT: u8 = 5;
17-pub const BRANCH_TURN: u32 = 10;
18-pub const APPOINTMENT_TURN: u32 = 20;
19-pub const REFRACTORY: Duration = Duration::from_secs(30);
20-pub const SELF_REPORT_INFLATION: f64 = 1.5;
21-pub const MM_PER_INCH: f64 = 25.4;
22-pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];
23-pub const LUBRICANTS: [&str; 5] = [
24- "application/baby-oil",
25- "application/coconut-oil",
26- "application/lotion",
27- "application/vaseline",
28- "text/plain; charset=spit",
29-];
30-pub const INTAKE: [&str; 3] = [
31- "The clinic does not accept excuses.",
32- "The clinic accepts one token. You have not been carrying it.",
33- "Be honest.",
34-];
35-
36-pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
37-pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
38-
39-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40-pub enum Lube {
41- BabyOil,
42- CoconutOil,
43- Lotion,
44- Vaseline,
45- Spit,
46-}
47-
48-impl Lube {
49- pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
50- let Some(raw) = content_type else {
51- return Ok(None);
52- };
53- let lowered = raw.to_ascii_lowercase();
54- let mut parts = lowered.split(';').map(str::trim);
55- let essence = parts.next().unwrap_or_default();
56- let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
57- match essence {
58- "application/baby-oil" => Ok(Some(Self::BabyOil)),
59- "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
60- "application/lotion" => Ok(Some(Self::Lotion)),
61- "application/vaseline" => Ok(Some(Self::Vaseline)),
62- "text/plain" if spit => Ok(Some(Self::Spit)),
63- e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
64- StatusCode::UNSUPPORTED_MEDIA_TYPE,
65- "no",
66- "No.",
67- )),
68- _ => Err(Problem::new(
69- StatusCode::UNSUPPORTED_MEDIA_TYPE,
70- "unsupported-lubricant",
71- format!("{essence} is not a lubricant."),
72- )
73- .with("lubricants", json!(LUBRICANTS))),
74- }
75- }
76-
77- fn modifier(self) -> i32 {
78- match self {
79- Self::BabyOil => 1,
80- Self::CoconutOil => 3,
81- Self::Lotion => 0,
82- Self::Vaseline => 2,
83- Self::Spit => -2,
84- }
85- }
86-}
87-
88-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
89-pub enum Technique {
90- Warmup,
91- Ice,
92- Wet,
93- Dry,
94- Stretch,
95- Uli,
96-}
97-
98-#[derive(Clone, Copy)]
99-enum LubeRule {
100- Required,
101- Forbidden,
102- Optional,
103-}
104-
105-struct Profile {
106- dc: i32,
107- length: f64,
108- girth: f64,
109- foreskin: Option<(f64, f64)>,
110- foreskin_growth: f64,
111- lube: LubeRule,
112-}
113-
114-impl Technique {
115- pub const ALL: [Self; 6] = [
116- Self::Warmup,
117- Self::Ice,
118- Self::Wet,
119- Self::Dry,
120- Self::Stretch,
121- Self::Uli,
122- ];
123-
124- pub fn name(self) -> &'static str {
125- match self {
126- Self::Warmup => "warmup",
127- Self::Ice => "ice",
128- Self::Wet => "wet",
129- Self::Dry => "dry",
130- Self::Stretch => "stretch",
131- Self::Uli => "uli",
132- }
133- }
134-
135- pub fn parse(name: &str) -> Option<Self> {
136- Self::ALL.into_iter().find(|t| t.name() == name)
137- }
138-
139- fn profile(self) -> Option<Profile> {
140- let profile = match self {
141- Self::Warmup | Self::Ice => return None,
142- Self::Wet => Profile {
143- dc: 10,
144- length: 0.010,
145- girth: 0.005,
146- foreskin: None,
147- foreskin_growth: 0.0,
148- lube: LubeRule::Required,
149- },
150- Self::Dry => Profile {
151- dc: 12,
152- length: 0.008,
153- girth: 0.003,
154- foreskin: Some((0.8, 2.0)),
155- foreskin_growth: 0.01,
156- lube: LubeRule::Forbidden,
157- },
158- Self::Stretch => Profile {
159- dc: 11,
160- length: 0.015,
161- girth: 0.0,
162- foreskin: Some((0.0, 1.8)),
163- foreskin_growth: 0.03,
164- lube: LubeRule::Optional,
165- },
166- Self::Uli => Profile {
167- dc: 15,
168- length: 0.002,
169- girth: 0.020,
170- foreskin: None,
171- foreskin_growth: 0.0,
172- lube: LubeRule::Optional,
173- },
174- };
175- Some(profile)
176- }
177-
178- pub fn describe(self, forum_read: bool) -> Value {
179- let Some(p) = self.profile() else {
180- let effect = match self {
181- Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",
182- _ => "EQ -40. Consumes an ice pack.",
183- };
184- return json!({ "name": self.name(), "effect": effect });
185- };
186- let lubricant = match p.lube {
187- LubeRule::Required => "required",
188- LubeRule::Forbidden => "forbidden",
189- LubeRule::Optional => "optional",
190- };
191- let foreskin = p
192- .foreskin
193- .map(|(min, max)| json!({ "min": min, "max": max }));
194- json!({
195- "name": self.name(),
196- "dc": p.dc,
197- "lubricant": lubricant,
198- "foreskin": foreskin,
199- "locked": self == Self::Uli && !forum_read,
200- })
201- }
202-}
203-
204-pub struct Outcome {
205- status: StatusCode,
206- body: Option<Value>,
207- headers: Vec<(HeaderName, String)>,
208-}
209-
210-impl Outcome {
211- pub fn new(status: StatusCode, body: Option<Value>) -> Self {
212- Self {
213- status,
214- body,
215- headers: Vec::new(),
216- }
217- }
218-
219- pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
220- self.headers.push((name, value.into()));
221- self
222- }
223-}
224-
225-impl IntoResponse for Outcome {
226- fn into_response(self) -> Response {
227- let mut response = match self.body {
228- // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
229- Some(body) if self.status == StatusCode::PARTIAL_CONTENT => {
230- let full = body.to_string().into_bytes();
231- let half = full.len() / 2;
232- let range = format!("bytes 0-{}/{}", half - 1, full.len());
233- (
234- self.status,
235- [
236- (header::CONTENT_TYPE, "application/json".to_owned()),
237- (header::CONTENT_RANGE, range),
238- ],
239- Body::from(full[..half].to_vec()),
240- )
241- .into_response()
242- }
243- Some(body) => (self.status, Json(body)).into_response(),
244- None => self.status.into_response(),
245- };
246- for (name, value) in self.headers {
247- let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
248- response.headers_mut().append(name, value);
249- }
250- response
251- }
252-}
253-
254-#[derive(Deserialize)]
255-pub struct PatchOp {
256- pub op: String,
257- pub path: String,
258- #[serde(default)]
259- pub value: Value,
260-}
261-
262-pub struct Pending {
263- due: u32,
264- length: f64,
265- girth: f64,
266-}
267-
268-pub struct Ending {
269- pub cause: &'static str,
270- pub detail: String,
271- pub turn: u32,
272-}
273-
274-pub struct Run {
275- pub id: String,
276- pub origin_etag: String,
277- pub version: u64,
278- pub turn: u32,
279- pub length: f64,
280- pub girth: f64,
281- pub foreskin: Option<f64>,
282- pub eq: i32,
283- pub stamina: i32,
284- pub suspicion: u8,
285- pub shame_base: u32,
286- pub shame: u32,
287- pub ice_packs: u32,
288- pub warmed_up: bool,
289- pub teapot: bool,
290- pub ruler: bool,
291- pub put_applied: bool,
292- pub forum_read: bool,
293- pub branch_pending: bool,
294- pub mom_present: bool,
295- pub observed: bool,
296- pub measured: bool,
297- pub cured: bool,
298- pub intake_attempts: usize,
299- pub refractory_until: Option<Instant>,
300- pub pending: Vec<Pending>,
301- pub ended: Option<Ending>,
302- rng: StdRng,
303-}
304-
305-fn round2(x: f64) -> f64 {
306- (x * 100.0).round() / 100.0
307-}
308-
309-fn round3(x: f64) -> f64 {
310- (x * 1000.0).round() / 1000.0
311-}
312-
313-fn tenths(x: f64) -> i64 {
314- (x * 10.0).round() as i64
315-}
316-
317-fn not_extended() -> Problem {
318- Problem::new(
319- StatusCode::NOT_EXTENDED,
320- "not-extended",
321- "The requested extension was not applied. The server has recorded the attempt.",
322- )
323-}
324-
325-pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
326- let (given, rest) = authorization.trim().split_once(' ')?;
327- given
328- .eq_ignore_ascii_case(scheme)
329- .then(|| rest.trim().trim_matches('"'))
330-}
331-
332-impl Run {
333- pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
334- let mut rng = StdRng::seed_from_u64(seed);
335- let length = round2(rng.random_range(4.5..6.5));
336- let girth = round2(rng.random_range(4.0..5.0));
337- let foreskin = round2(rng.random_range(0.4..2.4));
338- let mut run = Self {
339- id,
340- origin_etag: String::new(),
341- version: 0,
342- turn: 0,
343- length,
344- girth,
345- foreskin: Some(foreskin),
346- eq: 20,
347- stamina: MAX_STAMINA,
348- suspicion: 0,
349- shame_base,
350- shame: 0,
351- ice_packs: 1,
352- warmed_up: false,
353- teapot: false,
354- ruler: false,
355- put_applied: false,
356- forum_read: false,
357- branch_pending: false,
358- mom_present: false,
359- observed: false,
360- measured: false,
361- cured: false,
362- intake_attempts: 0,
363- refractory_until: None,
364- pending: Vec::new(),
365- ended: None,
366- rng,
367- };
368- run.origin_etag = run.etag();
369- run
370- }
371-
372- pub fn etag(&self) -> String {
373- format!("\"{}-{}\"", self.id, self.version)
374- }
375-
376- pub fn touch(&mut self) {
377- self.version += 1;
378- }
379-
380- pub fn self_reported(&self) -> f64 {
381- self.length + SELF_REPORT_INFLATION
382- }
383-
384- pub fn total_shame(&self) -> u32 {
385- self.shame_base + self.shame
386- }
387-
388- pub fn status(&self) -> &'static str {
389- match (&self.ended, self.cured) {
390- (Some(_), _) => "gone",
391- (None, true) => "normal",
392- (None, false) => "alive",
393- }
394- }
395-
396- pub fn view(&self) -> Value {
397- let mut view = json!({
398- "id": self.id,
399- "status": self.status(),
400- "turn": self.turn,
401- "floor": 1,
402- "self_reported_length": round2(self.self_reported()),
403- "girth": round2(self.girth),
404- "foreskin": self.foreskin.map(round2),
405- "eq": self.eq,
406- "stamina": self.stamina,
407- "suspicion": self.suspicion,
408- "shame": self.total_shame(),
409- "warmed_up": self.warmed_up,
410- });
411- if self.ruler {
412- view["length"] = json!(round2(self.length));
413- }
414- if self.teapot {
415- view["condition"] = json!("short and stout");
416- }
417- view
418- }
419-
420- pub fn end(&mut self, cause: &'static str, detail: &str) {
421- self.ended = Some(Ending {
422- cause,
423- detail: detail.into(),
424- turn: self.turn,
425- });
426- }
427-
428- pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
429- let challenge =
430- |p: Problem| p.header(header::WWW_AUTHENTICATE, "Excuse realm=\"household\"");
431- let Some(authorization) = authorization else {
432- return Err(challenge(
433- Problem::new(
434- StatusCode::UNAUTHORIZED,
435- "someone-at-the-door",
436- "Someone is at the door.",
437- )
438- .with("inventory", format!("/runs/{}/inventory", self.id)),
439- ));
440- };
441- let excuse = parse_credentials(authorization, "excuse");
442- if excuse.is_some_and(|e| EXCUSES.iter().any(|known| known.eq_ignore_ascii_case(e))) {
443- self.mom_present = false;
444- self.suspicion = 0;
445- self.touch();
446- return Ok(());
447- }
448- self.shame += 3;
449- self.touch();
450- Err(challenge(Problem::new(
451- StatusCode::UNAUTHORIZED,
452- "excuse-rejected",
453- "Mom has not heard that one before. She does not believe it.",
454- )))
455- }
456-
457- fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
458- let Some(until) = self.refractory_until else {
459- return Ok(());
460- };
461- if now < until {
462- let seconds = (until - now).as_secs() + 1;
463- return Err(Problem::new(
464- StatusCode::TOO_MANY_REQUESTS,
465- "overtrained",
466- "Stamina is exhausted. The refractory period is in effect.",
467- )
468- .header(header::RETRY_AFTER, seconds.to_string()));
469- }
470- self.refractory_until = None;
471- self.stamina = MAX_STAMINA;
472- self.eq = 20;
473- self.warmed_up = false;
474- self.touch();
475- Ok(())
476- }
477-
478- fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
479- let Some(f) = self.foreskin else {
480- if min <= 0.0 {
481- return Ok(());
482- }
483- return Err(Problem::new(
484- StatusCode::GONE,
485- "foreskin-gone",
486- "This technique requires a foreskin. The foreskin is gone.",
487- )
488- .header(
489- header::CONTENT_LOCATION,
490- format!("/runs/{}/foreskin", self.id),
491- ));
492- };
493- if f < min {
494- return Err(Problem::new(
495- StatusCode::LENGTH_REQUIRED,
496- "foreskin-insufficient",
497- format!(
498- "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
499- ),
500- )
501- .with("required", min)
502- .with("available", round2(f)));
503- }
504- if f > max {
505- return Err(Problem::new(
506- StatusCode::PAYLOAD_TOO_LARGE,
507- "foreskin-excessive",
508- format!(
509- "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
510- ),
511- )
512- .with("maximum", max)
513- .with("present", round2(f)));
514- }
515- Ok(())
516- }
517-
518- fn realize(&mut self) {
519- let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
520- .into_iter()
521- .partition(|p| p.due <= self.turn);
522- self.pending = waiting;
523- for p in due {
524- self.length += p.length;
525- self.girth += p.girth;
526- }
527- }
528-
529- fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
530- self.length += length;
531- self.girth += girth;
532- if let Some(f) = &mut self.foreskin {
533- *f += foreskin;
534- }
535- }
536-
537- fn rupture(&mut self) -> Problem {
538- let detail = if self.ice_packs > 0 {
539- "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
540- } else {
541- "A vessel has exceeded its rated pressure. The run has ended."
542- };
543- self.end("vascular-event", detail);
544- Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
545- .with("eq_at_failure", self.eq)
546- .with("turn", self.turn)
547- }
548-
549- pub fn jelq(
550- &mut self,
551- technique: Technique,
552- intensity: i32,
553- lube: Option<Lube>,
554- now: Instant,
555- ) -> Result<Outcome, Problem> {
556- self.check_refractory(now)?;
557- if self.branch_pending {
558- return Ok(self.branch_offer());
559- }
560- let Some(profile) = technique.profile() else {
561- return self.utility(technique);
562- };
563- if technique == Technique::Uli && !self.forum_read {
564- return Err(Problem::new(
565- StatusCode::FORBIDDEN,
566- "technique-locked",
567- "This technique is discussed on the forum. You have not been to the forum.",
568- ));
569- }
570- if !self.warmed_up {
571- return Err(Problem::new(
572- StatusCode::PRECONDITION_FAILED,
573- "cold-start",
574- "Warm-up was skipped. The server has noted this.",
575- )
576- .with("technique", Technique::Warmup.name()));
577- }
578- match (profile.lube, lube) {
579- (LubeRule::Required, None) => {
580- return Err(Problem::new(
581- StatusCode::UNSUPPORTED_MEDIA_TYPE,
582- "lubricant-required",
583- format!(
584- "The {} technique requires a lubricant. None was declared.",
585- technique.name()
586- ),
587- )
588- .with("lubricants", json!(LUBRICANTS)));
589- }
590- (LubeRule::Forbidden, Some(_)) => {
591- return Err(Problem::new(
592- StatusCode::UNSUPPORTED_MEDIA_TYPE,
593- "lubricant-declared",
594- "The dry technique was requested. A lubricant was declared.",
595- ));
596- }
597- _ => {}
598- }
599- if let Some((min, max)) = profile.foreskin {
600- self.check_foreskin(min, max)?;
601- }
602-
603- self.touch();
604- self.turn += 1;
605- self.realize();
606- self.stamina -= 1 + i32::from(lube == Some(Lube::Lotion));
607- if self.stamina <= 0 {
608- self.stamina = 0;
609- self.refractory_until = Some(now + REFRACTORY);
610- }
611- self.eq = (self.eq + intensity * 2 - 6).clamp(0, 100);
612- let suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
613- self.suspicion = (self.suspicion + suspicion as u8).min(SUSPICION_LIMIT);
614- if self.suspicion == SUSPICION_LIMIT {
615- self.mom_present = true;
616- }
617- if self.rng.random_ratio(1, 6) {
618- self.observed = true;
619- self.shame += 1;
620- }
621- if self.turn == BRANCH_TURN {
622- self.branch_pending = true;
623- }
624-
625- if self.eq > 95 {
626- self.eq = 0;
627- self.warmed_up = false;
628- self.shame += 5;
629- return Err(Problem::new(
630- StatusCode::TOO_EARLY,
631- "premature",
632- "The floor has ended early. Everyone involved has agreed not to discuss it.",
633- ));
634- }
635- if self.eq < 30 {
636- return Err(Problem::new(
637- StatusCode::NOT_FOUND,
638- "cold",
639- "The resource exists. It is cold.",
640- )
641- .with("eq", self.eq));
642- }
643- self.roll(technique, &profile, intensity, lube)
644- }
645-
646- fn roll(
647- &mut self,
648- technique: Technique,
649- profile: &Profile,
650- intensity: i32,
651- lube: Option<Lube>,
652- ) -> Result<Outcome, Problem> {
653- let natural: i32 = self.rng.random_range(1..=20);
654- let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
655- let modifier = lube.map_or(0, Lube::modifier) + band;
656- let dc = profile.dc + intensity / 3;
657- let total = natural + modifier;
658- let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
659- let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
660-
661- let scale = f64::from(intensity);
662- let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
663- if self.teapot {
664- length *= 0.5;
665- girth *= 1.5;
666- }
667-
668- if natural == 1 {
669- let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
670- if touches_foreskin && self.foreskin.is_some() {
671- self.foreskin = None;
672- self.shame += 25;
673- return Err(timed(
674- Problem::new(
675- StatusCode::GONE,
676- "foreskin-lost",
677- "The technique has removed the foreskin. It will not be returning.",
678- )
679- .header(
680- header::CONTENT_LOCATION,
681- format!("/runs/{}/foreskin", self.id),
682- ),
683- ));
684- }
685- if self.eq > 70 {
686- return Err(timed(self.rupture()));
687- }
688- if technique == Technique::Stretch && !self.teapot {
689- self.teapot = true;
690- self.shame += 5;
691- return Err(timed(Problem::new(
692- StatusCode::IM_A_TEAPOT,
693- "teapot",
694- "The subject is now short and stout. Length gains are halved for the rest of the run.",
695- )));
696- }
697- return Err(timed(not_extended()));
698- }
699- if self.eq > 90 && total < dc {
700- return Err(timed(self.rupture()));
701- }
702- if natural == 20 {
703- self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
704- return Ok(Outcome::new(
705- StatusCode::CREATED,
706- Some(json!({
707- "detail": "A permanent gain has been created.",
708- "length": round3(length * 2.0),
709- "girth": round3(girth * 2.0),
710- })),
711- )
712- .header(header::LOCATION, format!("/runs/{}/length", self.id))
713- .header(SERVER_TIMING, timing));
714- }
715- if total < dc - 3 {
716- return Err(timed(not_extended()));
717- }
718- if total < dc {
719- return Ok(Outcome::new(StatusCode::NO_CONTENT, None).header(SERVER_TIMING, timing));
720- }
721-
722- let outcome = if self.eq < 50 {
723- self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
724- Outcome::new(
725- StatusCode::PARTIAL_CONTENT,
726- Some(json!({
727- "detail": "Partial gains were applied. EQ was below the band.",
728- "length": round3(length / 2.0),
729- "girth": round3(girth / 2.0),
730- })),
731- )
732- } else if total >= dc + 5 {
733- self.gain(length, girth, profile.foreskin_growth);
734- Outcome::new(
735- StatusCode::OK,
736- Some(json!({
737- "detail": "Gains were applied.",
738- "length": round3(length),
739- "girth": round3(girth),
740- })),
741- )
742- } else {
743- let eta = self.rng.random_range(1..=4);
744- self.pending.push(Pending {
745- due: self.turn + eta,
746- length,
747- girth,
748- });
749- self.gain(0.0, 0.0, profile.foreskin_growth);
750- Outcome::new(
751- StatusCode::ACCEPTED,
752- Some(json!({
753- "detail": "Gains have been queued. They may arrive.",
754- "eta_turns": eta,
755- })),
756- )
757- };
758- Ok(outcome.header(SERVER_TIMING, timing))
759- }
760-
761- fn utility(&mut self, technique: Technique) -> Result<Outcome, Problem> {
762- if technique == Technique::Warmup {
763- self.touch();
764- self.warmed_up = true;
765- self.eq = (self.eq + 25).min(100);
766- return Ok(Outcome::new(
767- StatusCode::OK,
768- Some(json!({ "detail": "Warmed up. The towel has cooled." })),
769- ));
770- }
771- if self.ice_packs == 0 {
772- return Err(Problem::new(
773- StatusCode::FAILED_DEPENDENCY,
774- "no-ice",
775- "The ice pack has been used. There was one ice pack.",
776- ));
777- }
778- self.touch();
779- self.ice_packs -= 1;
780- self.eq = (self.eq - 40).max(0);
781- Ok(Outcome::new(
782- StatusCode::OK,
783- Some(json!({ "detail": "EQ has been reduced. The ice pack is gone." })),
784- ))
785- }
786-
787- fn branch_offer(&self) -> Outcome {
788- let forum = format!("/runs/{}/branches/forum", self.id);
789- let close = format!("/runs/{}/branches/close-tab", self.id);
790- Outcome::new(
791- StatusCode::MULTIPLE_CHOICES,
792- Some(json!({
793- "detail": "A browser tab is open. It has been open for some time.",
794- "choices": [
795- { "href": forum, "method": "POST", "title": "Read the forum" },
796- { "href": close, "method": "POST", "title": "Close the tab" },
797- ],
798- })),
799- )
800- .header(
801- header::LINK,
802- format!(
803- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
804- ),
805- )
806- }
807-
808- pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
809- if !self.branch_pending {
810- return Err(Problem::new(
811- StatusCode::CONFLICT,
812- "no-decision-pending",
813- "There is no decision pending.",
814- ));
815- }
816- let (location, detail) = match choice {
817- "forum" => {
818- self.forum_read = true;
819- self.shame += 3;
820- (
821- format!("/runs/{}/techniques", self.id),
822- "The thread is from 2009. A technique called uli is discussed at length.",
823- )
824- }
825- "close-tab" => {
826- self.suspicion = 0;
827- (
828- format!("/runs/{}", self.id),
829- "The tab is closed. Nobody saw it.",
830- )
831- }
832- _ => {
833- return Err(Problem::new(
834- StatusCode::NOT_FOUND,
835- "no-such-choice",
836- "That was not one of the choices.",
837- ));
838- }
839- };
840- self.branch_pending = false;
841- self.touch();
842- Ok(
843- Outcome::new(StatusCode::SEE_OTHER, Some(json!({ "detail": detail })))
844- .header(header::LOCATION, location),
845- )
846- }
847-
848- pub fn search_drawer(&mut self) -> Outcome {
849- if self.ruler {
850- return Outcome::new(StatusCode::NO_CONTENT, None);
851- }
852- self.ruler = true;
853- self.shame += 10;
854- self.touch();
855- Outcome::new(
856- StatusCode::FOUND,
857- Some(json!({
858- "detail": "You now know. This is the first honest measurement of the run.",
859- })),
860- )
861- .header(
862- header::LOCATION,
863- format!("/runs/{}/inventory/ruler", self.id),
864- )
865- }
866-
867- pub fn put(&mut self) -> Outcome {
868- if self.put_applied {
869- return Outcome::new(StatusCode::NO_CONTENT, None);
870- }
871- self.put_applied = true;
872- self.length += 0.01;
873- self.touch();
874- Outcome::new(StatusCode::OK, Some(json!({ "detail": "Applied." })))
875- }
876-
877- pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
878- for op in ops {
879- if op.op != "test" {
880- return Err(Problem::new(
881- StatusCode::UNPROCESSABLE_ENTITY,
882- "wishful-thinking",
883- "The server accepts only the truth.",
884- )
885- .with("op", op.op.clone()));
886- }
887- let actual = match op.path.as_str() {
888- "/length" => self.length,
889- "/girth" => self.girth,
890- "/foreskin" => self.foreskin.unwrap_or(0.0),
891- _ => {
892- return Err(Problem::new(
893- StatusCode::UNPROCESSABLE_ENTITY,
894- "no-such-measurement",
895- "There is no such measurement.",
896- )
897- .with("path", op.path.clone()));
898- }
899- };
900- let Some(claimed) = op.value.as_f64() else {
901- return Err(Problem::new(
902- StatusCode::BAD_REQUEST,
903- "malformed-patch",
904- "A test requires a numeric value.",
905- ));
906- };
907- if op.path == "/length" && claimed >= self.length + 1.0 {
908- self.shame += 2;
909- self.touch();
910- return Err(Problem::new(
911- StatusCode::BAD_REQUEST,
912- "exaggeration",
913- "The server has measured. Lying is not a technique.",
914- ));
915- }
916- if tenths(claimed) != tenths(actual) {
917- self.shame += 1;
918- self.touch();
919- return Err(
920- Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
921- .with("path", op.path.clone()),
922- );
923- }
924- }
925- Ok(Outcome::new(
926- StatusCode::OK,
927- Some(json!({ "detail": "Correct." })),
928- ))
929- }
930-
931- pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
932- if self.foreskin.is_none() {
933- return Err(Problem::new(
934- StatusCode::GONE,
935- "foreskin-gone",
936- "The foreskin is gone. It will not be returning.",
937- ));
938- }
939- let Some(if_match) = if_match else {
940- return Err(Problem::new(
941- StatusCode::PRECONDITION_REQUIRED,
942- "confirmation-required",
943- "The server requires you to be sure. Send If-Match.",
944- ));
945- };
946- if !etag_matches(if_match, &self.etag()) {
947- return Err(Problem::new(
948- StatusCode::PRECONDITION_FAILED,
949- "stale-foreskin",
950- "The foreskin has changed since you last looked at it.",
951- ));
952- }
953- self.foreskin = None;
954- self.shame += 20;
955- self.touch();
956- Ok(Outcome::new(StatusCode::NO_CONTENT, None))
957- }
958-
959- pub fn diagnose(
960- &mut self,
961- authorization: Option<&str>,
962- if_none_match: Option<&str>,
963- ) -> Result<Outcome, Problem> {
964- let remaining = APPOINTMENT_TURN.saturating_sub(self.turn);
965- if remaining > 0 {
966- return Err(Problem::new(
967- StatusCode::TOO_EARLY,
968- "no-appointment",
969- format!("The clinic's next available appointment is in {remaining} turns."),
970- ));
971- }
972- let intake = Problem::new(
973- StatusCode::UNAUTHORIZED,
974- "intake",
975- "The clinic requires authorization.",
976- );
977- let Some(authorization) = authorization else {
978- return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
979- };
980- let honest = parse_credentials(authorization, "bearer")
981- .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
982- if !honest {
983- let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];
984- self.intake_attempts += 1;
985- return Err(intake.header(
986- header::WWW_AUTHENTICATE,
987- format!(
988- "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""
989- ),
990- ));
991- }
992-
993- let etag = self.origin_etag.clone();
994- if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {
995- self.cured = true;
996- return Ok(Outcome::new(StatusCode::NOT_MODIFIED, None)
997- .header(header::ETAG, etag)
998- .header(X_DIAGNOSIS, "normal"));
999- }
1000- if !self.measured {
1001- self.measured = true;
1002- if !self.ruler {
1003- self.shame += 15;
1004- }
1005- }
1006- Ok(Outcome::new(
1007- StatusCode::OK,
1008- Some(json!({
1009- "diagnosis": "within normal range",
1010- "measured_length": round2(self.length),
1011- "you_said": round2(self.self_reported()),
1012- "prescription": "Please stop.",
1013- })),
1014- )
1015- .header(header::ETAG, etag))
1016- }
1017-}
1018-
1019-pub fn etag_matches(condition: &str, etag: &str) -> bool {
1020- condition
1021- .split(',')
1022- .map(str::trim)
1023- .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
1024-}
1025-
1026-pub fn shaft(mm: usize) -> String {
1027- format!("8{}D", "=".repeat(mm.saturating_sub(2)))
1028-}
Added file: src/game/catalog.rs+248 additions
@@ -0,0 +1,248 @@
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; 10] = [
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: "ice-pack",
240+ price: 3,
241+ description: "Cold.",
242+ },
243+ ShopItem {
244+ slug: "cage-key",
245+ price: 15,
246+ description: "Pump-Bro does not ask.",
247+ },
248+];
Added file: src/game/encounters.rs+721 additions
@@ -0,0 +1,721 @@
1+use std::collections::HashMap;
2+
3+use axum::http::{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, parse_credentials, round2,
10+ tenths,
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+
21+const INTAKE: [&str; 3] = [
22+ "The clinic does not accept excuses.",
23+ "The clinic accepts one token. You have not been carrying it.",
24+ "Be honest.",
25+];
26+
27+const SPREADSHEET: &str = "\
28+date,length,girth,technique,notes,honest?
29+2007-01-03,7.50,5.50,wet,baseline,no
30+2007-06-11,7.50,5.50,wet,feeling fuller,no
31+2008-01-02,7.50,5.50,dry,new routine,no
32+2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
33+2009-06-14,7.50,5.50,wet,progress is slow but real,no
34+";
35+
36+pub fn forum_thread() -> Value {
37+ json!({
38+ "title": "The definitive jelqing FAQ (updated 2009)",
39+ "posts": [
40+ { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
41+ { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },
42+ { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },
43+ { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },
44+ { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },
45+ { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },
46+ { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },
47+ ],
48+ })
49+}
50+
51+#[derive(Deserialize)]
52+pub struct PatchOp {
53+ pub op: String,
54+ pub path: String,
55+ #[serde(default)]
56+ pub value: Value,
57+}
58+
59+fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
60+ let (scheme, rest) = authorization.trim().split_once(' ')?;
61+ if !scheme.eq_ignore_ascii_case("digest") {
62+ return None;
63+ }
64+ Some(
65+ rest.split(',')
66+ .filter_map(|pair| {
67+ let (key, value) = pair.split_once('=')?;
68+ Some((
69+ key.trim().to_ascii_lowercase(),
70+ value.trim().trim_matches('"').to_owned(),
71+ ))
72+ })
73+ .collect(),
74+ )
75+}
76+
77+fn exaggeration() -> Problem {
78+ Problem::new(
79+ StatusCode::BAD_REQUEST,
80+ "exaggeration",
81+ "The server has measured. Lying is not a technique.",
82+ )
83+}
84+
85+impl Run {
86+ pub fn excuses(&self) -> Vec<&'static str> {
87+ if self.floor.number() < Floor::Grandmas.number() {
88+ return EXCUSES.to_vec();
89+ }
90+ EXCUSES
91+ .into_iter()
92+ .chain(THANKSGIVING_EXCUSES)
93+ .filter(|e| !self.heard.iter().any(|h| h == e))
94+ .collect()
95+ }
96+
97+ pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
98+ let digest = self.floor == Floor::Grandmas;
99+ let challenge_value = if digest {
100+ format!("Digest realm=\"household\", nonce=\"{}\"", self.nonce)
101+ } else {
102+ "Excuse realm=\"household\"".to_owned()
103+ };
104+ let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);
105+
106+ let Some(authorization) = authorization else {
107+ return Err(challenge(
108+ Problem::new(
109+ StatusCode::UNAUTHORIZED,
110+ "someone-at-the-door",
111+ format!("Someone is at the door. {}", self.mom_errand),
112+ )
113+ .with("inventory", format!("/runs/{}/inventory", self.id)),
114+ ));
115+ };
116+
117+ let excuse = if digest {
118+ let Some(params) = digest_params(authorization) else {
119+ return Err(challenge(Problem::new(
120+ StatusCode::UNAUTHORIZED,
121+ "digest-required",
122+ "Mom has started using Digest authentication.",
123+ )));
124+ };
125+ if params.get("nonce") != Some(&self.nonce) {
126+ return Err(challenge(Problem::new(
127+ StatusCode::UNAUTHORIZED,
128+ "stale-nonce",
129+ "That was a different conversation.",
130+ )));
131+ }
132+ params.get("username").cloned().unwrap_or_default()
133+ } else {
134+ parse_credentials(authorization, "excuse")
135+ .unwrap_or_default()
136+ .to_owned()
137+ };
138+ let excuse = excuse.to_ascii_lowercase();
139+
140+ if TRUTHS.contains(&excuse.as_str()) {
141+ self.shame += 10;
142+ self.leave_door();
143+ return Ok(());
144+ }
145+ if digest && self.heard.contains(&excuse) {
146+ self.shame += 3;
147+ self.touch();
148+ return Err(challenge(Problem::new(
149+ StatusCode::UNAUTHORIZED,
150+ "excuse-replayed",
151+ "Mom has heard that one before. She would have preferred the truth.",
152+ )));
153+ }
154+ if self.excuses().contains(&excuse.as_str()) {
155+ self.heard.push(excuse);
156+ self.leave_door();
157+ return Ok(());
158+ }
159+ self.shame += 3;
160+ self.touch();
161+ let detail = if digest {
162+ "Mom has not heard that one before. She does not believe it. She would have preferred the truth."
163+ } else {
164+ "Mom has not heard that one before. She does not believe it."
165+ };
166+ Err(challenge(Problem::new(
167+ StatusCode::UNAUTHORIZED,
168+ "excuse-rejected",
169+ detail,
170+ )))
171+ }
172+
173+ pub(super) fn errand(&mut self, summoned: bool) -> &'static str {
174+ if summoned {
175+ return "She heard a noise.";
176+ }
177+ let errands: &[&'static str] = if self.floor == Floor::Grandmas {
178+ &[
179+ "Grandma would like to see you.",
180+ "Dinner is in ten minutes.",
181+ "She needs help with the folding table.",
182+ "An uncle has asked where you went.",
183+ ]
184+ } else {
185+ &[
186+ "She has brought laundry.",
187+ "She wants to know if you have eaten.",
188+ "She needs the wifi password.",
189+ "She would like to talk about your future.",
190+ ]
191+ };
192+ errands[self.rng.random_range(0..errands.len())]
193+ }
194+
195+ fn leave_door(&mut self) {
196+ self.mom_present = false;
197+ self.suspicion = 0;
198+ self.touch();
199+ }
200+
201+ pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {
202+ let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)
203+ && self.rng.random_ratio(u32::from(self.suspicion) + 1, 32);
204+ if self.suspicion < SUSPICION_LIMIT && !wanders_in {
205+ return Ok(());
206+ }
207+ match self.floor {
208+ Floor::Bathroom => {
209+ self.suspicion = 0;
210+ self.touch();
211+ if self.items.door_locks > 0 {
212+ self.items.door_locks -= 1;
213+ return Ok(());
214+ }
215+ self.roommate_told = true;
216+ Err(Problem::new(
217+ StatusCode::CONFLICT,
218+ "roommate",
219+ "The roommate needs the bathroom. He will mention this to people.",
220+ ))
221+ }
222+ Floor::LockerRoom => {
223+ self.suspicion = 0;
224+ self.shame += 5;
225+ self.touch();
226+ Err(Problem::new(
227+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
228+ "gym-staff",
229+ "Gym staff have been notified. The server cannot serve this request for legal reasons.",
230+ ))
231+ }
232+ Floor::Hotel => {
233+ self.suspicion = 0;
234+ self.touch();
235+ if self.items.dnd_sign {
236+ return Ok(());
237+ }
238+ self.shame += 3;
239+ Err(Problem::new(
240+ StatusCode::UNAUTHORIZED,
241+ "housekeeping",
242+ "Housekeeping has entered. Housekeeping accepts no excuses.",
243+ )
244+ .header(header::WWW_AUTHENTICATE, "Excuse realm=\"housekeeping\""))
245+ }
246+ _ => Ok(()),
247+ }
248+ }
249+
250+ pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
251+ self.drawer_searches += 1;
252+ self.touch();
253+ let first = self.drawer_searches == 1;
254+ let id = self.id.clone();
255+ let found = |slug: &str, detail: &str| {
256+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))
257+ .header(header::LOCATION, format!("/runs/{id}/inventory/{slug}"))
258+ };
259+ match self.floor {
260+ Floor::Bedroom if first && !self.items.ruler => {
261+ self.items.ruler = true;
262+ self.shame += 10;
263+ Ok(found(
264+ "ruler",
265+ "You now know. This is the first honest measurement of the run.",
266+ ))
267+ }
268+ Floor::Bathroom if first => {
269+ self.items.door_locks += 1;
270+ Ok(found("door-lock", "A door lock. It works once."))
271+ }
272+ Floor::LockerRoom => {
273+ self.shame += 3;
274+ Err(Problem::new(
275+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
276+ "not-your-locker",
277+ "That locker belongs to Greg. The server cannot serve this request for legal reasons.",
278+ ))
279+ }
280+ Floor::Grandmas if first => {
281+ self.items.cage = true;
282+ self.shame += 5;
283+ Ok(found(
284+ "chastity-cage",
285+ "A small box. Inside was a cage. It is now on. The key is not in the box.",
286+ ))
287+ }
288+ Floor::Grandmas if self.items.cage => {
289+ self.items.cage = false;
290+ Ok(Outcome::json(
291+ StatusCode::OK,
292+ json!({ "detail": "The key was under where the box had been. The cage is off." }),
293+ ))
294+ }
295+ Floor::Hotel if first => {
296+ self.items.dnd_sign = true;
297+ self.items.ice_packs += 2;
298+ Ok(found(
299+ "do-not-disturb-sign",
300+ "A Do Not Disturb sign, and two ice packs from the machine down the hall.",
301+ ))
302+ }
303+ Floor::Forum if first => {
304+ self.items.spreadsheet = true;
305+ Ok(found(
306+ "spreadsheet",
307+ "Your own spreadsheet. Every line is flat.",
308+ ))
309+ }
310+ Floor::Clinic => Err(Problem::new(
311+ StatusCode::FORBIDDEN,
312+ "doctors-drawer",
313+ "That is the doctor's drawer.",
314+ )),
315+ _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),
316+ }
317+ }
318+
319+ pub fn shop_view(&self) -> Result<Value, Problem> {
320+ if !self.floor.shop_open() {
321+ return Err(pump_bro_absent());
322+ }
323+ let stock: Vec<Value> = SHOP
324+ .iter()
325+ .map(|item| {
326+ json!({
327+ "item": item.slug,
328+ "price": item.price,
329+ "description": item.description,
330+ "href": format!("/runs/{}/shop/{}", self.id, item.slug),
331+ "method": "POST",
332+ })
333+ })
334+ .collect();
335+ Ok(json!({ "merchant": "Pump-Bro", "cash": self.cash, "stock": stock }))
336+ }
337+
338+ pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {
339+ if !self.floor.shop_open() {
340+ return Err(pump_bro_absent());
341+ }
342+ let Some(item) = SHOP.iter().find(|i| i.slug == slug) else {
343+ return Err(Problem::new(
344+ StatusCode::NOT_FOUND,
345+ "not-for-sale",
346+ "Pump-Bro does not sell that. He knows a guy.",
347+ ));
348+ };
349+ if slug == "pump" {
350+ return Err(Problem::new(
351+ StatusCode::UPGRADE_REQUIRED,
352+ "upsell",
353+ "This pump requires an upgrade.",
354+ )
355+ .header(header::UPGRADE, "pump-pro/2.0")
356+ .with("upgrade", format!("/runs/{}/shop/pump-pro", self.id)));
357+ }
358+ if self.cash < item.price {
359+ return Err(Problem::new(
360+ StatusCode::PAYMENT_REQUIRED,
361+ "insufficient-funds",
362+ "Payment is required. RFC 9110 has reserved this status for future use since 1997. The future is here and you cannot afford it.",
363+ )
364+ .with("cash", self.cash)
365+ .with("price", item.price));
366+ }
367+ self.cash -= item.price;
368+ self.touch();
369+ let items = &mut self.items;
370+ match slug {
371+ "supplement" => {
372+ items.supplements += 1;
373+ return Err(Problem::new(
374+ StatusCode::GATEWAY_TIMEOUT,
375+ "still-shipping",
376+ "The supplement is still shipping. You have been charged. It may arrive.",
377+ )
378+ .with("cash", self.cash));
379+ }
380+ "pump-pro" => items.pump_pro = true,
381+ "extender" => items.extender = true,
382+ "cock-ring" => items.ring = true,
383+ "door-lock" => items.door_locks += 1,
384+ "do-not-disturb-sign" => items.dnd_sign = true,
385+ "ruler" => items.ruler = true,
386+ "ice-pack" => items.ice_packs += 1,
387+ _ => items.cage = false,
388+ }
389+ Ok(Outcome::json(
390+ StatusCode::CREATED,
391+ json!({ "detail": "Purchased. No refunds.", "cash": self.cash }),
392+ )
393+ .header(
394+ header::LOCATION,
395+ format!("/runs/{}/inventory/{slug}", self.id),
396+ ))
397+ }
398+
399+ pub fn drop_item(&mut self, slug: &str) -> Result<Outcome, Problem> {
400+ match slug {
401+ "extender" => self.items.extender = false,
402+ "cock-ring" => self.items.ring = false,
403+ "chastity-cage" => {
404+ return Err(Problem::new(
405+ StatusCode::LOCKED,
406+ "caged",
407+ "The cage is locked.",
408+ ));
409+ }
410+ _ => {
411+ return Err(Problem::new(
412+ StatusCode::FORBIDDEN,
413+ "cannot-be-dropped",
414+ "That cannot be dropped.",
415+ ));
416+ }
417+ }
418+ self.touch();
419+ Ok(Outcome::empty(StatusCode::NO_CONTENT))
420+ }
421+
422+ pub(super) fn branch_offer(&self) -> Outcome {
423+ let forum = format!("/runs/{}/branches/forum", self.id);
424+ let close = format!("/runs/{}/branches/close-tab", self.id);
425+ Outcome::json(
426+ StatusCode::MULTIPLE_CHOICES,
427+ json!({
428+ "detail": "A browser tab is open. It has been open for some time.",
429+ "choices": [
430+ { "href": forum, "method": "POST", "title": "Read the forum" },
431+ { "href": close, "method": "POST", "title": "Close the tab" },
432+ ],
433+ }),
434+ )
435+ .header(
436+ header::LINK,
437+ format!(
438+ "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
439+ ),
440+ )
441+ }
442+
443+ pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
444+ if !self.branch_pending {
445+ return Err(Problem::new(
446+ StatusCode::CONFLICT,
447+ "no-decision-pending",
448+ "There is no decision pending.",
449+ ));
450+ }
451+ let (location, detail) = match choice {
452+ "forum" => {
453+ self.forum_read = true;
454+ self.shame += 3;
455+ (
456+ format!("/runs/{}/techniques", self.id),
457+ "The thread is from 2009. A technique called uli is discussed at length.",
458+ )
459+ }
460+ "close-tab" => {
461+ self.suspicion = 0;
462+ (
463+ format!("/runs/{}", self.id),
464+ "The tab is closed. Nobody saw it.",
465+ )
466+ }
467+ _ => {
468+ return Err(Problem::new(
469+ StatusCode::NOT_FOUND,
470+ "no-such-choice",
471+ "That was not one of the choices.",
472+ ));
473+ }
474+ };
475+ self.branch_pending = false;
476+ self.touch();
477+ Ok(
478+ Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))
479+ .header(header::LOCATION, location),
480+ )
481+ }
482+
483+ pub fn put(&mut self) -> Outcome {
484+ if self.put_applied {
485+ return Outcome::empty(StatusCode::NO_CONTENT);
486+ }
487+ self.put_applied = true;
488+ self.length += 0.01;
489+ self.touch();
490+ Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
491+ }
492+
493+ pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
494+ for op in ops {
495+ if op.op != "test" {
496+ return Err(Problem::new(
497+ StatusCode::UNPROCESSABLE_ENTITY,
498+ "wishful-thinking",
499+ "The server accepts only the truth.",
500+ )
501+ .with("op", op.op.clone()));
502+ }
503+ let actual = match op.path.as_str() {
504+ "/length" => self.current_length(),
505+ "/girth" => self.girth,
506+ "/foreskin" => self.foreskin.unwrap_or(0.0),
507+ _ => {
508+ return Err(Problem::new(
509+ StatusCode::UNPROCESSABLE_ENTITY,
510+ "no-such-measurement",
511+ "There is no such measurement.",
512+ )
513+ .with("path", op.path.clone()));
514+ }
515+ };
516+ let Some(claimed) = op.value.as_f64() else {
517+ return Err(Problem::new(
518+ StatusCode::BAD_REQUEST,
519+ "malformed-patch",
520+ "A test requires a numeric value.",
521+ ));
522+ };
523+ if op.path == "/length" && claimed >= actual + 1.0 {
524+ self.shame += 2;
525+ self.touch();
526+ return Err(exaggeration());
527+ }
528+ if tenths(claimed) != tenths(actual) {
529+ self.shame += 1;
530+ self.touch();
531+ return Err(
532+ Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
533+ .with("path", op.path.clone()),
534+ );
535+ }
536+ }
537+ Ok(Outcome::json(
538+ StatusCode::OK,
539+ json!({ "detail": "Correct." }),
540+ ))
541+ }
542+
543+ pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
544+ if self.foreskin.is_none() {
545+ return Err(Problem::new(
546+ StatusCode::GONE,
547+ "foreskin-gone",
548+ "The foreskin is gone. It will not be returning.",
549+ ));
550+ }
551+ let Some(if_match) = if_match else {
552+ return Err(Problem::new(
553+ StatusCode::PRECONDITION_REQUIRED,
554+ "confirmation-required",
555+ "The server requires you to be sure. Send If-Match.",
556+ ));
557+ };
558+ if !super::etag_matches(if_match, &self.etag()) {
559+ return Err(Problem::new(
560+ StatusCode::PRECONDITION_FAILED,
561+ "stale-foreskin",
562+ "The foreskin has changed since you last looked at it.",
563+ ));
564+ }
565+ self.foreskin = None;
566+ self.shame += 20;
567+ self.touch();
568+ Ok(Outcome::empty(StatusCode::NO_CONTENT))
569+ }
570+
571+ pub fn set_grip(&mut self, grip: Grip) {
572+ self.grip = grip;
573+ self.touch();
574+ }
575+
576+ pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {
577+ if self.floor != Floor::Hotel {
578+ return Err(Problem::new(
579+ StatusCode::NOT_FOUND,
580+ "no-portal",
581+ "There is no captive portal here. There is only the regular kind of captivity.",
582+ ));
583+ }
584+ self.wifi_accepted = true;
585+ self.touch();
586+ Ok(Outcome::json(
587+ StatusCode::SEE_OTHER,
588+ json!({ "detail": "Connected. The hotel does not want to know." }),
589+ )
590+ .header(header::LOCATION, format!("/runs/{}", self.id)))
591+ }
592+
593+ pub fn elder(
594+ &mut self,
595+ proxy_authorization: Option<&str>,
596+ if_modified_since: Option<&str>,
597+ ) -> Result<Outcome, Problem> {
598+ if self.floor != Floor::Forum {
599+ return Err(Problem::new(
600+ StatusCode::NOT_FOUND,
601+ "elder-absent",
602+ "The Elder only posts on the forum.",
603+ ));
604+ }
605+ if !self.verified {
606+ let verification = |detail: &str| {
607+ Problem::new(
608+ StatusCode::PROXY_AUTHENTICATION_REQUIRED,
609+ "verification-required",
610+ detail,
611+ )
612+ .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")
613+ };
614+ let Some(credentials) = proxy_authorization else {
615+ return Err(verification("The forum requires verification."));
616+ };
617+ let Some(claimed) =
618+ parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())
619+ else {
620+ return Err(verification("The forum requires verification, in inches."));
621+ };
622+ let actual = self.current_length();
623+ if claimed >= actual + 1.0 {
624+ self.shame += 2;
625+ self.touch();
626+ return Err(exaggeration());
627+ }
628+ if tenths(claimed) != tenths(actual) {
629+ self.shame += 1;
630+ self.touch();
631+ return Err(verification("Verification failed. The forum can tell."));
632+ }
633+ self.verified = true;
634+ self.touch();
635+ }
636+
637+ let last_modified =
638+ httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");
639+ let current = if_modified_since
640+ .and_then(|d| httpdate::parse_http_date(d).ok())
641+ .is_some_and(|since| since >= last_modified);
642+ if current {
643+ self.elder_beaten = true;
644+ self.touch();
645+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
646+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));
647+ }
648+ Ok(
649+ Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
650+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED),
651+ )
652+ }
653+
654+ pub fn diagnose(
655+ &mut self,
656+ authorization: Option<&str>,
657+ if_none_match: Option<&str>,
658+ ) -> Result<Outcome, Problem> {
659+ if self.floor != Floor::Clinic {
660+ let floors = Floor::Clinic.number() - self.floor.number();
661+ return Err(Problem::new(
662+ StatusCode::TOO_EARLY,
663+ "no-appointment",
664+ format!("The clinic's next available appointment is {floors} floors away."),
665+ ));
666+ }
667+ let intake = Problem::new(
668+ StatusCode::UNAUTHORIZED,
669+ "intake",
670+ "The clinic requires authorization.",
671+ );
672+ let Some(authorization) = authorization else {
673+ return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
674+ };
675+ let honest = parse_credentials(authorization, "bearer")
676+ .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
677+ if !honest {
678+ let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];
679+ self.intake_attempts += 1;
680+ return Err(intake.header(
681+ header::WWW_AUTHENTICATE,
682+ format!(
683+ "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""
684+ ),
685+ ));
686+ }
687+
688+ self.peyronie = false;
689+ let etag = self.origin_etag.clone();
690+ if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {
691+ self.cured = true;
692+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
693+ .header(header::ETAG, etag)
694+ .header(X_DIAGNOSIS, "normal"));
695+ }
696+ if !self.measured {
697+ self.measured = true;
698+ if !self.items.ruler {
699+ self.shame += 15;
700+ }
701+ }
702+ Ok(Outcome::json(
703+ StatusCode::OK,
704+ json!({
705+ "diagnosis": "within normal range",
706+ "measured_length": round2(self.length),
707+ "you_said": round2(self.self_reported()),
708+ "prescription": "Please stop.",
709+ }),
710+ )
711+ .header(header::ETAG, etag))
712+ }
713+}
714+
715+fn pump_bro_absent() -> Problem {
716+ Problem::new(
717+ StatusCode::NOT_FOUND,
718+ "pump-bro-absent",
719+ "Pump-Bro is not here.",
720+ )
721+}
Added file: src/game/jelq.rs+603 additions
@@ -0,0 +1,603 @@
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+
18+impl Run {
19+ pub fn jelq(
20+ &mut self,
21+ techniques: &[Technique],
22+ intensity: i32,
23+ lube: Option<Lube>,
24+ now: Instant,
25+ ) -> Result<Outcome, Problem> {
26+ self.check_refractory(now)?;
27+ if self.branch_pending {
28+ return Ok(self.branch_offer());
29+ }
30+ match self.floor {
31+ Floor::Forum => {
32+ return Ok(Outcome::json(
33+ StatusCode::SEE_OTHER,
34+ json!({ "detail": "Refer to the forum." }),
35+ )
36+ .header(header::LOCATION, FORUM_THREAD));
37+ }
38+ Floor::Clinic => {
39+ return Err(Problem::new(
40+ StatusCode::METHOD_NOT_ALLOWED,
41+ "not-in-the-clinic",
42+ "JELQ is not performed in the clinic.",
43+ )
44+ .header(header::ALLOW, CLINIC_ALLOW));
45+ }
46+ _ => {}
47+ }
48+ if self.items.cage {
49+ return Err(Problem::new(
50+ StatusCode::LOCKED,
51+ "caged",
52+ "The cage is locked. The key is not in the box.",
53+ ));
54+ }
55+ match *techniques {
56+ [technique] => self.single(technique, intensity, lube, now),
57+ [a, b] => self.combo(a, b, intensity, lube, now),
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 single(
67+ &mut self,
68+ technique: Technique,
69+ intensity: i32,
70+ lube: Option<Lube>,
71+ now: Instant,
72+ ) -> Result<Outcome, Problem> {
73+ match technique {
74+ Technique::Warmup => return self.warm_up(),
75+ Technique::Ice => return self.ice(),
76+ Technique::Supplement => return self.supplement(),
77+ Technique::Edge => {}
78+ _ => self.edges = 0,
79+ }
80+ self.prepare(technique, lube)?;
81+ self.check_storage()?;
82+ self.floor_enemy()?;
83+ match technique {
84+ Technique::Edge => return self.edge(now),
85+ Technique::Pump => return self.pump(now),
86+ _ => {}
87+ }
88+ let profile = technique.profile().expect("training technique");
89+ self.commit(intensity, lube, now, 1);
90+ self.hazards(intensity)?;
91+ let result = self.roll(technique, &profile, intensity, lube);
92+ Ok(self.gossip(result?))
93+ }
94+
95+ fn combo(
96+ &mut self,
97+ a: Technique,
98+ b: Technique,
99+ intensity: i32,
100+ lube: Option<Lube>,
101+ now: Instant,
102+ ) -> Result<Outcome, Problem> {
103+ let (Some(first), Some(second)) = (a.profile(), b.profile()) else {
104+ return Err(Problem::new(
105+ StatusCode::BAD_REQUEST,
106+ "combo-invalid",
107+ "Only training techniques can be combined.",
108+ ));
109+ };
110+ if a == b {
111+ return Err(Problem::new(
112+ StatusCode::BAD_REQUEST,
113+ "combo-invalid",
114+ "A technique cannot be combined with itself. The server has checked.",
115+ ));
116+ }
117+ self.edges = 0;
118+ self.prepare(a, lube)?;
119+ self.prepare(b, lube)?;
120+ self.check_storage()?;
121+ self.floor_enemy()?;
122+ self.commit(intensity, lube, now, 2);
123+ self.hazards(intensity)?;
124+
125+ let mut statuses = Vec::new();
126+ for (technique, profile) in [(a, first), (b, second)] {
127+ let result = self.roll(technique, &profile, intensity, lube);
128+ if self.ended.is_some() {
129+ return result;
130+ }
131+ let status = match &result {
132+ Ok(outcome) => outcome.status(),
133+ Err(problem) => problem.status(),
134+ };
135+ statuses.push((technique, status));
136+ }
137+
138+ let mut xml = String::from(
139+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
140+ );
141+ for (technique, status) in statuses {
142+ let _ = write!(
143+ xml,
144+ " <D:response>\n <D:href>/runs/{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",
145+ self.id,
146+ technique.name(),
147+ status.as_u16(),
148+ status.canonical_reason().unwrap_or_default(),
149+ );
150+ }
151+ xml.push_str(
152+ " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
153+ );
154+ Ok(Outcome::raw(
155+ StatusCode::MULTI_STATUS,
156+ "application/xml; charset=utf-8",
157+ xml,
158+ ))
159+ }
160+
161+ fn prepare(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
162+ if technique == Technique::Uli && !self.forum_read {
163+ return Err(Problem::new(
164+ StatusCode::FORBIDDEN,
165+ "technique-locked",
166+ "This technique is discussed on the forum. You have not been to the forum.",
167+ ));
168+ }
169+ if !self.warmed_up {
170+ return Err(Problem::new(
171+ StatusCode::PRECONDITION_FAILED,
172+ "cold-start",
173+ "Warm-up was skipped. The server has noted this.",
174+ )
175+ .with("technique", Technique::Warmup.name()));
176+ }
177+ if technique == Technique::Pump && !self.items.pump_pro {
178+ return Err(Problem::new(
179+ StatusCode::FAILED_DEPENDENCY,
180+ "no-pump",
181+ "The pump technique depends on a pump.",
182+ ));
183+ }
184+ let Some(profile) = technique.profile() else {
185+ return Ok(());
186+ };
187+ match (profile.lube, lube) {
188+ (LubeRule::Required, None) => {
189+ return Err(Problem::new(
190+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
191+ "lubricant-required",
192+ format!(
193+ "The {} technique requires a lubricant. None was declared.",
194+ technique.name()
195+ ),
196+ )
197+ .with("lubricants", json!(LUBRICANTS)));
198+ }
199+ (LubeRule::Forbidden, Some(_)) => {
200+ return Err(Problem::new(
201+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
202+ "lubricant-declared",
203+ "The dry technique was requested. A lubricant was declared.",
204+ ));
205+ }
206+ _ => {}
207+ }
208+ if let Some((min, max)) = profile.foreskin {
209+ self.check_foreskin(min, max)?;
210+ }
211+ Ok(())
212+ }
213+
214+ fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
215+ let Some(f) = self.foreskin else {
216+ if min <= 0.0 {
217+ return Ok(());
218+ }
219+ return Err(Problem::new(
220+ StatusCode::GONE,
221+ "foreskin-gone",
222+ "This technique requires a foreskin. The foreskin is gone.",
223+ )
224+ .header(
225+ header::CONTENT_LOCATION,
226+ format!("/runs/{}/foreskin", self.id),
227+ ));
228+ };
229+ if f < min {
230+ return Err(Problem::new(
231+ StatusCode::LENGTH_REQUIRED,
232+ "foreskin-insufficient",
233+ format!(
234+ "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
235+ ),
236+ )
237+ .with("required", min)
238+ .with("available", super::round2(f)));
239+ }
240+ if f > max {
241+ return Err(Problem::new(
242+ StatusCode::PAYLOAD_TOO_LARGE,
243+ "foreskin-excessive",
244+ format!(
245+ "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
246+ ),
247+ )
248+ .with("maximum", max)
249+ .with("present", super::round2(f)));
250+ }
251+ Ok(())
252+ }
253+
254+ fn check_storage(&self) -> Result<(), Problem> {
255+ if self.items.extender && self.girth > 5.0 {
256+ return Err(Problem::new(
257+ StatusCode::INSUFFICIENT_STORAGE,
258+ "jeans-full",
259+ "The jeans are full. The extender is occupying the remaining storage.",
260+ )
261+ .with("extender", format!("/runs/{}/inventory/extender", self.id)));
262+ }
263+ Ok(())
264+ }
265+
266+ fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
267+ self.touch();
268+ self.turn += 1;
269+ self.floor_turns += 1;
270+ self.realize();
271+ if self.items.extender {
272+ self.length += 0.004;
273+ }
274+ self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
275+ if self.stamina <= 0 {
276+ self.stamina = 0;
277+ self.refractory_until = Some(now + REFRACTORY);
278+ }
279+ let chill = if self.floor == Floor::LockerRoom {
280+ 8
281+ } else {
282+ 0
283+ };
284+ self.eq = (self.eq + intensity * 2 - 6 - chill).clamp(0, 100);
285+
286+ let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
287+ if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {
288+ suspicion *= 2;
289+ }
290+ if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {
291+ suspicion += 2;
292+ }
293+ let was_below = self.suspicion < SUSPICION_LIMIT;
294+ self.suspicion =
295+ (i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)) as u8;
296+ if self.floor.has_mom() && !self.mom_present {
297+ let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
298+ if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {
299+ self.mom_present = true;
300+ self.mom_errand = self.errand(summoned);
301+ self.nonce = self.fresh_nonce();
302+ }
303+ }
304+
305+ if let Some(observer) = self.floor.observer()
306+ && self.rng.random_ratio(1, 6)
307+ {
308+ self.observed = Some(observer);
309+ self.shame += 1;
310+ }
311+ if self.turn == super::BRANCH_TURN {
312+ self.branch_pending = true;
313+ }
314+ }
315+
316+ fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {
317+ if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {
318+ self.shame += 1;
319+ return Err(Problem::new(
320+ StatusCode::USE_PROXY,
321+ "friend",
322+ "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",
323+ ));
324+ }
325+ if self.peyronie && self.rng.random_ratio(1, 3) {
326+ return Err(Problem::new(
327+ StatusCode::MISDIRECTED_REQUEST,
328+ "misdirected",
329+ "The technique landed slightly to the left. It was meant for a different server.",
330+ ));
331+ }
332+ if intensity <= 2 && self.eq < 50 {
333+ self.eq = (self.eq - 10).max(0);
334+ return Err(Problem::new(
335+ StatusCode::REQUEST_TIMEOUT,
336+ "lost-it",
337+ "The request took too long. It was lost.",
338+ )
339+ .header(header::CONNECTION, "close"));
340+ }
341+ if self.eq > 95 {
342+ self.shame += 5;
343+ self.next_floor();
344+ return Err(Problem::new(
345+ StatusCode::TOO_EARLY,
346+ "premature",
347+ "The floor has ended early. Everyone involved has agreed not to discuss it.",
348+ )
349+ .with("floor", self.floor.number()));
350+ }
351+ if self.eq < 30 {
352+ return Err(Problem::new(
353+ StatusCode::NOT_FOUND,
354+ "cold",
355+ "The resource exists. It is cold.",
356+ )
357+ .with("eq", self.eq));
358+ }
359+ Ok(())
360+ }
361+
362+ fn roll(
363+ &mut self,
364+ technique: Technique,
365+ profile: &Profile,
366+ intensity: i32,
367+ lube: Option<Lube>,
368+ ) -> Result<Outcome, Problem> {
369+ let natural: i32 = self.rng.random_range(1..=20);
370+ let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
371+ let grip = match (self.grip, technique) {
372+ (Grip::Underhand, Technique::Stretch) => 2,
373+ (Grip::Underhand, Technique::Wet) => -1,
374+ _ => 0,
375+ };
376+ let modifier = lube.map_or(0, Lube::modifier) + band + grip;
377+ let dc = profile.dc + intensity / 3;
378+ let total = natural + modifier;
379+ let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
380+ let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
381+
382+ let scale = f64::from(intensity);
383+ let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
384+ if self.teapot {
385+ length *= 0.5;
386+ girth *= 1.5;
387+ }
388+ if self.items.ring {
389+ girth *= 2.0;
390+ }
391+ let rupture_eq = if self.items.ring { 80 } else { 90 };
392+
393+ if natural == 1 {
394+ let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
395+ if touches_foreskin && self.foreskin.is_some() {
396+ self.foreskin = None;
397+ self.shame += 25;
398+ return Err(timed(
399+ Problem::new(
400+ StatusCode::GONE,
401+ "foreskin-lost",
402+ "The technique has removed the foreskin. It will not be returning.",
403+ )
404+ .header(
405+ header::CONTENT_LOCATION,
406+ format!("/runs/{}/foreskin", self.id),
407+ ),
408+ ));
409+ }
410+ if self.eq > 70 {
411+ return Err(timed(self.rupture()));
412+ }
413+ if technique == Technique::Stretch && !self.teapot {
414+ self.teapot = true;
415+ self.items.teapot_spout = true;
416+ self.shame += 5;
417+ return Err(timed(Problem::new(
418+ StatusCode::IM_A_TEAPOT,
419+ "teapot",
420+ "The subject is now short and stout. Length gains are halved for the rest of the floor.",
421+ )));
422+ }
423+ if technique == Technique::Uli && !self.peyronie {
424+ self.peyronie = true;
425+ self.shame += 5;
426+ return Err(timed(Problem::new(
427+ StatusCode::MISDIRECTED_REQUEST,
428+ "curvature",
429+ "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
430+ )));
431+ }
432+ return Err(timed(not_extended()));
433+ }
434+ if self.eq > rupture_eq && total < dc {
435+ return Err(timed(self.rupture()));
436+ }
437+ if natural == 20 {
438+ self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
439+ return Ok(Outcome::json(
440+ StatusCode::CREATED,
441+ json!({
442+ "detail": "A permanent gain has been created.",
443+ "length": round3(length * 2.0),
444+ "girth": round3(girth * 2.0),
445+ }),
446+ )
447+ .header(header::LOCATION, format!("/runs/{}/length", self.id))
448+ .header(SERVER_TIMING, timing));
449+ }
450+ if total < dc - 3 {
451+ return Err(timed(not_extended()));
452+ }
453+ if total < dc {
454+ return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
455+ }
456+
457+ let outcome = if self.eq < 50 {
458+ self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
459+ Outcome::json(
460+ StatusCode::PARTIAL_CONTENT,
461+ json!({
462+ "detail": "Partial gains were applied. EQ was below the band.",
463+ "length": round3(length / 2.0),
464+ "girth": round3(girth / 2.0),
465+ }),
466+ )
467+ } else if total >= dc + 5 {
468+ self.gain(length, girth, profile.foreskin_growth);
469+ Outcome::json(
470+ StatusCode::OK,
471+ json!({
472+ "detail": "Gains were applied.",
473+ "length": round3(length),
474+ "girth": round3(girth),
475+ }),
476+ )
477+ } else {
478+ let eta = self.rng.random_range(1..=4);
479+ self.pending.push(super::Pending {
480+ due: self.turn + eta,
481+ length,
482+ girth,
483+ });
484+ self.gain(0.0, 0.0, profile.foreskin_growth);
485+ Outcome::json(
486+ StatusCode::ACCEPTED,
487+ json!({
488+ "detail": "Gains have been queued. They may arrive.",
489+ "eta_turns": eta,
490+ }),
491+ )
492+ };
493+ Ok(outcome.header(SERVER_TIMING, timing))
494+ }
495+
496+ fn gossip(&mut self, outcome: Outcome) -> Outcome {
497+ let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
498+ if !self.roommate_told || !reportable {
499+ return outcome;
500+ }
501+ self.roommate_told = false;
502+ let mut outcome = outcome;
503+ outcome.status = StatusCode::ALREADY_REPORTED;
504+ if let super::Payload::Json(body) = &mut outcome.payload {
505+ body["detail"] = json!("The roommate has already told everyone.");
506+ }
507+ outcome
508+ }
509+
510+ fn warm_up(&mut self) -> Result<Outcome, Problem> {
511+ if self.floor == Floor::Hotel && self.rng.random_ratio(1, 3) {
512+ return Err(Problem::new(
513+ StatusCode::SERVICE_UNAVAILABLE,
514+ "service-unavailable",
515+ "Service is unavailable. The server estimates next week. The server is being pessimistic.",
516+ )
517+ .header(
518+ header::RETRY_AFTER,
519+ httpdate::fmt_http_date(SystemTime::now() + WEEK),
520+ ));
521+ }
522+ let (boost, detail) = if self.floor == Floor::Bathroom {
523+ (
524+ 35,
525+ "Warmed up. The shower is hot. The roommate's will not be.",
526+ )
527+ } else {
528+ (25, "Warmed up. The towel has cooled.")
529+ };
530+ self.touch();
531+ self.warmed_up = true;
532+ self.eq = (self.eq + boost).min(100);
533+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
534+ }
535+
536+ fn ice(&mut self) -> Result<Outcome, Problem> {
537+ if self.items.ice_packs == 0 {
538+ return Err(Problem::new(
539+ StatusCode::FAILED_DEPENDENCY,
540+ "no-ice",
541+ "The ice technique depends on an ice pack. There are none.",
542+ ));
543+ }
544+ self.touch();
545+ self.items.ice_packs -= 1;
546+ self.eq = (self.eq - 40).max(0);
547+ Ok(Outcome::json(
548+ StatusCode::OK,
549+ json!({ "detail": "EQ has been reduced. The ice pack is gone." }),
550+ ))
551+ }
552+
553+ fn supplement(&mut self) -> Result<Outcome, Problem> {
554+ if self.items.supplements == 0 {
555+ return Err(Problem::new(
556+ StatusCode::FAILED_DEPENDENCY,
557+ "no-supplement",
558+ "The supplement technique depends on a supplement.",
559+ ));
560+ }
561+ self.items.supplements -= 1;
562+ self.touch();
563+ Err(Problem::new(
564+ StatusCode::BAD_GATEWAY,
565+ "bad-gateway",
566+ "The upstream returned an invalid response.",
567+ )
568+ .with("upstream", "Pump-Bro"))
569+ }
570+
571+ fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {
572+ self.commit(5, None, now, 1);
573+ self.edges += 1;
574+ self.eq = (self.eq + 11).min(100);
575+ self.girth += 0.004;
576+ if self.edges >= 3 {
577+ self.edges = 0;
578+ self.stamina = 0;
579+ self.refractory_until = Some(now + REFRACTORY);
580+ return Err(Problem::new(
581+ StatusCode::LOOP_DETECTED,
582+ "edging-loop",
583+ "Edging has looped. The server detected an infinite loop and terminated it.",
584+ ));
585+ }
586+ self.hazards(5)?;
587+ Ok(Outcome::json(
588+ StatusCode::OK,
589+ json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),
590+ ))
591+ }
592+
593+ fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {
594+ self.commit(4, None, now, 1);
595+ self.hazards(4)?;
596+ self.temp_length += 0.5;
597+ Ok(Outcome::json(
598+ StatusCode::TEMPORARY_REDIRECT,
599+ json!({ "detail": "Gains applied. They are temporary.", "length": 0.5 }),
600+ )
601+ .header(header::LOCATION, format!("/runs/{}/length", self.id)))
602+ }
603+}
Added file: src/game/mod.rs+666 additions
@@ -0,0 +1,666 @@
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};
7+
8+use std::time::{Duration, Instant};
9+
10+use axum::{
11+ Json,
12+ http::{HeaderName, HeaderValue, StatusCode, header},
13+ response::{IntoResponse, Response},
14+};
15+use rand::{Rng, SeedableRng, rngs::StdRng};
16+use serde_json::{Value, json};
17+
18+use crate::problem::Problem;
19+
20+pub const MAX_STAMINA: i32 = 10;
21+pub const SUSPICION_LIMIT: u8 = 5;
22+pub const BRANCH_TURN: u32 = 10;
23+pub const FLOOR_TURNS: u32 = 6;
24+pub const STARTING_CASH: u32 = 20;
25+pub const FLOOR_ALLOWANCE: u32 = 10;
26+pub const REFRACTORY: Duration = Duration::from_secs(30);
27+pub const SELF_REPORT_INFLATION: f64 = 1.5;
28+pub const MM_PER_INCH: f64 = 25.4;
29+pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";
30+pub const CLINIC_ALLOW: &str = "GET, HEAD, PUT, PATCH, DELETE, OPTIONS, TRACE";
31+
32+pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
33+pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
34+
35+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36+pub enum Floor {
37+ Bedroom,
38+ Bathroom,
39+ LockerRoom,
40+ Grandmas,
41+ Hotel,
42+ Forum,
43+ Clinic,
44+}
45+
46+impl Floor {
47+ const ALL: [Self; 7] = [
48+ Self::Bedroom,
49+ Self::Bathroom,
50+ Self::LockerRoom,
51+ Self::Grandmas,
52+ Self::Hotel,
53+ Self::Forum,
54+ Self::Clinic,
55+ ];
56+
57+ pub fn number(self) -> usize {
58+ Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + 1
59+ }
60+
61+ fn next(self) -> Option<Self> {
62+ Self::ALL.get(self.number()).copied()
63+ }
64+
65+ pub fn name(self) -> &'static str {
66+ match self {
67+ Self::Bedroom => "the bedroom",
68+ Self::Bathroom => "the shared bathroom",
69+ Self::LockerRoom => "the gym locker room",
70+ Self::Grandmas => "grandma's house for Thanksgiving",
71+ Self::Hotel => "a business trip hotel",
72+ Self::Forum => "the forum",
73+ Self::Clinic => "the urologist's office",
74+ }
75+ }
76+
77+ pub fn note(self) -> &'static str {
78+ match self {
79+ Self::Bedroom => "The door does not lock. The cat is present.",
80+ Self::Bathroom => "The water is hot. The roommate showers at eight.",
81+ Self::LockerRoom => {
82+ "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."
83+ }
84+ Self::Grandmas => {
85+ "The walls are thin. Mom is here, and she has been reading about Digest authentication."
86+ }
87+ Self::Hotel => {
88+ "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."
89+ }
90+ Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",
91+ Self::Clinic => "The urologist will see you now. He is bored.",
92+ }
93+ }
94+
95+ fn observer(self) -> Option<&'static str> {
96+ match self {
97+ Self::Bedroom => Some("cat"),
98+ Self::LockerRoom => Some("greg"),
99+ Self::Grandmas => Some("grandma"),
100+ _ => None,
101+ }
102+ }
103+
104+ pub fn shop_open(self) -> bool {
105+ matches!(self, Self::LockerRoom | Self::Hotel)
106+ }
107+
108+ pub fn has_mom(self) -> bool {
109+ matches!(self, Self::Bedroom | Self::Grandmas)
110+ }
111+}
112+
113+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114+pub enum Grip {
115+ Overhand,
116+ Underhand,
117+}
118+
119+impl Grip {
120+ pub fn name(self) -> &'static str {
121+ match self {
122+ Self::Overhand => "overhand",
123+ Self::Underhand => "underhand",
124+ }
125+ }
126+
127+ pub fn parse(protocol: &str) -> Option<Self> {
128+ match protocol.to_ascii_lowercase().as_str() {
129+ "overhand" => Some(Self::Overhand),
130+ "underhand" => Some(Self::Underhand),
131+ _ => None,
132+ }
133+ }
134+}
135+
136+#[derive(Default)]
137+pub struct Items {
138+ pub ruler: bool,
139+ pub ice_packs: u32,
140+ pub door_locks: u32,
141+ pub dnd_sign: bool,
142+ pub pump_pro: bool,
143+ pub extender: bool,
144+ pub ring: bool,
145+ pub supplements: u32,
146+ pub cage: bool,
147+ pub spreadsheet: bool,
148+ pub teapot_spout: bool,
149+}
150+
151+enum Payload {
152+ Empty,
153+ Json(Value),
154+ Raw(&'static str, Vec<u8>),
155+}
156+
157+pub struct Outcome {
158+ status: StatusCode,
159+ payload: Payload,
160+ headers: Vec<(HeaderName, String)>,
161+}
162+
163+impl Outcome {
164+ pub fn empty(status: StatusCode) -> Self {
165+ Self {
166+ status,
167+ payload: Payload::Empty,
168+ headers: Vec::new(),
169+ }
170+ }
171+
172+ pub fn json(status: StatusCode, body: Value) -> Self {
173+ Self {
174+ payload: Payload::Json(body),
175+ ..Self::empty(status)
176+ }
177+ }
178+
179+ pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
180+ Self {
181+ payload: Payload::Raw(content_type, body.into()),
182+ ..Self::empty(status)
183+ }
184+ }
185+
186+ pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
187+ self.headers.push((name, value.into()));
188+ self
189+ }
190+
191+ pub fn status(&self) -> StatusCode {
192+ self.status
193+ }
194+}
195+
196+impl IntoResponse for Outcome {
197+ fn into_response(self) -> Response {
198+ let mut response = match self.payload {
199+ // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
200+ Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {
201+ let full = body.to_string().into_bytes();
202+ let half = full.len() / 2;
203+ let range = format!("bytes 0-{}/{}", half - 1, full.len());
204+ (
205+ self.status,
206+ [
207+ (header::CONTENT_TYPE, "application/json".to_owned()),
208+ (header::CONTENT_RANGE, range),
209+ ],
210+ full[..half].to_vec(),
211+ )
212+ .into_response()
213+ }
214+ Payload::Json(body) => (self.status, Json(body)).into_response(),
215+ Payload::Raw(content_type, body) => {
216+ (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()
217+ }
218+ Payload::Empty => self.status.into_response(),
219+ };
220+ for (name, value) in self.headers {
221+ let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
222+ response.headers_mut().append(name, value);
223+ }
224+ response
225+ }
226+}
227+
228+pub struct Pending {
229+ due: u32,
230+ length: f64,
231+ girth: f64,
232+}
233+
234+pub struct Ending {
235+ pub cause: &'static str,
236+ pub detail: String,
237+ pub turn: u32,
238+}
239+
240+pub struct Run {
241+ pub id: String,
242+ pub origin_etag: String,
243+ pub version: u64,
244+ pub turn: u32,
245+ pub floor: Floor,
246+ pub floor_turns: u32,
247+ pub length: f64,
248+ pub temp_length: f64,
249+ pub girth: f64,
250+ pub foreskin: Option<f64>,
251+ pub eq: i32,
252+ pub stamina: i32,
253+ pub suspicion: u8,
254+ pub shame_base: u32,
255+ pub shame: u32,
256+ pub cash: u32,
257+ pub grip: Grip,
258+ pub items: Items,
259+ pub warmed_up: bool,
260+ pub teapot: bool,
261+ pub peyronie: bool,
262+ pub put_applied: bool,
263+ pub forum_read: bool,
264+ pub branch_pending: bool,
265+ pub mom_present: bool,
266+ pub mom_errand: &'static str,
267+ pub observed: Option<&'static str>,
268+ pub measured: bool,
269+ pub cured: bool,
270+ pub roommate_told: bool,
271+ pub wifi_accepted: bool,
272+ pub verified: bool,
273+ pub elder_beaten: bool,
274+ pub edges: u8,
275+ pub drawer_searches: u8,
276+ pub intake_attempts: usize,
277+ pub nonce: String,
278+ pub heard: Vec<String>,
279+ pub refractory_until: Option<Instant>,
280+ pub pending: Vec<Pending>,
281+ pub ended: Option<Ending>,
282+ rng: StdRng,
283+}
284+
285+pub fn round2(x: f64) -> f64 {
286+ (x * 100.0).round() / 100.0
287+}
288+
289+fn round3(x: f64) -> f64 {
290+ (x * 1000.0).round() / 1000.0
291+}
292+
293+fn tenths(x: f64) -> i64 {
294+ (x * 10.0).round() as i64
295+}
296+
297+fn not_extended() -> Problem {
298+ Problem::new(
299+ StatusCode::NOT_EXTENDED,
300+ "not-extended",
301+ "The requested extension was not applied. The server has recorded the attempt.",
302+ )
303+}
304+
305+pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
306+ let (given, rest) = authorization.trim().split_once(' ')?;
307+ given
308+ .eq_ignore_ascii_case(scheme)
309+ .then(|| rest.trim().trim_matches('"'))
310+}
311+
312+pub fn etag_matches(condition: &str, etag: &str) -> bool {
313+ condition
314+ .split(',')
315+ .map(str::trim)
316+ .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
317+}
318+
319+pub fn shaft(mm: usize) -> String {
320+ format!("8{}D", "=".repeat(mm.saturating_sub(2)))
321+}
322+
323+impl Run {
324+ pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
325+ let mut rng = StdRng::seed_from_u64(seed);
326+ let length = round2(rng.random_range(4.5..6.5));
327+ let girth = round2(rng.random_range(4.0..5.0));
328+ let foreskin = round2(rng.random_range(0.4..2.4));
329+ let mut run = Self {
330+ id,
331+ origin_etag: String::new(),
332+ version: 0,
333+ turn: 0,
334+ floor: Floor::Bedroom,
335+ floor_turns: 0,
336+ length,
337+ temp_length: 0.0,
338+ girth,
339+ foreskin: Some(foreskin),
340+ eq: 20,
341+ stamina: MAX_STAMINA,
342+ suspicion: 0,
343+ shame_base,
344+ shame: 0,
345+ cash: STARTING_CASH,
346+ grip: Grip::Overhand,
347+ items: Items {
348+ ice_packs: 1,
349+ ..Items::default()
350+ },
351+ warmed_up: false,
352+ teapot: false,
353+ peyronie: false,
354+ put_applied: false,
355+ forum_read: false,
356+ branch_pending: false,
357+ mom_present: false,
358+ mom_errand: "She heard a noise.",
359+ observed: None,
360+ measured: false,
361+ cured: false,
362+ roommate_told: false,
363+ wifi_accepted: false,
364+ verified: false,
365+ elder_beaten: false,
366+ edges: 0,
367+ drawer_searches: 0,
368+ intake_attempts: 0,
369+ nonce: String::new(),
370+ heard: Vec::new(),
371+ refractory_until: None,
372+ pending: Vec::new(),
373+ ended: None,
374+ rng,
375+ };
376+ run.origin_etag = run.etag();
377+ run.nonce = run.fresh_nonce();
378+ run
379+ }
380+
381+ pub fn etag(&self) -> String {
382+ format!("\"{}-{}\"", self.id, self.version)
383+ }
384+
385+ pub fn touch(&mut self) {
386+ self.version += 1;
387+ }
388+
389+ fn fresh_nonce(&mut self) -> String {
390+ format!("{:016x}", self.rng.random::<u64>())
391+ }
392+
393+ pub fn current_length(&self) -> f64 {
394+ self.length + self.temp_length
395+ }
396+
397+ pub fn self_reported(&self) -> f64 {
398+ self.current_length() + SELF_REPORT_INFLATION
399+ }
400+
401+ pub fn total_shame(&self) -> u32 {
402+ self.shame_base + self.shame
403+ }
404+
405+ pub fn status(&self) -> &'static str {
406+ match (&self.ended, self.cured) {
407+ (Some(_), _) => "gone",
408+ (None, true) => "normal",
409+ (None, false) => "alive",
410+ }
411+ }
412+
413+ pub fn view(&self) -> Value {
414+ let conditions: Vec<&str> = [
415+ (self.teapot, "short and stout"),
416+ (self.peyronie, "curved"),
417+ (self.items.cage, "caged"),
418+ ]
419+ .into_iter()
420+ .filter_map(|(active, name)| active.then_some(name))
421+ .collect();
422+ let mut view = json!({
423+ "id": self.id,
424+ "status": self.status(),
425+ "turn": self.turn,
426+ "floor": { "number": self.floor.number(), "name": self.floor.name() },
427+ "self_reported_length": round2(self.self_reported()),
428+ "girth": round2(self.girth),
429+ "foreskin": self.foreskin.map(round2),
430+ "eq": self.eq,
431+ "stamina": self.stamina,
432+ "suspicion": self.suspicion,
433+ "shame": self.total_shame(),
434+ "cash": self.cash,
435+ "grip": self.grip.name(),
436+ "warmed_up": self.warmed_up,
437+ "conditions": conditions,
438+ });
439+ if self.items.ruler {
440+ view["length"] = json!(round2(self.current_length()));
441+ }
442+ view
443+ }
444+
445+ pub fn floor_view(&self) -> Value {
446+ let id = &self.id;
447+ let turns_required = match self.floor {
448+ Floor::Forum | Floor::Clinic => Value::Null,
449+ _ => json!(FLOOR_TURNS),
450+ };
451+ let mut view = json!({
452+ "number": self.floor.number(),
453+ "name": self.floor.name(),
454+ "note": self.floor.note(),
455+ "turns": self.floor_turns,
456+ "turns_required": turns_required,
457+ "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" },
458+ });
459+ match self.floor {
460+ Floor::LockerRoom => {
461+ view["others"] = json!([
462+ { "name": "Greg", "self_reported_length": 9.5 },
463+ { "name": "Dmitri", "self_reported_length": 8.75 },
464+ { "name": "a man in a towel", "self_reported_length": 11.0 },
465+ ]);
466+ }
467+ Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")),
468+ Floor::Forum => {
469+ view["thread"] = json!(FORUM_THREAD);
470+ view["elder"] = json!(format!("/runs/{id}/elder"));
471+ }
472+ Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")),
473+ _ => {}
474+ }
475+ if self.floor.shop_open() {
476+ view["shop"] = json!(format!("/runs/{id}/shop"));
477+ }
478+ view
479+ }
480+
481+ pub fn inventory(&self) -> Vec<(&'static str, &'static str)> {
482+ let i = &self.items;
483+ let owned = [
484+ (
485+ true,
486+ "warm-towel",
487+ "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
488+ ),
489+ (
490+ i.ice_packs > 0,
491+ "ice-pack",
492+ "Lowers EQ by 40. Use technique=ice.",
493+ ),
494+ (
495+ i.ruler,
496+ "ruler",
497+ "Measures honestly. The length resource now carries a Content-Length.",
498+ ),
499+ (
500+ i.door_locks > 0,
501+ "door-lock",
502+ "Keeps the roommate out once.",
503+ ),
504+ (
505+ i.dnd_sign,
506+ "do-not-disturb-sign",
507+ "Works on housekeeping. Mom does not read signs.",
508+ ),
509+ (
510+ i.pump_pro,
511+ "pump-pro",
512+ "Use technique=pump. Gains are temporary.",
513+ ),
514+ (
515+ i.extender,
516+ "extender",
517+ "Passive gains each turn. Takes up room. Can be removed with DELETE.",
518+ ),
519+ (
520+ i.ring,
521+ "cock-ring",
522+ "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.",
523+ ),
524+ (i.supplements > 0, "supplement", "Use technique=supplement."),
525+ (i.cage, "chastity-cage", "Locked."),
526+ (
527+ i.spreadsheet,
528+ "spreadsheet",
529+ "Charts your progress. Every line is flat.",
530+ ),
531+ (
532+ i.teapot_spout,
533+ "teapot-spout",
534+ "Does nothing. Cannot be dropped.",
535+ ),
536+ ];
537+ owned
538+ .into_iter()
539+ .filter_map(|(have, slug, description)| have.then_some((slug, description)))
540+ .collect()
541+ }
542+
543+ pub fn end(&mut self, cause: &'static str, detail: &str) {
544+ self.ended = Some(Ending {
545+ cause,
546+ detail: detail.into(),
547+ turn: self.turn,
548+ });
549+ }
550+
551+ fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
552+ let Some(until) = self.refractory_until else {
553+ return Ok(());
554+ };
555+ if now < until {
556+ let seconds = (until - now).as_secs() + 1;
557+ return Err(Problem::new(
558+ StatusCode::TOO_MANY_REQUESTS,
559+ "overtrained",
560+ "Stamina is exhausted. The refractory period is in effect.",
561+ )
562+ .header(header::RETRY_AFTER, seconds.to_string()));
563+ }
564+ self.refractory_until = None;
565+ self.stamina = MAX_STAMINA;
566+ self.eq = 20;
567+ self.warmed_up = false;
568+ self.touch();
569+ Ok(())
570+ }
571+
572+ fn realize(&mut self) {
573+ let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
574+ .into_iter()
575+ .partition(|p| p.due <= self.turn);
576+ self.pending = waiting;
577+ for p in due {
578+ self.length += p.length;
579+ self.girth += p.girth;
580+ }
581+ }
582+
583+ fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
584+ self.length += length;
585+ self.girth += girth;
586+ if let Some(f) = &mut self.foreskin {
587+ *f += foreskin;
588+ }
589+ }
590+
591+ fn rupture(&mut self) -> Problem {
592+ let detail = if self.items.ice_packs > 0 {
593+ "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
594+ } else {
595+ "A vessel has exceeded its rated pressure. The run has ended."
596+ };
597+ self.end("vascular-event", detail);
598+ Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
599+ .with("eq_at_failure", self.eq)
600+ .with("turn", self.turn)
601+ }
602+
603+ fn next_floor(&mut self) {
604+ let Some(next) = self.floor.next() else {
605+ return;
606+ };
607+ self.floor = next;
608+ self.floor_turns = 0;
609+ self.temp_length = 0.0;
610+ self.teapot = false;
611+ self.warmed_up = false;
612+ self.eq = 20;
613+ self.suspicion = 0;
614+ self.mom_present = false;
615+ self.roommate_told = false;
616+ self.drawer_searches = 0;
617+ self.edges = 0;
618+ self.cash += FLOOR_ALLOWANCE;
619+ self.nonce = self.fresh_nonce();
620+ self.touch();
621+ }
622+
623+ pub fn advance(&mut self) -> Result<Outcome, Problem> {
624+ match self.floor {
625+ Floor::Clinic => {
626+ return Err(Problem::new(
627+ StatusCode::CONFLICT,
628+ "no-further-floor",
629+ "There is no further floor. There is only the diagnosis.",
630+ ));
631+ }
632+ Floor::Forum if !self.elder_beaten => {
633+ return Err(Problem::new(
634+ StatusCode::CONFLICT,
635+ "elder-unaddressed",
636+ "The Elder has not been addressed.",
637+ )
638+ .with("elder", format!("/runs/{}/elder", self.id)));
639+ }
640+ Floor::Forum => {}
641+ _ if self.floor_turns < FLOOR_TURNS => {
642+ return Err(Problem::new(
643+ StatusCode::TOO_EARLY,
644+ "floor-not-over",
645+ format!(
646+ "The floor is not over. {} turns remain.",
647+ FLOOR_TURNS - self.floor_turns
648+ ),
649+ ));
650+ }
651+ _ => {}
652+ }
653+ let reverted = self.temp_length;
654+ self.next_floor();
655+ let mut body = json!({
656+ "detail": format!("You have arrived at {}.", self.floor.name()),
657+ "floor": self.floor.number(),
658+ "note": self.floor.note(),
659+ });
660+ if reverted > 0.0 {
661+ body["reverted"] = json!(round2(reverted));
662+ }
663+ Ok(Outcome::json(StatusCode::SEE_OTHER, body)
664+ .header(header::LOCATION, format!("/runs/{}/floor", self.id)))
665+ }
666+}
Modified file: src/http.rs+522 additions−179 deletions
@@ -2,6 +2,7 @@ use std::{
22 collections::HashMap,
33 convert::Infallible,
44 fmt::Write as _,
5+ io::Write as _,
56 sync::{Arc, Mutex},
67 time::Instant,
78 };
@@ -15,13 +16,16 @@ use axum::{
1516 response::{IntoResponse, Response},
1617 routing::any,
1718 };
19+use flate2::{Compression, write::GzEncoder};
20+use hyper_util::rt::TokioIo;
1821 use rand::Rng;
1922 use serde_json::{Value, json};
23+use tokio::io::AsyncWriteExt;
2024
2125 use crate::{
2226 game::{
23- EXCUSES, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, PatchOp, Run, SUSPICION_LIMIT,
24- Technique, shaft,
27+ ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,
28+ PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,
2529 },
2630 problem::{PROBLEM_BASE, Problem},
2731 };
@@ -29,6 +33,8 @@ use crate::{
2933 const REL_BASE: &str = "https://jelq.dev/rels/";
3034 const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
3135 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.";
36+const MAX_URI: usize = 512;
37+const MAX_HEADER: usize = 1024;
3238
3339 #[derive(Default)]
3440 pub struct AppState {
@@ -43,6 +49,16 @@ impl AppState {
4349
4450 type Shared = Arc<AppState>;
4551
52+#[derive(Clone)]
53+struct Compress;
54+
55+/// Which household hazards stand between the request and the run.
56+#[derive(Clone, Copy, PartialEq, Eq)]
57+enum Scope {
58+ Home,
59+ Pockets,
60+}
61+
4662 pub fn app() -> Router {
4763 router(Arc::default())
4864 }
@@ -58,7 +74,14 @@ pub fn router(state: Shared) -> Router {
5874 .route("/runs/{id}/techniques", any(techniques))
5975 .route("/runs/{id}/drawer", any(drawer))
6076 .route("/runs/{id}/branches/{choice}", any(branch))
77+ .route("/runs/{id}/floor", any(floor))
78+ .route("/runs/{id}/shop", any(shop))
79+ .route("/runs/{id}/shop/{item}", any(buy))
80+ .route("/runs/{id}/portal", any(portal))
81+ .route("/runs/{id}/elder", any(elder))
82+ .route("/runs/{id}/grip", any(grip))
6183 .route("/runs/{id}/diagnosis", any(diagnosis))
84+ .route("/forum/threads/{year}", any(thread))
6285 .route("/graveyard", any(graveyard))
6386 .route("/leaderboard", any(leaderboard))
6487 .fallback(not_found)
@@ -66,8 +89,74 @@ pub fn router(state: Shared) -> Router {
6689 .with_state(state)
6790 }
6891
92+fn reject(request: &Request) -> Option<Problem> {
93+ if request.version() < Version::HTTP_11 {
94+ return Some(Problem::new(
95+ StatusCode::HTTP_VERSION_NOT_SUPPORTED,
96+ "pdf",
97+ "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.",
98+ ));
99+ }
100+ if request.uri().to_string().len() > MAX_URI {
101+ return Some(Problem::new(
102+ StatusCode::URI_TOO_LONG,
103+ "uri-too-long",
104+ "The URI is longer than anything else here.",
105+ ));
106+ }
107+ if request.headers().values().any(|v| v.len() > MAX_HEADER) {
108+ return Some(Problem::new(
109+ StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
110+ "head-too-large",
111+ "Head too large.",
112+ ));
113+ }
114+ let expectation = request.headers().get(header::EXPECT)?;
115+ if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") {
116+ return None;
117+ }
118+ Some(Problem::new(
119+ StatusCode::EXPECTATION_FAILED,
120+ "expectations",
121+ format!(
122+ "Expect: {} cannot be met.",
123+ expectation.to_str().unwrap_or("that")
124+ ),
125+ ))
126+}
127+
128+async fn compress(response: Response) -> Response {
129+ let (mut parts, body) = response.into_parts();
130+ let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
131+ return Response::from_parts(parts, Body::empty());
132+ };
133+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
134+ if encoder.write_all(&bytes).is_err() {
135+ return Response::from_parts(parts, Body::from(bytes));
136+ }
137+ let Ok(gzipped) = encoder.finish() else {
138+ return Response::from_parts(parts, Body::from(bytes));
139+ };
140+ parts
141+ .headers
142+ .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
143+ parts.headers.remove(header::CONTENT_LENGTH);
144+ parts
145+ .headers
146+ .append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
147+ Response::from_parts(parts, Body::from(gzipped))
148+}
149+
69150 async fn chrome(request: Request, next: Next) -> Response {
70- let mut response = next.run(request).await;
151+ let gzip = header_str(request.headers(), header::ACCEPT_ENCODING)
152+ .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));
153+ let mut response = match reject(&request) {
154+ Some(problem) => problem.into_response(),
155+ None => next.run(request).await,
156+ };
157+ if gzip && response.extensions().get::<Compress>().is_some() {
158+ response = compress(response).await;
159+ }
71160 let headers = response.headers_mut();
72161 headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
73162 headers.insert("x-powered-by", HeaderValue::from_static("blood"));
@@ -156,20 +245,26 @@ fn links(run: &Run) -> String {
156245 let id = &run.id;
157246 let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];
158247 for rel in [
248+ "floor",
159249 "length",
160250 "foreskin",
161251 "inventory",
162252 "techniques",
163253 "drawer",
254+ "grip",
164255 "diagnosis",
165256 ] {
166257 links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));
167258 }
259+ if run.floor.shop_open() {
260+ links.push(format!("</runs/{id}/shop>; rel=\"{REL_BASE}shop\""));
261+ }
168262 links.join(", ")
169263 }
170264
171265 fn finish(run: &Run, mut response: Response) -> Response {
172- if response.status() == StatusCode::NOT_MODIFIED {
266+ let status = response.status();
267+ if status == StatusCode::NOT_MODIFIED {
173268 return response;
174269 }
175270 let mut stats = vec![
@@ -189,10 +284,12 @@ fn finish(run: &Run, mut response: Response) -> Response {
189284 format!("{}/{SUSPICION_LIMIT}", run.suspicion),
190285 ),
191286 ("x-shame", run.total_shame().to_string()),
287+ ("x-cash", run.cash.to_string()),
288+ ("x-floor", run.floor.number().to_string()),
192289 ("link", links(run)),
193290 ];
194- if run.observed {
195- stats.push(("x-observed-by", "cat".into()));
291+ if let Some(observer) = run.observed {
292+ stats.push(("x-observed-by", observer.into()));
196293 }
197294 if run.mom_present {
198295 stats.push(("via", "1.1 mom".into()));
@@ -206,13 +303,17 @@ fn finish(run: &Run, mut response: Response) -> Response {
206303 "set-cookie",
207304 format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()),
208305 ));
209- if run.ruler {
306+ if run.items.ruler {
210307 stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}")));
211308 }
212309 let headers = response.headers_mut();
213310 for (name, value) in stats {
214311 headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));
215312 }
313+ let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
314+ if run.items.ring && !bodiless {
315+ response.extensions_mut().insert(Compress);
316+ }
216317 response
217318 }
218319
@@ -225,11 +326,25 @@ fn gone(run: &Run) -> Response {
225326 .into_response()
226327 }
227328
329+fn captive_portal(run: &Run) -> Response {
330+ Problem::new(
331+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
332+ "captive-portal",
333+ "The network requires authentication. The hotel would like you to agree to some terms.",
334+ )
335+ .with("portal", format!("/runs/{}/portal", run.id))
336+ .header(
337+ header::LINK,
338+ format!("</runs/{}/portal>; rel=\"{REL_BASE}portal\"", run.id),
339+ )
340+ .into_response()
341+}
342+
228343 fn with_run(
229344 state: &AppState,
230345 id: &str,
231346 headers: &HeaderMap,
232- mom_applies: bool,
347+ scope: Scope,
233348 handle: impl FnOnce(&mut Run) -> Response,
234349 ) -> Response {
235350 let mut runs = state.runs.lock().unwrap();
@@ -244,12 +359,17 @@ fn with_run(
244359 if run.ended.is_some() {
245360 return gone(run);
246361 }
247- run.observed = false;
248- if mom_applies
249- && run.mom_present
250- && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))
251- {
252- return finish(run, problem.into_response());
362+ run.observed = None;
363+ if scope == Scope::Home {
364+ if run.floor == Floor::Hotel && !run.wifi_accepted {
365+ return finish(run, captive_portal(run));
366+ }
367+ if run.floor.has_mom()
368+ && run.mom_present
369+ && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))
370+ {
371+ return finish(run, problem.into_response());
372+ }
253373 }
254374 let response = handle(run);
255375 finish(run, response)
@@ -314,39 +434,40 @@ async fn run(
314434 headers: HeaderMap,
315435 body: Bytes,
316436 ) -> Response {
317- const ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";
318437 let now = Instant::now();
319- with_run(&state, &id, &headers, true, |run| match method.as_str() {
320- "GET" | "HEAD" => {
321- let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();
322- if accept.contains("reassurance") {
323- return Problem::new(
324- StatusCode::NOT_ACCEPTABLE,
325- "no-reassurance",
326- "The server does not produce reassurance.",
438+ with_run(&state, &id, &headers, Scope::Home, |run| {
439+ match method.as_str() {
440+ "GET" | "HEAD" => {
441+ let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();
442+ if accept.contains("reassurance") {
443+ return Problem::new(
444+ StatusCode::NOT_ACCEPTABLE,
445+ "no-reassurance",
446+ "The server does not produce reassurance.",
447+ )
448+ .into_response();
449+ }
450+ (
451+ StatusCode::OK,
452+ [(header::ETAG, run.etag())],
453+ Json(run.view()),
327454 )
328- .into_response();
455+ .into_response()
329456 }
330- (
331- StatusCode::OK,
332- [(header::ETAG, run.etag())],
333- Json(run.view()),
334- )
335- .into_response()
336- }
337- "JELQ" => jelq(run, &query, &headers, now),
338- "PUT" => run.put().into_response(),
339- "PATCH" => patch(run, &headers, &body),
340- "DELETE" => {
341- run.end("abandoned", "The run was abandoned.");
342- StatusCode::NO_CONTENT.into_response()
343- }
344- "OPTIONS" => options(ALLOW, true),
345- "TRACE" => {
346- run.shame += 1;
347- trace(&method, &uri, version, &headers)
457+ "JELQ" => jelq(run, &query, &headers, now),
458+ "PUT" => run.put().into_response(),
459+ "PATCH" => patch(run, &headers, &body),
460+ "DELETE" => {
461+ run.end("abandoned", "The run was abandoned.");
462+ StatusCode::NO_CONTENT.into_response()
463+ }
464+ "OPTIONS" => options(RUN_ALLOW, true),
465+ "TRACE" => {
466+ run.shame += 1;
467+ trace(&method, &uri, version, &headers)
468+ }
469+ _ => unsupported(&method, RUN_ALLOW),
348470 }
349- _ => unsupported(&method, ALLOW),
350471 })
351472 }
352473
@@ -356,29 +477,29 @@ fn jelq(
356477 headers: &HeaderMap,
357478 now: Instant,
358479 ) -> Response {
359- let technique = match query.get("technique") {
360- None => {
480+ let techniques_href = format!("/runs/{}/techniques", run.id);
481+ let Some(names) = query.get("technique") else {
482+ return Problem::new(
483+ StatusCode::BAD_REQUEST,
484+ "technique-required",
485+ "No technique was specified.",
486+ )
487+ .with("techniques", techniques_href)
488+ .into_response();
489+ };
490+ let mut techniques = Vec::new();
491+ for name in names.split(',').map(str::trim) {
492+ let Some(technique) = Technique::parse(name) else {
361493 return Problem::new(
362494 StatusCode::BAD_REQUEST,
363- "technique-required",
364- "No technique was specified.",
495+ "unknown-technique",
496+ format!("{name} is not a technique."),
365497 )
366- .with("techniques", format!("/runs/{}/techniques", run.id))
498+ .with("techniques", techniques_href)
367499 .into_response();
368- }
369- Some(name) => match Technique::parse(name) {
370- Some(t) => t,
371- None => {
372- return Problem::new(
373- StatusCode::BAD_REQUEST,
374- "unknown-technique",
375- format!("{name} is not a technique."),
376- )
377- .with("techniques", format!("/runs/{}/techniques", run.id))
378- .into_response();
379- }
380- },
381- };
500+ };
501+ techniques.push(technique);
502+ }
382503 let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {
383504 None => 5,
384505 Some(Ok(i)) if (1..=10).contains(&i) => i,
@@ -395,7 +516,7 @@ fn jelq(
395516 Ok(lube) => lube,
396517 Err(problem) => return problem.into_response(),
397518 };
398- respond(run.jelq(technique, intensity, lube, now))
519+ respond(run.jelq(&techniques, intensity, lube, now))
399520 }
400521
401522 fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
@@ -423,6 +544,71 @@ fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
423544 }
424545 }
425546
547+fn millimetres(inches: f64) -> usize {
548+ (inches * MM_PER_INCH).round() as usize
549+}
550+
551+fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {
552+ let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
553+ let start: f64 = start.trim().parse().ok()?;
554+ let end: f64 = match end.trim() {
555+ "" => total,
556+ end => end.parse().ok()?,
557+ };
558+ (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))
559+}
560+
561+fn length_response(run: &Run, headers: &HeaderMap) -> Response {
562+ let inches = if run.items.ruler {
563+ run.current_length()
564+ } else {
565+ run.self_reported()
566+ };
567+ let body = shaft(millimetres(inches));
568+ let content_type = (
569+ header::CONTENT_TYPE,
570+ "text/plain; charset=us-ascii".to_owned(),
571+ );
572+ let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
573+
574+ if let Some(range) = header_str(headers, header::RANGE)
575+ && range.starts_with("inches=")
576+ {
577+ let Some((start, end)) = parse_inch_range(range, inches) else {
578+ return Problem::new(
579+ StatusCode::RANGE_NOT_SATISFIABLE,
580+ "range-not-satisfiable",
581+ format!("The requested range is not available. {inches:.2} inches are."),
582+ )
583+ .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))
584+ .into_response();
585+ };
586+ let from = millimetres(start).min(body.len() - 1);
587+ let to = millimetres(end).clamp(from + 1, body.len());
588+ let content_range = (
589+ header::CONTENT_RANGE,
590+ format!("inches {start:.2}-{end:.2}/{inches:.2}"),
591+ );
592+ return (
593+ StatusCode::PARTIAL_CONTENT,
594+ [content_type, accept_ranges, content_range],
595+ body[from..to].to_owned(),
596+ )
597+ .into_response();
598+ }
599+
600+ if run.items.ruler {
601+ return (StatusCode::OK, [content_type, accept_ranges], body).into_response();
602+ }
603+ let chunks: Vec<Result<Bytes, Infallible>> = body
604+ .into_bytes()
605+ .chunks(16)
606+ .map(|c| Ok(Bytes::copy_from_slice(c)))
607+ .collect();
608+ let body = Body::from_stream(futures_util::stream::iter(chunks));
609+ (StatusCode::OK, [content_type, accept_ranges], body).into_response()
610+}
611+
426612 async fn length(
427613 State(state): State<Shared>,
428614 Path(id): Path<String>,
@@ -430,24 +616,12 @@ async fn length(
430616 headers: HeaderMap,
431617 ) -> Response {
432618 const ALLOW: &str = "GET, HEAD, OPTIONS";
433- with_run(&state, &id, &headers, true, |run| match method.as_str() {
434- "GET" | "HEAD" => {
435- let content_type = [(header::CONTENT_TYPE, "text/plain; charset=us-ascii")];
436- if run.ruler {
437- let mm = (run.length * MM_PER_INCH).round() as usize;
438- return (StatusCode::OK, content_type, shaft(mm)).into_response();
439- }
440- let mm = (run.self_reported() * MM_PER_INCH).round() as usize;
441- let chunks: Vec<Result<Bytes, Infallible>> = shaft(mm)
442- .into_bytes()
443- .chunks(16)
444- .map(|c| Ok(Bytes::copy_from_slice(c)))
445- .collect();
446- let body = Body::from_stream(futures_util::stream::iter(chunks));
447- (StatusCode::OK, content_type, body).into_response()
619+ with_run(&state, &id, &headers, Scope::Home, |run| {
620+ match method.as_str() {
621+ "GET" | "HEAD" => length_response(run, &headers),
622+ "OPTIONS" => options(ALLOW, false),
623+ _ => unsupported(&method, ALLOW),
448624 }
449- "OPTIONS" => options(ALLOW, false),
450- _ => unsupported(&method, ALLOW),
451625 })
452626 }
453627
@@ -458,50 +632,29 @@ async fn foreskin(
458632 headers: HeaderMap,
459633 ) -> Response {
460634 const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
461- with_run(&state, &id, &headers, true, |run| match method.as_str() {
462- "GET" | "HEAD" => match run.foreskin {
463- Some(f) => (
464- StatusCode::OK,
465- [(header::ETAG, run.etag())],
466- Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),
467- )
635+ with_run(&state, &id, &headers, Scope::Home, |run| {
636+ match method.as_str() {
637+ "GET" | "HEAD" => match run.foreskin {
638+ Some(f) => (
639+ StatusCode::OK,
640+ [(header::ETAG, run.etag())],
641+ Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),
642+ )
643+ .into_response(),
644+ None => Problem::new(
645+ StatusCode::GONE,
646+ "foreskin-gone",
647+ "The foreskin is gone. It will not be returning.",
648+ )
468649 .into_response(),
469- None => Problem::new(
470- StatusCode::GONE,
471- "foreskin-gone",
472- "The foreskin is gone. It will not be returning.",
473- )
474- .into_response(),
475- },
476- "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),
477- "OPTIONS" => options(ALLOW, false),
478- _ => unsupported(&method, ALLOW),
650+ },
651+ "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),
652+ "OPTIONS" => options(ALLOW, false),
653+ _ => unsupported(&method, ALLOW),
654+ }
479655 })
480656 }
481657
482-fn items(run: &Run) -> Vec<(&'static str, &'static str)> {
483- let mut items = vec![(
484- "warm-towel",
485- "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
486- )];
487- if run.ice_packs > 0 {
488- items.push((
489- "ice-pack",
490- "Lowers EQ by 40. Use technique=ice. There is one.",
491- ));
492- }
493- if run.ruler {
494- items.push((
495- "ruler",
496- "Measures honestly. The length resource now carries a Content-Length.",
497- ));
498- }
499- if run.teapot {
500- items.push(("teapot-spout", "Does nothing. Cannot be dropped."));
501- }
502- items
503-}
504-
505658 async fn inventory(
506659 State(state): State<Shared>,
507660 Path(id): Path<String>,
@@ -509,16 +662,27 @@ async fn inventory(
509662 headers: HeaderMap,
510663 ) -> Response {
511664 const ALLOW: &str = "GET, HEAD, OPTIONS";
512- with_run(&state, &id, &headers, false, |run| match method.as_str() {
513- "GET" | "HEAD" => {
514- let listed: Vec<Value> = items(run)
515- .into_iter()
516- .map(|(name, _)| json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) }))
517- .collect();
518- Json(json!({ "items": listed, "excuses": EXCUSES })).into_response()
665+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
666+ match method.as_str() {
667+ "GET" | "HEAD" => {
668+ let listed: Vec<Value> = run
669+ .inventory()
670+ .into_iter()
671+ .map(|(name, _)| {
672+ json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) })
673+ })
674+ .collect();
675+ Json(json!({
676+ "items": listed,
677+ "ice_packs": run.items.ice_packs,
678+ "cash": run.cash,
679+ "excuses": run.excuses(),
680+ }))
681+ .into_response()
682+ }
683+ "OPTIONS" => options(ALLOW, false),
684+ _ => unsupported(&method, ALLOW),
519685 }
520- "OPTIONS" => options(ALLOW, false),
521- _ => unsupported(&method, ALLOW),
522686 })
523687 }
524688
@@ -529,8 +693,9 @@ async fn item(
529693 headers: HeaderMap,
530694 ) -> Response {
531695 const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
532- with_run(&state, &id, &headers, false, |run| {
533- let Some((name, description)) = items(run).into_iter().find(|(n, _)| *n == name) else {
696+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
697+ let Some((name, description)) = run.inventory().into_iter().find(|(n, _)| *n == name)
698+ else {
534699 return Problem::new(
535700 StatusCode::NOT_FOUND,
536701 "no-such-item",
@@ -542,12 +707,7 @@ async fn item(
542707 "GET" | "HEAD" => {
543708 Json(json!({ "name": name, "description": description })).into_response()
544709 }
545- "DELETE" => Problem::new(
546- StatusCode::FORBIDDEN,
547- "cannot-be-dropped",
548- "Items cannot be dropped.",
549- )
550- .into_response(),
710+ "DELETE" => respond(run.drop_item(name)),
551711 "OPTIONS" => options(ALLOW, false),
552712 _ => unsupported(&method, ALLOW),
553713 }
@@ -561,23 +721,25 @@ async fn techniques(
561721 headers: HeaderMap,
562722 ) -> Response {
563723 const ALLOW: &str = "GET, HEAD, OPTIONS";
564- with_run(&state, &id, &headers, true, |run| match method.as_str() {
565- "GET" | "HEAD" => {
566- let listed: Vec<Value> = Technique::ALL
567- .into_iter()
568- .map(|t| t.describe(run.forum_read))
569- .collect();
570- Json(json!({
571- "usage": format!("JELQ /runs/{}?technique=<name>&intensity=<1-10>", run.id),
572- "lubricant": "Declared with Content-Type.",
573- "lubricants": LUBRICANTS,
574- "eq_band": [50, 70],
575- "techniques": listed,
576- }))
577- .into_response()
724+ with_run(&state, &id, &headers, Scope::Home, |run| {
725+ match method.as_str() {
726+ "GET" | "HEAD" => {
727+ let listed: Vec<Value> = Technique::ALL
728+ .into_iter()
729+ .map(|t| t.describe(run.forum_read))
730+ .collect();
731+ Json(json!({
732+ "usage": format!("JELQ /runs/{}?technique=<name>[,<name>]&intensity=<1-10>", run.id),
733+ "lubricant": "Declared with Content-Type.",
734+ "lubricants": LUBRICANTS,
735+ "eq_band": [50, 70],
736+ "techniques": listed,
737+ }))
738+ .into_response()
739+ }
740+ "OPTIONS" => options(ALLOW, false),
741+ _ => unsupported(&method, ALLOW),
578742 }
579- "OPTIONS" => options(ALLOW, false),
580- _ => unsupported(&method, ALLOW),
581743 })
582744 }
583745
@@ -587,10 +749,12 @@ async fn drawer(
587749 method: Method,
588750 headers: HeaderMap,
589751 ) -> Response {
590- with_run(&state, &id, &headers, true, |run| match method.as_str() {
591- "POST" => run.search_drawer().into_response(),
592- "OPTIONS" => options("POST, OPTIONS", false),
593- _ => unsupported(&method, "POST, OPTIONS"),
752+ with_run(&state, &id, &headers, Scope::Home, |run| {
753+ match method.as_str() {
754+ "POST" => respond(run.search_drawer()),
755+ "OPTIONS" => options("POST, OPTIONS", false),
756+ _ => unsupported(&method, "POST, OPTIONS"),
757+ }
594758 })
595759 }
596760
@@ -600,13 +764,164 @@ async fn branch(
600764 method: Method,
601765 headers: HeaderMap,
602766 ) -> Response {
603- with_run(&state, &id, &headers, true, |run| match method.as_str() {
604- "POST" => respond(run.choose(&choice)),
605- "OPTIONS" => options("POST, OPTIONS", false),
606- _ => unsupported(&method, "POST, OPTIONS"),
767+ with_run(&state, &id, &headers, Scope::Home, |run| {
768+ match method.as_str() {
769+ "POST" => respond(run.choose(&choice)),
770+ "OPTIONS" => options("POST, OPTIONS", false),
771+ _ => unsupported(&method, "POST, OPTIONS"),
772+ }
773+ })
774+}
775+
776+async fn floor(
777+ State(state): State<Shared>,
778+ Path(id): Path<String>,
779+ method: Method,
780+ headers: HeaderMap,
781+) -> Response {
782+ const ALLOW: &str = "GET, HEAD, POST, OPTIONS";
783+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
784+ match method.as_str() {
785+ "GET" | "HEAD" => Json(run.floor_view()).into_response(),
786+ "POST" => respond(run.advance()),
787+ "OPTIONS" => options(ALLOW, false),
788+ _ => unsupported(&method, ALLOW),
789+ }
790+ })
791+}
792+
793+async fn shop(
794+ State(state): State<Shared>,
795+ Path(id): Path<String>,
796+ method: Method,
797+ headers: HeaderMap,
798+) -> Response {
799+ const ALLOW: &str = "GET, HEAD, OPTIONS";
800+ with_run(&state, &id, &headers, Scope::Home, |run| {
801+ match method.as_str() {
802+ "GET" | "HEAD" => respond(run.shop_view().map(Json)),
803+ "OPTIONS" => options(ALLOW, false),
804+ _ => unsupported(&method, ALLOW),
805+ }
806+ })
807+}
808+
809+async fn buy(
810+ State(state): State<Shared>,
811+ Path((id, item)): Path<(String, String)>,
812+ method: Method,
813+ headers: HeaderMap,
814+) -> Response {
815+ with_run(&state, &id, &headers, Scope::Home, |run| {
816+ match method.as_str() {
817+ "POST" => respond(run.buy(&item)),
818+ "OPTIONS" => options("POST, OPTIONS", false),
819+ _ => unsupported(&method, "POST, OPTIONS"),
820+ }
821+ })
822+}
823+
824+async fn portal(
825+ State(state): State<Shared>,
826+ Path(id): Path<String>,
827+ method: Method,
828+ headers: HeaderMap,
829+) -> Response {
830+ const ALLOW: &str = "GET, HEAD, POST, OPTIONS";
831+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
832+ match method.as_str() {
833+ "GET" | "HEAD" => Json(json!({
834+ "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.",
835+ "accept": { "href": format!("/runs/{}/portal", run.id), "method": "POST" },
836+ }))
837+ .into_response(),
838+ "POST" => respond(run.accept_terms()),
839+ "OPTIONS" => options(ALLOW, false),
840+ _ => unsupported(&method, ALLOW),
841+ }
842+ })
843+}
844+
845+async fn elder(
846+ State(state): State<Shared>,
847+ Path(id): Path<String>,
848+ method: Method,
849+ headers: HeaderMap,
850+) -> Response {
851+ const ALLOW: &str = "GET, HEAD, OPTIONS";
852+ with_run(&state, &id, &headers, Scope::Home, |run| {
853+ match method.as_str() {
854+ "GET" | "HEAD" => respond(run.elder(
855+ header_str(&headers, header::PROXY_AUTHORIZATION),
856+ header_str(&headers, header::IF_MODIFIED_SINCE),
857+ )),
858+ "OPTIONS" => options(ALLOW, false),
859+ _ => unsupported(&method, ALLOW),
860+ }
607861 })
608862 }
609863
864+fn requested_grip(headers: &HeaderMap) -> Option<Grip> {
865+ let connection = header_str(headers, header::CONNECTION)?.to_ascii_lowercase();
866+ if !connection.split(',').any(|token| token.trim() == "upgrade") {
867+ return None;
868+ }
869+ header_str(headers, header::UPGRADE)?
870+ .split(',')
871+ .find_map(|protocol| Grip::parse(protocol.trim()))
872+}
873+
874+async fn grip(
875+ State(state): State<Shared>,
876+ Path(id): Path<String>,
877+ mut request: Request,
878+) -> Response {
879+ const ALLOW: &str = "GET, HEAD, OPTIONS";
880+ let method = request.method().clone();
881+ let headers = request.headers().clone();
882+ let wanted = requested_grip(&headers);
883+ let upgrade = wanted.map(|_| hyper::upgrade::on(&mut request));
884+ let response = with_run(&state, &id, &headers, Scope::Home, |run| {
885+ match (method.as_str(), wanted) {
886+ ("GET" | "HEAD", Some(grip)) => {
887+ run.set_grip(grip);
888+ (
889+ StatusCode::SWITCHING_PROTOCOLS,
890+ [
891+ (header::UPGRADE, grip.name()),
892+ (header::CONNECTION, "upgrade"),
893+ ],
894+ )
895+ .into_response()
896+ }
897+ ("GET" | "HEAD", None) => (
898+ StatusCode::OK,
899+ [(header::UPGRADE, "overhand, underhand")],
900+ Json(json!({
901+ "grip": run.grip.name(),
902+ "detail": "Grips are protocols. Change grip by upgrading the connection.",
903+ })),
904+ )
905+ .into_response(),
906+ ("OPTIONS", _) => options(ALLOW, false),
907+ _ => unsupported(&method, ALLOW),
908+ }
909+ });
910+ if response.status() == StatusCode::SWITCHING_PROTOCOLS
911+ && let (Some(upgrade), Some(grip)) = (upgrade, wanted)
912+ {
913+ tokio::spawn(async move {
914+ if let Ok(upgraded) = upgrade.await {
915+ let mut io = TokioIo::new(upgraded);
916+ let line = format!("You are now jelqing {}.\r\n", grip.name());
917+ let _ = io.write_all(line.as_bytes()).await;
918+ let _ = io.shutdown().await;
919+ }
920+ });
921+ }
922+ response
923+}
924+
610925 async fn diagnosis(
611926 State(state): State<Shared>,
612927 Path(id): Path<String>,
@@ -614,16 +929,45 @@ async fn diagnosis(
614929 headers: HeaderMap,
615930 ) -> Response {
616931 const ALLOW: &str = "GET, HEAD, OPTIONS";
617- with_run(&state, &id, &headers, false, |run| match method.as_str() {
618- "GET" | "HEAD" => respond(run.diagnose(
619- header_str(&headers, header::AUTHORIZATION),
620- header_str(&headers, header::IF_NONE_MATCH),
621- )),
622- "OPTIONS" => options(ALLOW, false),
623- _ => unsupported(&method, ALLOW),
932+ with_run(&state, &id, &headers, Scope::Pockets, |run| {
933+ match method.as_str() {
934+ "GET" | "HEAD" => respond(run.diagnose(
935+ header_str(&headers, header::AUTHORIZATION),
936+ header_str(&headers, header::IF_NONE_MATCH),
937+ )),
938+ "OPTIONS" => options(ALLOW, false),
939+ _ => unsupported(&method, ALLOW),
940+ }
624941 })
625942 }
626943
944+async fn thread(Path(year): Path<String>, method: Method) -> Response {
945+ if method != Method::GET && method != Method::HEAD {
946+ return unsupported(&method, "GET, HEAD");
947+ }
948+ match year.parse::<u32>() {
949+ Ok(2009) => (
950+ [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],
951+ Json(forum_thread()),
952+ )
953+ .into_response(),
954+ Ok(y) if y > 2009 => Problem::new(
955+ StatusCode::NOT_FOUND,
956+ "no-thread",
957+ "Nobody has posted since 2009.",
958+ )
959+ .with("thread", FORUM_THREAD)
960+ .into_response(),
961+ _ => Problem::new(
962+ StatusCode::NOT_FOUND,
963+ "no-thread",
964+ "The forum did not exist yet. The problem did.",
965+ )
966+ .with("thread", FORUM_THREAD)
967+ .into_response(),
968+ }
969+}
970+
627971 async fn graveyard(State(state): State<Shared>, method: Method) -> Response {
628972 if method != Method::GET && method != Method::HEAD {
629973 return unsupported(&method, "GET, HEAD");
@@ -675,26 +1019,25 @@ async fn leaderboard(
6751019 };
6761020 let runs = state.runs.lock().unwrap();
6771021 let mut entries: Vec<&Run> = runs.values().collect();
678- entries.sort_by(|a, b| {
679- let key = |r: &Run| {
680- if truthful {
681- r.length
682- } else {
683- r.self_reported()
684- }
685- };
686- key(b).total_cmp(&key(a))
687- });
1022+ let key = |r: &Run| {
1023+ if truthful {
1024+ r.current_length()
1025+ } else {
1026+ r.self_reported()
1027+ }
1028+ };
1029+ entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
6881030 let entries: Vec<Value> = entries
6891031 .into_iter()
6901032 .map(|run| {
6911033 let mut entry = json!({
6921034 "id": run.id,
6931035 "status": run.status(),
1036+ "floor": run.floor.number(),
6941037 "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
6951038 });
6961039 if truthful {
697- entry["content_length"] = json!((run.length * MM_PER_INCH).round() as u64);
1040+ entry["content_length"] = json!(millimetres(run.current_length()));
6981041 }
6991042 entry
7001043 })
Modified file: src/problem.rs+4 additions
@@ -29,6 +29,10 @@ impl Problem {
2929 }))
3030 }
3131
32+ pub fn status(&self) -> StatusCode {
33+ self.0.status
34+ }
35+
3236 pub fn with(mut self, key: &str, value: impl Into<Value>) -> Self {
3337 self.0.extensions.insert(key.into(), value.into());
3438 self
Modified file: tests/http.rs+215 additions−12 deletions
@@ -6,7 +6,11 @@ use axum::{
66 http::{Method, Request, StatusCode, header},
77 response::Response,
88 };
9-use jelqd::{AppState, game::Run, router};
9+use jelqd::{
10+ AppState,
11+ game::{Floor, Run},
12+ router,
13+};
1014 use serde_json::Value;
1115 use tower::ServiceExt;
1216
@@ -33,10 +37,10 @@ async fn json(response: Response) -> Value {
3337 serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
3438 }
3539
36-fn seeded(turn: u32) -> (Router, String) {
40+fn seeded(floor: Floor) -> (Router, String) {
3741 let state = Arc::new(AppState::default());
3842 let mut run = Run::new("cafebabe".into(), 69, 0);
39- run.turn = turn;
43+ run.floor = floor;
4044 let etag = run.origin_etag.clone();
4145 state.insert(run);
4246 (router(state), etag)
@@ -54,7 +58,7 @@ async fn creating_a_run_returns_location_and_etag() {
5458
5559 #[tokio::test]
5660 async fn jelqing_cold_fails_the_precondition() {
57- let (app, _) = seeded(0);
61+ let (app, _) = seeded(Floor::Bedroom);
5862 let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
5963 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
6064 assert_eq!(
@@ -65,7 +69,7 @@ async fn jelqing_cold_fails_the_precondition() {
6569
6670 #[tokio::test]
6771 async fn wet_without_lube_is_unsupported_media() {
68- let (app, _) = seeded(0);
72+ let (app, _) = seeded(Floor::Bedroom);
6973 send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
7074 let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;
7175 assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
@@ -82,14 +86,14 @@ async fn wet_without_lube_is_unsupported_media() {
8286
8387 #[tokio::test]
8488 async fn unknown_methods_are_not_implemented() {
85- let (app, _) = seeded(0);
89+ let (app, _) = seeded(Floor::Bedroom);
8690 let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;
8791 assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
8892 }
8993
9094 #[tokio::test]
9195 async fn patch_only_accepts_the_truth() {
92- let (app, _) = seeded(0);
96+ let (app, _) = seeded(Floor::Bedroom);
9397 let patch = [("content-type", "application/json-patch+json")];
9498 let replace = send(
9599 &app,
@@ -116,7 +120,7 @@ async fn patch_only_accepts_the_truth() {
116120
117121 #[tokio::test]
118122 async fn deleting_the_foreskin_requires_certainty() {
119- let (app, _) = seeded(0);
123+ let (app, _) = seeded(Floor::Bedroom);
120124 let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;
121125 assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
122126 let etag = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "")
@@ -140,7 +144,7 @@ async fn deleting_the_foreskin_requires_certainty() {
140144
141145 #[tokio::test]
142146 async fn length_is_chunked_until_the_ruler() {
143- let (app, _) = seeded(0);
147+ let (app, _) = seeded(Floor::Bedroom);
144148 let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;
145149 assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));
146150 let found = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
@@ -158,7 +162,7 @@ async fn length_is_chunked_until_the_ruler() {
158162
159163 #[tokio::test]
160164 async fn the_clinic_escalates_toward_honesty() {
161- let (app, _) = seeded(20);
165+ let (app, _) = seeded(Floor::Clinic);
162166 let mut hints = Vec::new();
163167 for _ in 0..3 {
164168 let response = send(
@@ -182,8 +186,8 @@ async fn the_clinic_escalates_toward_honesty() {
182186
183187 #[tokio::test]
184188 async fn the_true_ending_is_not_modified() {
185- let (app, etag) = seeded(20);
186- let early = seeded(3).0;
189+ let (app, etag) = seeded(Floor::Clinic);
190+ let early = seeded(Floor::Bedroom).0;
187191 let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;
188192 assert_eq!(response.status(), StatusCode::TOO_EARLY);
189193
@@ -222,3 +226,202 @@ async fn mom_accepts_a_known_excuse() {
222226 .await;
223227 assert_eq!(response.status(), StatusCode::OK);
224228 }
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+}