atagen

jelq

public

an http based roguelike rpg about jelqing

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