atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
14de3e1e8dc2a460103dbac0f56b745af96da78e
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/turn.rsRust721 lines25.6 KB
1
use std::{fmt::Write as _, ops::ControlFlow, time::Instant};
2
3
use axum::http::{StatusCode, header};
4
use rand::Rng;
5
use serde_json::json;
6
7
use super::{
8
    Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
9
    balance::{
10
        ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, BURN_RUPTURE, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL,
11
        DANGER_EQ, EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FRICTION_BURN, FRICTION_BURN_AT,
12
        FRICTION_DRY, FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT,
13
        GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ,
14
        PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM,
15
        RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
16
    },
17
    not_extended, round3,
18
    state::{MumVisit, Observer, Pending},
19
    techniques::{LubeRule, Profile, Training, Utility},
20
};
21
use crate::{headers::SERVER_TIMING, problem::Problem};
22
23
impl Run {
24
    pub fn jelq(
25
        &mut self,
26
        techniques: &[Technique],
27
        intensity: i32,
28
        lube: Option<Lube>,
29
        now: Instant,
30
    ) -> Result<Outcome, Problem> {
31
        self.check_refractory(now)?;
32
        if self.story == Story::Pending {
33
            return Ok(self.branch_offer());
34
        }
35
        if self.floor == Floor::Forum {
36
            return Ok(Outcome::json(
37
                StatusCode::SEE_OTHER,
38
                json!({ "detail": "Refer to the forum." }),
39
            )
40
            .header(header::LOCATION, super::FORUM_THREAD));
41
        }
42
        if self.items.cage {
43
            return Err(Problem::new(
44
                StatusCode::LOCKED,
45
                "caged",
46
                "The cage is locked. The key is not in the box.",
47
            ));
48
        }
49
        self.here.shopping = false;
50
        match *techniques {
51
            [Technique::Utility(utility)] => self.utility(utility, lube, now),
52
            [Technique::Training(training)] => self.single(training, intensity, lube, now),
53
            [Technique::Training(a), Technique::Training(b)] => {
54
                self.combo(a, b, intensity, lube, now)
55
            }
56
            [_, _] => Err(Problem::new(
57
                StatusCode::BAD_REQUEST,
58
                "combo-invalid",
59
                "Only training techniques can be combined.",
60
            )),
61
            _ => Err(Problem::new(
62
                StatusCode::BAD_REQUEST,
63
                "too-many-techniques",
64
                "Two techniques at most. The server has limits even if you do not.",
65
            )),
66
        }
67
    }
68
69
    fn utility(
70
        &mut self,
71
        utility: Utility,
72
        lube: Option<Lube>,
73
        now: Instant,
74
    ) -> Result<Outcome, Problem> {
75
        match utility {
76
            Utility::Warmup => return self.warm_up(),
77
            Utility::Ice => return self.ice(),
78
            Utility::Supplement => return self.supplement(),
79
            Utility::Meditate => return self.meditate(),
80
            Utility::Energy => return self.energy(),
81
            Utility::Edge | Utility::Pump => {}
82
        }
83
        if utility == Utility::Pump {
84
            self.here.edges = 0;
85
        }
86
        self.ready(Technique::Utility(utility), lube)?;
87
        self.check_storage()?;
88
        self.floor_enemy()?;
89
        match utility {
90
            Utility::Edge => self.edge(now),
91
            _ => self.pump(now),
92
        }
93
    }
94
95
    fn single(
96
        &mut self,
97
        training: Training,
98
        intensity: i32,
99
        lube: Option<Lube>,
100
        now: Instant,
101
    ) -> Result<Outcome, Problem> {
102
        self.here.edges = 0;
103
        self.ready(Technique::Training(training), lube)?;
104
        self.check_storage()?;
105
        self.lube_available(lube)?;
106
        self.floor_enemy()?;
107
        self.take_lube(lube)?;
108
        self.chafe(lube);
109
        self.commit(intensity, lube, now, 1);
110
        if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
111
            return Ok(interrupted);
112
        }
113
        if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
114
            return Ok(interrupted);
115
        }
