atagen

jelq

public

an http based roguelike rpg about jelqing

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