atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
99524cf9f17c87f6898b4227baea78d8cfe28ef9
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/encounters.rsRust762 lines26.9 KB
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, honest, parse_credentials,
10
    round2,
11
};
12
use crate::problem::Problem;
13
14
pub const FORUM_THREAD: &str = "/forum/threads/2009";
15
pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";
16
17
pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];
18
pub const THANKSGIVING_EXCUSES: [&str; 3] = ["on a call", "folding laundry", "praying"];
19
const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];
20
const PATIENCE: u8 = 3;
21
const CAUGHT: &str = "Mom has seen enough. The run has ended. Nobody will mention it at dinner.";
22
const BANNED: &str = "Your account has been banned. The forum can tell. The run has ended.";
23
24
const INTAKE: [&str; 3] = [
25
    "The clinic does not accept excuses.",
26
    "The clinic accepts one token. You have not been carrying it.",
27
    "Be honest.",
28
];
29
30
const SPREADSHEET: &str = "\
31
date,length,girth,technique,notes,honest?
32
2007-01-03,7.50,5.50,wet,baseline,no
33
2007-06-11,7.50,5.50,wet,feeling fuller,no
34
2008-01-02,7.50,5.50,dry,new routine,no
35
2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
36
2009-06-14,7.50,5.50,wet,progress is slow but real,no
37
";
38
39
pub fn forum_thread() -> Value {
40
    json!({
41
        "title": "The definitive jelqing FAQ (updated 2009)",
42
        "posts": [
43
            { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
44
            { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },
45
            { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },
46
            { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },
47
            { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },
48
            { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },
49
            { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },
50
        ],
51
    })
52
}
53
54
#[derive(Deserialize)]
55
pub struct PatchOp {
56
    pub op: String,
57
    pub path: String,
58
    #[serde(default)]
59
    pub value: Value,
60
}
61
62
fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {
63
    let (scheme, rest) = authorization.trim().split_once(' ')?;
64
    if !scheme.eq_ignore_ascii_case("digest") {
65
        return None;
66
    }
67
    Some(
68
        rest.split(',')
69
            .filter_map(|pair| {
70
                let (key, value) = pair.split_once('=')?;
71
                Some((
72
                    key.trim().to_ascii_lowercase(),
73
                    value.trim().trim_matches('"').to_owned(),
74
                ))
75
            })
76
            .collect(),
77
    )
78
}
79
80
fn exaggeration() -> Problem {
81
    Problem::new(
82
        StatusCode::BAD_REQUEST,
83
        "exaggeration",
84
        "The server has measured. Lying is not a technique.",
85
    )
86
}
87
88
impl Run {
89
    pub fn excuses(&self) -> Vec<&'static str> {
90
        if self.floor.number() < Floor::Grandmas.number() {
91
            return EXCUSES.to_vec();
92
        }
93
        EXCUSES
94
            .into_iter()
95
            .chain(THANKSGIVING_EXCUSES)
96
            .filter(|e| !self.heard.iter().any(|h| h == e))
97
            .collect()
98
    }
99
100
    pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
101
        let digest = self.floor == Floor::Grandmas;
102
        let challenge_value = if digest {
103
            format!("Digest realm=\"household\", nonce=\"{}\"", self.nonce)
104
        } else {
105
            "Excuse realm=\"household\"".to_owned()
106
        };
107
        let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);
108
109
        let Some(authorization) = authorization else {
110
            return Err(challenge(
111
                Problem::new(
112
                    StatusCode::UNAUTHORIZED,
113
                    "someone-at-the-door",
114
                    format!("Someone is at the door. {}", self.mom_errand),
115
                )
116
                .with("inventory", format!("/runs/{}/inventory", self.id)),
117
            ));
118
        };
119
120
        let spoken = authorization
121
            .split_once(' ')
122
            .map(|(_, rest)| rest.trim().trim_matches('"').to_ascii_lowercase())
123
            .unwrap_or_default();
124
        if TRUTHS.contains(&spoken.as_str()) {
125
            self.shame += 10;
126
            self.leave_door();
127
            return Ok(());
128
        }