116
        let outcome = self.roll(training, intensity, lube)?;
117
        Ok(self.gossip(outcome))
118
    }
119
120
    fn combo(
121
        &mut self,
122
        a: Training,
123
        b: Training,
124
        intensity: i32,
125
        lube: Option<Lube>,
126
        now: Instant,
127
    ) -> Result<Outcome, Problem> {
128
        if a == b {
129
            return Err(Problem::new(
130
                StatusCode::BAD_REQUEST,
131
                "combo-invalid",
132
                "A technique cannot be combined with itself. The server has checked.",
133
            ));
134
        }
135
        self.here.edges = 0;
136
        self.ready(Technique::Training(a), lube)?;
137
        self.ready(Technique::Training(b), lube)?;
138
        self.check_storage()?;
139
        self.lube_available(lube)?;
140
        self.floor_enemy()?;
141
        self.take_lube(lube)?;
142
        self.chafe(lube);
143
        self.commit(intensity, lube, now, 2);
144
        for training in [a, b] {
145
            if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
146
                return Ok(interrupted);
147
            }
148
        }
149
        if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
150
            return Ok(interrupted);
151
        }
152
153
        let mut statuses = Vec::new();
154
        for training in [a, b] {
155
            let result = self.roll(training, intensity, lube);
156
            if self.ended.is_some() {
157
                return result;
158
            }
159
            let status = match result {
160
                Ok(outcome) => self.gossip(outcome).status,
161
                Err(problem) => problem.status(),
162
            };
163
            statuses.push((training, status));
164
        }
165
166
        let mut xml = String::from(
167
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
168
        );
169
        for (training, status) in statuses {
170
            let _ = write!(
171
                xml,
172
                "  <D:response>\n    <D:href>{}?technique={}</D:href>\n    <D:status>HTTP/1.1 {} {}</D:status>\n  </D:response>\n",
173
                self.path(""),
174
                training.name(),
175
                status.as_u16(),
176
                crate::problem::reason(status),
177
            );
178
        }
179
        xml.push_str(
180
            "  <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
181
        );
182
        Ok(Outcome::raw(
183
            StatusCode::MULTI_STATUS,
184
            "application/xml; charset=utf-8",
185
            xml,
186
        ))
187
    }
188
189
    fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
190
        self.access(technique)?;
191
        match technique {
192
            Technique::Training(training) => Self::check_lube(training, lube),
193
            Technique::Utility(_) => Ok(()),
194
        }
195
    }
196
197
    /// Whether the run could attempt the technique now, ignoring the lubricant.
198
    fn access(&self, technique: Technique) -> Result<(), Problem> {
199
        if technique == Technique::Training(Training::Uli) && !self.forum_read() {
200
            return Err(Problem::new(
201
                StatusCode::FORBIDDEN,
202
                "technique-locked",
203
                "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not been to the forum.",
204
            ));
205
        }
206
        if technique == Technique::Utility(Utility::Meditate) {
207
            return if self.meditation_taught {
208
                Ok(())
209
            } else {
210
                Err(Problem::new(
211
                    StatusCode::FORBIDDEN,
212
                    "technique-locked",
213
                    "The breathing technique is described in one of the forum's replies. You have not found it.",
214
                ))
215
            };
216
        }
217
        if !self.warmed_up {
218
            return Err(Problem::new(
219
                StatusCode::PRECONDITION_FAILED,
220
                "cold-start",
221
                "Warm-up was skipped. The server has noted this.",
222
            )
223
            .with("technique", Utility::Warmup.name()));
224
        }
225
        match technique {
226
            Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(
227
                StatusCode::FAILED_DEPENDENCY,
228
                "no-pump",
229
                "The pump technique depends on a pump.",
230
            )),
231
            Technique::Utility(_) => Ok(()),
232
            Technique::Training(training) => match training.profile().foreskin {
233
                Some((min, max)) => self.check_foreskin(min, max),
234
                None => Ok(()),
235
            },
236
        }
237
    }
