atagen

jelq

public

an http based roguelike rpg about jelqing

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