129
        if parse_credentials(authorization, "basic").is_some() {
130
            return Err(self.mom_strike(challenge(Problem::new(
131
                StatusCode::UNAUTHORIZED,
132
                "basic",
133
                "Mom does not accept Basic authentication. Nobody should.",
134
            ))));
135
        }
136
137
        let excuse = if digest {
138
            let Some(params) = digest_params(authorization) else {
139
                return Err(self.mom_strike(challenge(Problem::new(
140
                    StatusCode::UNAUTHORIZED,
141
                    "digest-required",
142
                    "Mom has started using Digest authentication.",
143
                ))));
144
            };
145
            if params.get("nonce") != Some(&self.nonce) {
146
                return Err(self.mom_strike(challenge(Problem::new(
147
                    StatusCode::UNAUTHORIZED,
148
                    "stale-nonce",
149
                    "That was a different conversation.",
150
                ))));
151
            }
152
            params.get("username").cloned().unwrap_or_default()
153
        } else {
154
            parse_credentials(authorization, "excuse")
155
                .unwrap_or_default()
156
                .to_owned()
157
        };
158
        let excuse = excuse.to_ascii_lowercase();
159
160
        if TRUTHS.contains(&excuse.as_str()) {
161
            self.shame += 10;
162
            self.leave_door();
163
            return Ok(());
164
        }
165
        if digest && self.heard.contains(&excuse) {
166
            self.shame += 3;
167
            self.touch();
168
            return Err(self.mom_strike(challenge(Problem::new(
169
                StatusCode::UNAUTHORIZED,
170
                "excuse-replayed",
171
                "Mom has heard that one before. She would have preferred the truth.",
172
            ))));
173
        }
174
        if self.excuses().contains(&excuse.as_str()) {
175
            self.heard.push(excuse);
176
            self.leave_door();
177
            return Ok(());
178
        }
179
        self.shame += 3;
180
        self.touch();
181
        let detail = if digest {
182
            "Mom has not heard that one before. She does not believe it. She would have preferred the truth."
183
        } else {
184
            "Mom has not heard that one before. She does not believe it."
185
        };
186
        Err(self.mom_strike(challenge(Problem::new(
187
            StatusCode::UNAUTHORIZED,
188
            "excuse-rejected",
189
            detail,
190
        ))))
191
    }
192
193
    pub(super) fn errand(&mut self, summoned: bool) -> &'static str {
194
        if summoned {
195
            return "She heard a noise.";
196
        }
197
        let errands: &[&'static str] = if self.floor == Floor::Grandmas {
198
            &[
199
                "Grandma would like to see you.",
200
                "Dinner is in ten minutes.",
201
                "She needs help with the folding table.",
202
                "An uncle has asked where you went.",
203
            ]
204
        } else {
205
            &[
206
                "She has brought laundry.",
207
                "She wants to know if you have eaten.",
208
                "She needs the wifi password.",
209
                "She would like to talk about your future.",
210
            ]
211
        };
212
        errands[self.rng.random_range(0..errands.len())]
213
    }
214
215
    fn mom_strike(&mut self, problem: Problem) -> Problem {
216
        self.mom_strikes += 1;
217
        self.touch();
218
        if self.mom_strikes < PATIENCE {
219
            return problem;
220
        }
221
        self.end("caught", CAUGHT);
222
        Problem::new(StatusCode::GONE, "caught", CAUGHT)
223
    }
224
225
    fn elder_strike(&mut self, problem: Problem) -> Problem {
226
        self.elder_strikes += 1;
227
        self.touch();
228
        if self.elder_strikes < PATIENCE {
229
            return problem;
230
        }
231
        self.end("banned", BANNED);
232
        Problem::new(StatusCode::FORBIDDEN, "banned", BANNED)
233
    }
234
235
    fn leave_door(&mut self) {
236
        self.mom_strikes = 0;
237
        self.mom_present = false;
238
        self.suspicion = 0;
239
        self.touch();
240
    }
241
242
    pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {
243
        let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)
244
            && self.rng.random_ratio(u32::from(self.suspicion) + 1, 32);
245
        if self.suspicion < SUSPICION_LIMIT && !wanders_in {
246
            return Ok(());
247
        }