238
239
    fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {
240
        match (training.profile().lube, lube) {
241
            (LubeRule::Required, None) => Err(Problem::new(
242
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
243
                "lubricant-required",
244
                format!(
245
                    "The {} technique requires a lubricant. None was declared.",
246
                    training.name()
247
                ),
248
            )
249
            .with("lubricants", json!(LUBRICANTS))),
250
            (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
251
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
252
                "lubricant-declared",
253
                format!(
254
                    "The {} technique was requested. A lubricant was declared.",
255
                    training.name()
256
                ),
257
            )),
258
            _ => Ok(()),
259
        }
260
    }
261
262
    /// One unit of whatever was declared. Spit is free and the tap is always on.
263
    fn lube_available(&self, lube: Option<Lube>) -> Result<(), Problem> {
264
        let (held, name) = match lube {
265
            None | Some(Lube::Spit) => return Ok(()),
266
            Some(Lube::BabyOil) => (&self.items.baby_oil, "baby-oil"),
267
            Some(Lube::CoconutOil) => (&self.items.coconut_oil, "coconut-oil"),
268
            Some(Lube::Lotion) => (&self.items.lotion, "lotion"),
269
            Some(Lube::Vaseline) => (&self.items.vaseline, "vaseline"),
270
        };
271
        if *held == 0 {
272
            return Err(Problem::new(
273
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
274
                "lubricant-exhausted",
275
                format!("{name} was declared, but the bottle is empty."),
276
            ));
277
        }
278
        Ok(())
279
    }
280
281
    fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
282
        self.lube_available(lube)?;
283
        let held = match lube {
284
            None | Some(Lube::Spit) => return Ok(()),
285
            Some(Lube::BabyOil) => &mut self.items.baby_oil,
286
            Some(Lube::CoconutOil) => &mut self.items.coconut_oil,
287
            Some(Lube::Lotion) => &mut self.items.lotion,
288
            Some(Lube::Vaseline) => &mut self.items.vaseline,
289
        };
290
        *held -= 1;
291
        Ok(())
292
    }
293
294
    /// The techniques a JELQ could use right now without being refused before the roll.
295
    pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {
296
        let blocked = !self.floor.timed()
297
            || self.items.cage
298
            || self.refractory_until.is_some_and(|until| now < until);
299
        if blocked {
300
            return Vec::new();
301
        }
302
        Technique::ALL
303
            .into_iter()
304
            .filter(|&technique| match technique {
305
                Technique::Utility(Utility::Warmup) => true,
306
                Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
307
                Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
308
                Technique::Utility(Utility::Meditate) => {
309
                    self.meditation_taught && !self.here.meditated
310
                }
311
                Technique::Utility(Utility::Energy) => self.items.energy_drinks > 0,
312
                _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
313
            })
314
            .collect()
315
    }
316
317
    fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
318
        let Some(f) = self.foreskin else {
319
            if min <= 0.0 {
320
                return Ok(());
321
            }
322
            return Err(Problem::new(
323
                StatusCode::GONE,
324
                "foreskin-gone",
325
                "This technique requires a foreskin. The foreskin is gone.",
326
            )
327
            .header(header::CONTENT_LOCATION, self.path("/foreskin")));
328
        };
329
        if f < min {
330
            return Err(Problem::new(
331
                StatusCode::LENGTH_REQUIRED,
332
                "foreskin-insufficient",
333
                format!(
334
                    "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
335
                ),
336
            )
337
            .with("required", min));
338
        }
339
        if f > max {
340
            return Err(Problem::new(
341
                StatusCode::PAYLOAD_TOO_LARGE,
342
                "foreskin-excessive",
343
                format!(
344
                    "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
345
                ),
346
            )
347
            .with("maximum", max));
348
        }
349
        Ok(())
350
    }
351
352
    fn check_storage(&self) -> Result<(), Problem> {
353
        if self.items.extender && self.girth > STORAGE_GIRTH {
354
            return Err(Problem::new(
355
                StatusCode::INSUFFICIENT_STORAGE,
356
                "jeans-full",
357
                "The jeans are full. The extender is occupying the remaining storage.",
358
            )
359
            .with("extender", self.path("/inventory/extender")));
360
        }
361
        Ok(())
362
    }
