atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
a9b016e4b248ebfb4326a7881143856a1fe77252
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game.rsRust1028 lines32.2 KB
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
}