248
        match self.floor {
249
            Floor::Bathroom => {
250
                self.suspicion = 0;
251
                self.touch();
252
                if self.items.door_locks > 0 {
253
                    self.items.door_locks -= 1;
254
                    return Ok(());
255
                }
256
                self.roommate_told = true;
257
                Err(Problem::new(
258
                    StatusCode::CONFLICT,
259
                    "roommate",
260
                    "The roommate needs the bathroom. He will mention this to people.",
261
                ))
262
            }
263
            Floor::LockerRoom => {
264
                self.suspicion = 0;
265
                self.shame += 5;
266
                self.touch();
267
                Err(Problem::new(
268
                    StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
269
                    "gym-staff",
270
                    "Gym staff have been notified. The server cannot serve this request for legal reasons.",
271
                ))
272
            }
273
            Floor::Hotel => {
274
                self.suspicion = 0;
275
                self.touch();
276
                if self.items.dnd_sign {
277
                    return Ok(());
278
                }
279
                self.shame += 3;
280
                Err(Problem::new(
281
                    StatusCode::UNAUTHORIZED,
282
                    "housekeeping",
283
                    "Housekeeping has entered. Housekeeping accepts no excuses.",
284
                )
285
                .header(header::WWW_AUTHENTICATE, "Excuse realm=\"housekeeping\""))
286
            }
287
            _ => Ok(()),
288
        }
289
    }
290
291
    pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
292
        self.drawer_searches += 1;
293
        self.touch();
294
        let first = self.drawer_searches == 1;
295
        let id = self.id.clone();
296
        let found = |slug: &str, detail: &str| {
297
            Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))
298
                .header(header::LOCATION, format!("/runs/{id}/inventory/{slug}"))
299
        };
300
        match self.floor {
301
            Floor::Bedroom if first && !self.items.ruler => {
302
                self.items.ruler = true;
303
                self.shame += 10;
304
                Ok(found(
305
                    "ruler",
306
                    "You now know. This is the first honest measurement of the run.",
307
                ))
308
            }
309
            Floor::Bathroom if first => {
310
                self.items.door_locks += 1;
311
                Ok(found("door-lock", "A door lock. It works once."))
312
            }
313
            Floor::LockerRoom => {
314
                self.shame += 3;
315
                Err(Problem::new(
316
                    StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
317
                    "not-your-locker",
318
                    "That locker belongs to Greg. The server cannot serve this request for legal reasons.",
319
                ))
320
            }
321
            Floor::Grandmas if first => {
322
                self.items.cage = true;
323
                self.shame += 5;
324
                Ok(found(
325
                    "chastity-cage",
326
                    "A small box. Inside was a cage. It is now on. The key is not in the box.",
327
                ))
328
            }
329
            Floor::Grandmas if self.items.cage => {
330
                self.items.cage = false;
331
                Ok(Outcome::json(
332
                    StatusCode::OK,
333
                    json!({ "detail": "The key was under where the box had been. The cage is off." }),
334
                ))
335
            }
336
            Floor::Hotel if first => {
337
                self.items.dnd_sign = true;
338
                self.items.ice_packs += 2;
339
                Ok(found(
340
                    "do-not-disturb-sign",
341
                    "A Do Not Disturb sign, and two ice packs from the machine down the hall.",
342
                ))
343
            }
344
            Floor::Forum if first => {
345
                self.items.spreadsheet = true;
346
                Ok(found(
347
                    "spreadsheet",
348
                    "Your own spreadsheet. Every line is flat.",
349
                ))
350
            }
351
            Floor::Clinic => Err(Problem::new(
352
                StatusCode::FORBIDDEN,
353
                "doctors-drawer",
354
                "That is the doctor's drawer.",
355
            )),
356
            _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),
357
        }
358
    }
359
360
    pub fn shop_view(&self) -> Result<Value, Problem> {
361
        if !self.floor.shop_open() {
362
            return Err(pump_bro_absent());
363
        }
364
        let stock: Vec<Value> = SHOP
365
            .iter()
366
            .map(|item| {
367
                json!({
368
                    "item": item.slug,
369
                    "price": item.price,
370
                    "description": item.description,
371
                    "href": format!("/runs/{}/shop/{}", self.id, item.slug),
372
                    "method": "POST",
373
                })
374
            })
375
            .collect();
376
        Ok(json!({ "merchant": "Pump-Bro", "cash": self.cash, "stock": stock }))
377
    }