363
364
    fn chafe(&mut self, lube: Option<Lube>) {
365
        let mut friction = match lube {
366
            None => FRICTION_DRY,
367
            Some(Lube::Spit) => FRICTION_SPIT,
368
            Some(_) => 0,
369
        };
370
        if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
371
            friction -= FRICTION_GRIP;
372
        }
373
        self.jelq_grip = Some(self.grip);
374
        self.friction = (self.friction + friction).min(FRICTION_MAX);
375
        if self.friction >= FRICTION_BURN_AT
376
            && !self.conditions.friction_burn
377
            && self.chance(FRICTION_BURN)
378
        {
379
            self.conditions.friction_burn = true;
380
        }
381
    }
382
383
    pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
384
        self.touch();
385
        self.turn += 1;
386
        self.here.turns += 1;
387
        self.realise();
388
        if self.items.extender {
389
            self.length += EXTENDER_GAIN;
390
        }
391
        self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
392
        if self.stamina <= 0 {
393
            self.exhaust(now);
394
        }
395
        let chill = if self.floor == Floor::ChangingRoom {
396
            CHANGING_ROOM_CHILL
397
        } else {
398
            0
399
        };
400
        let wobble = self.rng.random_range(-EQ_WOBBLE..=EQ_WOBBLE);
401
        self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
402
403
        let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
404
        if matches!(self.floor, Floor::ChangingRoom | Floor::Nans) {
405
            suspicion *= 2;
406
        }
407
        if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
408
            suspicion += 2;
409
        }
410
        let was_below = self.suspicion < SUSPICION_LIMIT;
411
        self.suspicion =
412
            u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))
413
                .unwrap_or(SUSPICION_LIMIT);
414
415
        if intensity >= PULLED_MUSCLE_INTENSITY
416
            && !self.conditions.pulled_muscle
417
            && self.chance(PULLED_MUSCLE)
418
        {
419
            self.conditions.pulled_muscle = true;
420
            self.stamina = self.stamina.min(self.max_stamina());
421
        }
422
        self.settle(was_below);
423
    }
424
425
    /// The part of a turn the house takes part in, whatever the action was.
426
    pub(super) fn settle(&mut self, was_below: bool) {
427
        self.maybe_summon_mum(was_below);
428
        if let Some(observer) = self.floor.observer()
429
            && self.chance(OBSERVED)
430
        {
431
            self.observed = Some(observer);
432
            self.shame += 1;
433
            if observer == Observer::Cat && self.chance(CAT_TAKES_TOWEL) {
434
                self.here.towel_stolen = true;
435
            }
436
        }
437
        if self.items.ring && self.eq > HOT_EQ && self.chance(RING_PRIAPISM) {
438
            self.conditions.priapism_turns = PRIAPISM_TURNS;
439
        }
440
        if self.conditions.priapism_turns > 0 {
441
            self.conditions.priapism_turns -= 1;
442
            self.eq = self.eq.max(PRIAPISM_EQ);
443
        }
444
        if self.turn == BRANCH_TURN && self.story == Story::Unopened {
445
            self.story = Story::Pending;
446
            self.notices
447
                .push("Somewhere, a forum thread from 2009 still has replies.");
448
        }
449
    }
450
451
    /// HOLD. A pause between strokes, offered when the band has been left or the stamina is gone.
452
    pub fn can_hold(&self) -> bool {
453
        self.eq > DANGER_EQ || self.stamina <= 2
454
    }
455
456
    pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {
457
        self.check_refractory(now)?;
458
        if self.story == Story::Pending {
459
            return Ok(self.branch_offer());
460
        }
461
        if self.items.cage {
462
            return Err(Problem::new(
463
                StatusCode::LOCKED,
464
                "caged",
465
                "The cage is locked. The key is not in the box.",
466
            ));
467
        }
468
        self.touch();
469
        self.turn += 1;
470
        self.here.turns += 1;
471
        self.realise();
472
        let was_below = self.suspicion < SUSPICION_LIMIT;
473
        if self.chance(REFLECTION) {
474
            self.eq = 0;
475
            self.shame += 1;
476
            self.notices
477
                .push("the count reached ten, and then the reflection started");
478
        } else {
479
            self.eq = (self.eq - HOLD_EQ).clamp(0, 100);
480
        }
481
        self.stamina = (self.stamina + 1).min(self.max_stamina());
482
        self.shed(FRICTION_HOLD);
483
        self.settle(was_below);
484
        Ok(Outcome::empty(StatusCode::NO_CONTENT))
485
    }
