atagen

jelq

public

an http based roguelike rpg about jelqing

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