378
379
    pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {
380
        if !self.floor.shop_open() {
381
            return Err(pump_bro_absent());
382
        }
383
        let Some(item) = SHOP.iter().find(|i| i.slug == slug) else {
384
            return Err(Problem::new(
385
                StatusCode::NOT_FOUND,
386
                "not-for-sale",
387
                "Pump-Bro does not sell that. He knows a guy.",
388
            ));
389
        };
390
        if slug == "pump" {
391
            return Err(Problem::new(
392
                StatusCode::UPGRADE_REQUIRED,
393
                "upsell",
394
                "This pump requires an upgrade.",
395
            )
396
            .header(header::UPGRADE, "pump-pro/2.0")
397
            .with("upgrade", format!("/runs/{}/shop/pump-pro", self.id)));
398
        }
399
        if self.cash < item.price {
400
            return Err(Problem::new(
401
                StatusCode::PAYMENT_REQUIRED,
402
                "insufficient-funds",
403
                "Payment is required. RFC 9110 has reserved this status for future use since 1997. The future is here and you cannot afford it.",
404
            )
405
            .with("cash", self.cash)
406
            .with("price", item.price));
407
        }
408
        self.cash -= item.price;
409
        self.touch();
410
        let items = &mut self.items;
411
        match slug {
412
            "supplement" => {
413
                items.supplements += 1;
414
                return Err(Problem::new(
415
                    StatusCode::GATEWAY_TIMEOUT,
416
                    "still-shipping",
417
                    "The supplement is still shipping. You have been charged. It may arrive.",
418
                )
419
                .with("cash", self.cash));
420
            }
421
            "pump-pro" => items.pump_pro = true,
422
            "extender" => items.extender = true,
423
            "cock-ring" => items.ring = true,
424
            "door-lock" => items.door_locks += 1,
425
            "do-not-disturb-sign" => items.dnd_sign = true,
426
            "ruler" => items.ruler = true,
427
            "ice-pack" => items.ice_packs += 1,
428
            _ => items.cage = false,
429
        }
430
        Ok(Outcome::json(
431
            StatusCode::CREATED,
432
            json!({ "detail": "Purchased. No refunds.", "cash": self.cash }),
433
        )
434
        .header(
435
            header::LOCATION,
436
            format!("/runs/{}/inventory/{slug}", self.id),
437
        ))
438
    }
439
440
    pub fn drop_item(&mut self, slug: &str) -> Result<Outcome, Problem> {
441
        match slug {
442
            "extender" => self.items.extender = false,
443
            "cock-ring" => self.items.ring = false,
444
            "chastity-cage" => {
445
                return Err(Problem::new(
446
                    StatusCode::LOCKED,
447
                    "caged",
448
                    "The cage is locked.",
449
                ));
450
            }
451
            _ => {
452
                return Err(Problem::new(
453
                    StatusCode::FORBIDDEN,
454
                    "cannot-be-dropped",
455
                    "That cannot be dropped.",
456
                ));
457
            }
458
        }
459
        self.touch();
460
        Ok(Outcome::empty(StatusCode::NO_CONTENT))
461
    }
462
463
    pub(super) fn branch_offer(&self) -> Outcome {
464
        let forum = format!("/runs/{}/branches/forum", self.id);
465
        let close = format!("/runs/{}/branches/close-tab", self.id);
466
        Outcome::json(
467
            StatusCode::MULTIPLE_CHOICES,
468
            json!({
469
                "detail": "A browser tab is open. It has been open for some time.",
470
                "choices": [
471
                    { "href": forum, "method": "POST", "title": "Read the forum" },
472
                    { "href": close, "method": "POST", "title": "Close the tab" },
473
                ],
474
            }),
475
        )
476
        .header(
477
            header::LINK,
478
            format!(
479
                "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
480
            ),
481
        )
482
    }
483
484
    pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
485
        if !self.branch_pending {
486
            return Err(Problem::new(
487
                StatusCode::CONFLICT,
488
                "no-decision-pending",
489
                "There is no decision pending.",
490
            ));
491
        }