486
487
    /// The breathing technique from the forum's replies. It spends no stamina and the turn is
488
    /// not the floor's, but each floor allows it once.
489
    fn meditate(&mut self) -> Result<Outcome, Problem> {
490
        self.access(Technique::Utility(Utility::Meditate))?;
491
        if self.here.meditated {
492
            return Err(Problem::new(
493
                StatusCode::CONFLICT,
494
                "already-meditated",
495
                "This floor has had its meditation.",
496
            ));
497
        }
498
        self.here.meditated = true;
499
        self.reset_eq(self.eq - MEDITATION_EQ);
500
        self.shed(FRICTION_MEDITATE);
501
        self.touch();
502
        Ok(Outcome::json(
503
            StatusCode::OK,
504
            json!({
505
                "detail": "In through the nose, out through the mouth. EQ falls by ten.",
506
            }),
507
        ))
508
    }
509
510
    pub(super) fn maybe_summon_mum(&mut self, was_below: bool) {
511
        if !self.floor.has_mum() || self.mum.is_some() {
512
            return;
513
        }
514
        let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
515
        let mut visits = u32::from(self.suspicion) + 1;
516
        if self.here.brother_told {
517
            visits *= 2;
518
        }
519
        if summoned || self.rng.random_ratio(visits, super::balance::MUM_VISIT) {
520
            let errand = self.errand(summoned);
521
            let nonce = self.fresh_nonce();
522
            self.mum = Some(MumVisit {
523
                errand,
524
                nonce,
525
                strikes: 0,
526
                knocked: false,
527
            });
528
        }
529
    }
530
531
    fn roll(
532
        &mut self,
533
        training: Training,
534
        intensity: i32,
535
        lube: Option<Lube>,
536
    ) -> Result<Outcome, Problem> {
537
        let profile = training.profile();
538
        let natural: i32 = self.rng.random_range(1..=20);
539
        let band = if EQ_BAND.contains(&self.eq) {
540
            BAND_BONUS
541
        } else {
542
            0
543
        };
544
        let grip = match (self.grip, training) {
545
            (Grip::Underhand, Training::Stretch) => 2,
546
            (Grip::Underhand, Training::Wet) => -1,
547
            _ => 0,
548
        };
549
        let arrival = if self.here.warm_arrival {
550
            self.here.warm_arrival = false;
551
            ARRIVAL_BONUS
552
        } else {
553
            0
554
        };
555
        let modifier = lube.map_or(0, Lube::modifier) + band + grip + arrival;
556
        let dc = profile.dc + intensity / 3;
557
        let total = natural + modifier;
558
        let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
559
        let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
560
561
        let (length, girth) = self.scaled_gains(&profile, intensity);
562
        let burn = if self.conditions.friction_burn {
563
            BURN_RUPTURE
564
        } else {
565
            0
566
        };
567
        let rupture_line = if self.items.ring {
568
            RING_RUPTURE_LINE
569
        } else {
570
            RUPTURE_LINE
571
        };
572
573
        if natural == 1 {
574
            return Err(timed(self.critical_failure(training, &profile)));
575
        }
576
        if natural == 2 && self.eq > DANGER_EQ - burn {
577
            return Err(timed(self.rupture()));
578
        }
579
        if self.eq > rupture_line - burn && total < dc {
580
            return Err(timed(self.rupture()));
581
        }
582
        if natural == 20 {
583
            self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
584
            return Ok(Outcome::json(
585
                StatusCode::PERMANENT_REDIRECT,
586
                json!({
587
                    "detail": "Your length has moved permanently.",
588
                    "length": round3(length * 2.0),
589
                    "girth": round3(girth * 2.0),
590
                }),
591
            )
592
            .header(header::LOCATION, self.path("/length"))
593
            .header(SERVER_TIMING, timing));
594
        }
595
        if total < dc - 3 {
596
            return Err(timed(not_extended()));
597
        }
598
        if total < dc {
599
            return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
600
        }
601
602
        let outcome = if self.eq < *EQ_BAND.start() {
603
            self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
604
            Outcome::json(
605
                StatusCode::PARTIAL_CONTENT,
606
                json!({
607
                    "detail": "Partial gains were applied. EQ was below the band.",
608
                    "length": round3(length / 2.0),
609
                    "girth": round3(girth / 2.0),
610
                }),
611
            )
612
        } else if total >= dc + 5 {
613
            self.gain(length, girth, profile.foreskin_growth);
614
            Outcome::json(
615
                StatusCode::OK,
616
                json!({
617
                    "detail": "Gains were applied.",
618
                    "length": round3(length),
619
                    "girth": round3(girth),
620
                }),
621
            )
622
        } else {
623
            let eta = self.rng.random_range(1..=4);
624
            self.pending.push(Pending {
625
                due: self.turn + eta,
626
                length,
627
                girth,
628
            });
629
            self.gain(0.0, 0.0, profile.foreskin_growth);
630
            Outcome::json(
631
                StatusCode::ACCEPTED,
632
                json!({
633
                    "detail": "Gains have been queued. They may arrive.",
634
                    "eta_turns": eta,
635
                }),
636
            )
637
        };
638
        Ok(outcome.header(SERVER_TIMING, timing))
639
    }
640
641
    fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {
642
        let scale = f64::from(intensity);
643
        let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
644
        let greed = if self.eq > HOT_EQ {
645
            GREED_HOT
646
        } else if self.eq > DANGER_EQ {
647
            GREED_WARM
648
        } else {
649
            1.0
650
        };
651
        length *= greed;
652
        girth *= greed;
653
        if self.here.teapot {
654
            length *= 0.5;
655
            girth *= 1.5;
656
        }
657
        if self.items.ring {
658
            girth *= 2.0;
659
        }
660
        if self.conditions.friction_burn {
661
            length *= 0.5;
662
            girth *= 0.5;
663
        }
664
        (length, girth)
665
    }
666
667
    fn critical_failure(&mut self, training: Training, profile: &Profile) -> Problem {
668
        let uses_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
669
        if uses_foreskin && self.foreskin.is_some() {
670
            self.foreskin = None;
671
            self.shame += 25;
672
            return Problem::new(
673
                StatusCode::GONE,
674
                "foreskin-lost",
675
                "The technique has removed the foreskin. It will not be returning.",
676
            )
677
            .header(header::CONTENT_LOCATION, self.path("/foreskin"));
678
        }
679
        let burn = if self.conditions.friction_burn {
680
            BURN_RUPTURE
681
        } else {
682
            0
683
        };
684
        if self.eq > DANGER_EQ - burn {
685
            return self.rupture();
686
        }
687
        if training == Training::Stretch && !self.here.teapot {
688
            self.here.teapot = true;
689
            self.items.teapot_spout = true;
690
            self.shame += 5;
691
            return 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 floor.",
695
            );
696
        }
697
        if training == Training::Uli && !self.conditions.peyronie {
698
            self.conditions.peyronie = true;
699
            self.shame += 5;
700
            return Problem::new(
701
                StatusCode::MISDIRECTED_REQUEST,
702
                "curvature",
703
                "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
704
            );
705
        }
706
        not_extended()
707
    }
708
709
    fn gossip(&mut self, mut outcome: Outcome) -> Outcome {
710
        let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
711
        if !self.here.brother_told || !reportable {
712
            return outcome;
713
        }
714
        self.here.brother_told = false;
715
        outcome.status = StatusCode::ALREADY_REPORTED;
716
        if let Payload::Json(body) = &mut outcome.payload {
717
            body["detail"] = json!("Your brother has already told Mum.");
718
        }
719
        outcome
720
    }
721
}