492
        let (location, detail) = match choice {
493
            "forum" => {
494
                self.forum_read = true;
495
                self.shame += 3;
496
                (
497
                    format!("/runs/{}/techniques", self.id),
498
                    "The thread is from 2009. A technique called uli is discussed at length.",
499
                )
500
            }
501
            "close-tab" => {
502
                self.suspicion = 0;
503
                (
504
                    format!("/runs/{}", self.id),
505
                    "The tab is closed. Nobody saw it.",
506
                )
507
            }
508
            _ => {
509
                return Err(Problem::new(
510
                    StatusCode::NOT_FOUND,
511
                    "no-such-choice",
512
                    "That was not one of the choices.",
513
                ));
514
            }
515
        };
516
        self.branch_pending = false;
517
        self.touch();
518
        Ok(
519
            Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))
520
                .header(header::LOCATION, location),
521
        )
522
    }
523
524
    pub fn put(&mut self) -> Outcome {
525
        if self.put_applied {
526
            return Outcome::empty(StatusCode::NO_CONTENT);
527
        }
528
        self.put_applied = true;
529
        self.length += 0.01;
530
        self.touch();
531
        Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))
532
    }
533
534
    pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {
535
        for op in ops {
536
            if op.op != "test" {
537
                return Err(Problem::new(
538
                    StatusCode::UNPROCESSABLE_ENTITY,
539
                    "wishful-thinking",
540
                    "The server accepts only the truth.",
541
                )
542
                .with("op", op.op.clone()));
543
            }
544
            let actual = match op.path.as_str() {
545
                "/length" => self.current_length(),
546
                "/girth" => self.girth,
547
                "/foreskin" => self.foreskin.unwrap_or(0.0),
548
                _ => {
549
                    return Err(Problem::new(
550
                        StatusCode::UNPROCESSABLE_ENTITY,
551
                        "no-such-measurement",
552
                        "There is no such measurement.",
553
                    )
554
                    .with("path", op.path.clone()));
555
                }
556
            };
557
            let Some(claimed) = op.value.as_f64() else {
558
                return Err(Problem::new(
559
                    StatusCode::BAD_REQUEST,
560
                    "malformed-patch",
561
                    "A test requires a numeric value.",
562
                ));
563
            };
564
            if op.path == "/length" && claimed >= actual + 1.0 {
565
                self.shame += 2;
566
                self.touch();
567
                return Err(exaggeration());
568
            }
569
            if !honest(claimed, actual) {
570
                self.shame += 1;
571
                self.touch();
572
                return Err(
573
                    Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")
574
                        .with("path", op.path.clone()),
575
                );
576
            }
577
        }
578
        Ok(Outcome::json(
579
            StatusCode::OK,
580
            json!({ "detail": "Correct." }),
581
        ))
582
    }
583
584
    pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {
585
        if self.foreskin.is_none() {
586
            return Err(Problem::new(
587
                StatusCode::GONE,
588
                "foreskin-gone",
589
                "The foreskin is gone. It will not be returning.",
590
            ));
591
        }
592
        let Some(if_match) = if_match else {
593
            return Err(Problem::new(
594
                StatusCode::PRECONDITION_REQUIRED,
595
                "confirmation-required",
596
                "The server requires you to be sure. Send If-Match.",
597
            ));
598
        };
599
        if !super::etag_matches(if_match, &self.etag()) {
600
            return Err(Problem::new(
601
                StatusCode::PRECONDITION_FAILED,
602
                "stale-foreskin",
603
                "The foreskin has changed since you last looked at it.",
604
            ));
605
        }
606
        self.foreskin = None;
607
        self.shame += 20;
608
        self.touch();
609
        Ok(Outcome::empty(StatusCode::NO_CONTENT))
610
    }
611
612
    pub fn set_grip(&mut self, grip: Grip) {
613
        self.grip = grip;
614
        self.touch();
615
    }
616
617
    pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {
618
        if self.floor != Floor::Hotel {
619
            return Err(Problem::new(
620
                StatusCode::NOT_FOUND,
621
                "no-portal",
622
                "There is no captive portal here. There is only the regular kind of captivity.",
623
            ));
624
        }
625
        self.wifi_accepted = true;
626
        self.touch();
627
        Ok(Outcome::json(
628
            StatusCode::SEE_OTHER,
629
            json!({ "detail": "Connected. The hotel does not want to know." }),
630
        )
631
        .header(header::LOCATION, format!("/runs/{}", self.id)))
632
    }
633
634
    pub fn elder(
635
        &mut self,
636
        proxy_authorization: Option<&str>,
637
        if_modified_since: Option<&str>,
638
    ) -> Result<Outcome, Problem> {
639
        if self.floor != Floor::Forum {
640
            return Err(Problem::new(
641
                StatusCode::NOT_FOUND,
642
                "elder-absent",
643
                "The Elder only posts on the forum.",
644
            ));
645
        }
646
        if !self.verified {
647
            let verification = |detail: &str| {
648
                Problem::new(
649
                    StatusCode::PROXY_AUTHENTICATION_REQUIRED,
650
                    "verification-required",
651
                    detail,
652
                )
653
                .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")
654
            };
655
            let Some(credentials) = proxy_authorization else {
656
                return Err(verification("The forum requires verification."));
657
            };
658
            let Some(claimed) =
659
                parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())
660
            else {
661
                return Err(verification("The forum requires verification, in inches."));
662
            };
663
            let actual = self.current_length();
664
            if claimed >= actual + 1.0 {
665
                self.shame += 2;
666
                return Err(self.elder_strike(exaggeration()));
667
            }
668
            if !honest(claimed, actual) {
669
                self.shame += 1;
670
                return Err(
671
                    self.elder_strike(verification("Verification failed. The forum can tell."))
672
                );
673
            }
674
            self.verified = true;
675
            self.touch();
676
        }
677
678
        let last_modified =
679
            httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");
680
        let current = if_modified_since
681
            .and_then(|d| httpdate::parse_http_date(d).ok())
682
            .is_some_and(|since| since >= last_modified);
683
        if current {
684
            self.elder_beaten = true;
685
            self.touch();
686
            return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
687
                .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));
688
        }
689
        Ok(
690
            Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
691
                .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED),
692
        )
693
    }
694
695
    pub fn diagnose(
696
        &mut self,
697
        authorization: Option<&str>,
698
        if_none_match: Option<&str>,
699
    ) -> Result<Outcome, Problem> {
700
        if self.floor != Floor::Clinic {
701
            let floors = Floor::Clinic.number() - self.floor.number();
702
            return Err(Problem::new(
703
                StatusCode::TOO_EARLY,
704
                "no-appointment",
705
                format!("The clinic's next available appointment is {floors} floors away."),
706
            ));
707
        }
708
        let intake = Problem::new(
709
            StatusCode::UNAUTHORIZED,
710
            "intake",
711
            "The clinic requires authorization.",
712
        );
713
        let Some(authorization) = authorization else {
714
            return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));
715
        };
716
        let truthful = parse_credentials(authorization, "bearer")
717
            .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));
718
        if !truthful {
719
            let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];
720
            self.intake_attempts += 1;
721
            return Err(intake.header(
722
                header::WWW_AUTHENTICATE,
723
                format!(
724
                    "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""
725
                ),
726
            ));
727
        }
728
729
        self.peyronie = false;
730
        let etag = self.origin_etag.clone();
731
        if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {
732
            self.cured = true;
733
            return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
734
                .header(header::ETAG, etag)
735
                .header(X_DIAGNOSIS, "normal"));
736
        }
737
        if !self.measured {
738
            self.measured = true;
739
            if !self.items.ruler {
740
                self.shame += 15;
741
            }
742
        }
743
        Ok(Outcome::json(
744
            StatusCode::OK,
745
            json!({
746
                "diagnosis": "within normal range",
747
                "measured_length": round2(self.length),
748
                "you_said": round2(self.self_reported()),
749
                "prescription": "Please stop.",
750
            }),
751
        )
752
        .header(header::ETAG, etag))
753
    }
754
}
755
756
fn pump_bro_absent() -> Problem {
757
    Problem::new(
758
        StatusCode::NOT_FOUND,
759
        "pump-bro-absent",
760
        "Pump-Bro is not here.",
761
    )
762
}