atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
e807266c323bcc1fd00f59aa160b3b94d8168beb
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
        match *techniques {
49
            [Technique::Utility(utility)] => self.utility(utility, lube, now),
50
            [Technique::Training(training)] => self.single(training, intensity, lube, now),
51
            [Technique::Training(a), Technique::Training(b)] => {
52
                self.combo(a, b, intensity, lube, now)
53
            }
54
            [_, _] => Err(Problem::new(
55
                StatusCode::BAD_REQUEST,
56
                "combo-invalid",
57
                "Only training techniques can be combined.",
58
            )),
59
            _ => Err(Problem::new(
60
                StatusCode::BAD_REQUEST,
61
                "too-many-techniques",
62
                "Two techniques at most. The server has limits even if you do not.",
63
            )),
64
        }
65
    }
66
67
    fn utility(
68
        &mut self,
69
        utility: Utility,
70
        lube: Option<Lube>,
71
        now: Instant,
72
    ) -> Result<Outcome, Problem> {
73
        match utility {
74
            Utility::Warmup => return self.warm_up(),
75
            Utility::Ice => return self.ice(),
76
            Utility::Supplement => return self.supplement(),
77
            Utility::Meditate => return self.meditate(),
78
            Utility::Energy => return self.energy(),
79
            Utility::Edge | Utility::Pump => {}
80
        }
81
        self.ready(Technique::Utility(utility), lube)?;
82
        self.check_storage()?;
83
        self.floor_enemy()?;
84
        if utility == Utility::Pump {
85
            self.here.edges = 0;
86
        }
87
        match utility {
88
            Utility::Edge => self.edge(now),
89
            _ => self.pump(now),
90
        }
91
    }
92
93
    fn single(
94
        &mut self,
95
        training: Training,
96
        intensity: i32,
97
        lube: Option<Lube>,
98
        now: Instant,
99
    ) -> Result<Outcome, Problem> {
100
        self.ready(Technique::Training(training), lube)?;
101
        self.check_storage()?;
102
        self.lube_available(lube)?;
103
        self.floor_enemy()?;
104
        self.here.edges = 0;
105
        self.take_lube(lube)?;
106
        self.chafe(lube);
107
        self.commit(intensity, lube, now, 1);
108
        if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
109
            return Ok(interrupted);
110
        }
111
        if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
112
            return Ok(interrupted);
113
        }
114
        let outcome = self.roll(training, intensity, lube)?;
115
        Ok(self.gossip(outcome))
116
    }
117
118
    fn combo(
119
        &mut self,
120
        a: Training,
121
        b: Training,
122
        intensity: i32,
123
        lube: Option<Lube>,
124
        now: Instant,
125
    ) -> Result<Outcome, Problem> {
126
        if a == b {
127
            return Err(Problem::new(
128
                StatusCode::BAD_REQUEST,
129
                "combo-invalid",
130
                "A technique cannot be combined with itself. The server has checked.",
131
            ));
132
        }
133
        self.ready(Technique::Training(a), lube)?;
134
        self.ready(Technique::Training(b), lube)?;
135
        self.check_storage()?;
136
        self.lube_available(lube)?;
137
        self.floor_enemy()?;
138
        self.here.edges = 0;
139
        self.take_lube(lube)?;
140
        self.chafe(lube);
141
        self.commit(intensity, lube, now, 2);
142
        for training in [a, b] {
143
            if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
144
                return Ok(interrupted);
145
            }
146
        }
147
        if let ControlFlow::Break(interrupted) = self.hazards(intensity)? {
148
            return Ok(interrupted);
149
        }
150
151
        let mut statuses = Vec::new();
152
        for training in [a, b] {
153
            let result = self.roll(training, intensity, lube);
154
            if self.ended.is_some() {
155
                return result;
156
            }
157
            let status = match result {
158
                Ok(outcome) => self.gossip(outcome).status,
159
                Err(problem) => problem.status(),
160
            };
161
            statuses.push((training, status));
162
        }
163
164
        let mut xml = String::from(
165
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
166
        );
167
        for (training, status) in statuses {
168
            let _ = write!(
169
                xml,
170
                "  <D:response>\n    <D:href>{}?technique={}</D:href>\n    <D:status>HTTP/1.1 {} {}</D:status>\n  </D:response>\n",
171
                self.path(""),
172
                training.name(),
173
                status.as_u16(),
174
                crate::problem::reason(status),
175
            );
176
        }
177
        xml.push_str(
178
            "  <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
179
        );
180
        Ok(Outcome::raw(
181
            StatusCode::MULTI_STATUS,
182
            "application/xml; charset=utf-8",
183
            xml,
184
        ))
185
    }
186
187
    fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
188
        self.access(technique)?;
189
        match technique {
190
            Technique::Training(training) => Self::check_lube(training, lube),
191
            Technique::Utility(_) => Ok(()),
192
        }
193
    }
194
195
    /// Whether the run could attempt the technique now, ignoring the lubricant.
196
    fn access(&self, technique: Technique) -> Result<(), Problem> {
197
        if technique == Technique::Training(Training::Uli) && !self.uli_taught {
198
            return Err(Problem::new(
199
                StatusCode::FORBIDDEN,
200
                "technique-locked",
201
                "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not found the reply.",
202
            ));
203
        }
204
        if !self.warmed_up {
205
            return Err(Problem::new(
206
                StatusCode::PRECONDITION_FAILED,
207
                "cold-start",
208
                "Warm-up was skipped. The server has noted this.",
209
            )
210
            .with("technique", Utility::Warmup.name()));
211
        }
212
        match technique {
213
            Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(
214
                StatusCode::FAILED_DEPENDENCY,
215
                "no-pump",
216
                "The pump technique depends on a pump.",
217
            )),
218
            Technique::Utility(_) => Ok(()),
219
            Technique::Training(training) => match training.profile().foreskin {
220
                Some((min, max)) => self.check_foreskin(min, max),
221
                None => Ok(()),
222
            },
223
        }
224
    }
225
226
    fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {
227
        match (training.profile().lube, lube) {
228
            (LubeRule::Required, None) => Err(Problem::new(
229
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
230
                "lubricant-required",
231
                format!(
232
                    "The {} technique requires a lubricant. None was declared.",
233
                    training.name()
234
                ),
235
            )
236
            .with("lubricants", json!(LUBRICANTS))),
237
            (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
238
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
239
                "lubricant-declared",
240
                format!(
241
                    "The {} technique was requested. A lubricant was declared.",
242
                    training.name()
243
                ),
244
            )),
245
            _ => Ok(()),
246
        }
247
    }
248
249
    /// One unit of whatever was declared. Spit is free and the tap is always on.
250
    fn lube_available(&self, lube: Option<Lube>) -> Result<(), Problem> {
251
        let (held, name) = match lube {
252
            None | Some(Lube::Spit) => return Ok(()),
253
            Some(Lube::BabyOil) => (&self.items.baby_oil, "baby-oil"),
254
            Some(Lube::CoconutOil) => (&self.items.coconut_oil, "coconut-oil"),
255
            Some(Lube::Lotion) => (&self.items.lotion, "lotion"),
256
            Some(Lube::Vaseline) => (&self.items.vaseline, "vaseline"),
257
        };
258
        if *held == 0 {
259
            return Err(Problem::new(
260
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
261
                "lubricant-exhausted",
262
                format!("{name} was declared, but the bottle is empty."),
263
            ));
264
        }
265
        Ok(())
266
    }
267
268
    fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
269
        self.lube_available(lube)?;
270
        let held = match lube {
271
            None | Some(Lube::Spit) => return Ok(()),
272
            Some(Lube::BabyOil) => &mut self.items.baby_oil,
273
            Some(Lube::CoconutOil) => &mut self.items.coconut_oil,
274
            Some(Lube::Lotion) => &mut self.items.lotion,
275
            Some(Lube::Vaseline) => &mut self.items.vaseline,
276
        };
277
        *held -= 1;
278
        Ok(())
279
    }
280
281
    /// The techniques a JELQ could use right now without being refused before the roll.
282
    pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {
283
        let blocked = !self.floor.timed()
284
            || self.items.cage
285
            || self.refractory_until.is_some_and(|until| now < until);
286
        if blocked {
287
            return Vec::new();
288
        }
289
        Technique::ALL
290
            .into_iter()
291
            .filter(|&technique| match technique {
292
                Technique::Utility(Utility::Warmup) => true,
293
                Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
294
                Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
295
                Technique::Utility(Utility::Meditate) => !self.here.meditated,
296
                Technique::Utility(Utility::Energy) => self.items.energy_drinks > 0,
297
                _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
298
            })
299
            .collect()
300
    }
301
302
    fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
303
        let Some(f) = self.foreskin else {
304
            if min <= 0.0 {
305
                return Ok(());
306
            }
307
            return Err(Problem::new(
308
                StatusCode::GONE,
309
                "foreskin-gone",
310
                "This technique requires a foreskin. The foreskin is gone.",
311
            )
312
            .header(header::CONTENT_LOCATION, self.path("/foreskin")));
313
        };
314
        if f < min {
315
            return Err(Problem::new(
316
                StatusCode::LENGTH_REQUIRED,
317
                "foreskin-insufficient",
318
                format!(
319
                    "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
320
                ),
321
            )
322
            .with("required", min));
323
        }
324
        if f > max {
325
            return Err(Problem::new(
326
                StatusCode::PAYLOAD_TOO_LARGE,
327
                "foreskin-excessive",
328
                format!(
329
                    "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
330
                ),
331
            )
332
            .with("maximum", max));
333
        }
334
        Ok(())
335
    }
336
337
    fn check_storage(&self) -> Result<(), Problem> {
338
        if self.items.extender && self.girth > STORAGE_GIRTH {
339
            return Err(Problem::new(
340
                StatusCode::INSUFFICIENT_STORAGE,
341
                "jeans-full",
342
                "The jeans are full. The extender is occupying the remaining storage.",
343
            )
344
            .with("extender", self.path("/inventory/extender")));
345
        }
346
        Ok(())
347
    }
348
349
    fn chafe(&mut self, lube: Option<Lube>) {
350
        let mut friction = match lube {
351
            None => FRICTION_DRY,
352
            Some(Lube::Spit) => FRICTION_SPIT,
353
            Some(_) => 0,
354
        };
355
        if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
356
            friction -= FRICTION_GRIP;
357
        }
358
        self.jelq_grip = Some(self.grip);
359
        self.friction = (self.friction + friction).min(FRICTION_MAX);
360
        if self.friction >= FRICTION_BURN_AT
361
            && !self.conditions.friction_burn
362
            && self.chance(
363
                self.decision(
364
                    "friction-burn",
365
                    "With the skin this raw, has it finally burned?",
366
                ),
367
                FRICTION_BURN,
368
            )
369
        {
370
            self.conditions.friction_burn = true;
371
        }
372
    }
373
374
    pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
375
        self.touch();
376
        self.turn += 1;
377
        self.here.turns += 1;
378
        self.step_away();
379
        self.realise();
380
        if self.items.extender {
381
            self.length += EXTENDER_GAIN;
382
        }
383
        self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
384
        if self.stamina <= 0 {
385
            self.exhaust(now);
386
        }
387
        let chill = if self.floor == Floor::ChangingRoom {
388
            CHANGING_ROOM_CHILL
389
        } else {
390
            0
391
        };
392
        let wobble = self.oracle.span_int(
393
            self.decision(
394
                "wobble",
395
                "How does the count drift on this stroke, up or down?",
396
            ),
397
            -EQ_WOBBLE,
398
            EQ_WOBBLE,
399
        );
400
        self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
401
402
        let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
403
        if matches!(self.floor, Floor::ChangingRoom | Floor::Nans) {
404
            suspicion *= 2;
405
        }
406
        if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
407
            suspicion += 2;
408
        }
409
        let was_below = self.suspicion < SUSPICION_LIMIT;
410
        self.suspicion =
411
            u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))
412
                .unwrap_or(SUSPICION_LIMIT);
413
414
        if intensity >= PULLED_MUSCLE_INTENSITY
415
            && !self.conditions.pulled_muscle
416
            && self.oracle.chance_not(
417
                self.decision("pulled-muscle", "Do the muscles hold?"),
418
                PULLED_MUSCLE,
419
            )
420
        {
421
            self.conditions.pulled_muscle = true;
422
            self.stamina = self.stamina.min(self.max_stamina());
423
        }
424
        self.settle(was_below);
425
    }
426
427
    /// The part of a turn the house takes part in, whatever the action was.
428
    pub(super) fn settle(&mut self, was_below: bool) {
429
        self.maybe_summon_mum(was_below);
430
        if let Some(observer) = self.floor.observer()
431
            && self.chance(
432
                self.decision("observed", "Has someone quietly noticed what is going on?"),
433
                OBSERVED,
434
            )
435
        {
436
            self.observed = Some(observer);
437
            self.shame += 1;
438
            if observer == Observer::Cat
439
                && self.chance(
440
                    self.decision("cat-takes-towel", "Has the cat taken the towel?"),
441
                    CAT_TAKES_TOWEL,
442
                )
443
            {
444
                self.here.towel_stolen = true;
445
            }
446
        }
447
        if self.items.ring
448
            && self.eq > HOT_EQ
449
            && self.chance(
450
                self.decision(
451
                    "ring-priapism",
452
                    "With the ring still on and everything this warm, will it not go down?",
453
                ),
454
                RING_PRIAPISM,
455
            )
456
        {
457
            self.conditions.priapism_turns = PRIAPISM_TURNS;
458
        }
459
        if self.conditions.priapism_turns > 0 {
460
            self.conditions.priapism_turns -= 1;
461
            self.eq = self.eq.max(PRIAPISM_EQ);
462
        }
463
        if self.turn == BRANCH_TURN && self.story == Story::Unopened {
464
            self.story = Story::Pending;
465
            self.notices
466
                .push("Somewhere, a forum thread from 2009 still has replies.");
467
        }
468
    }
469
470
    /// HOLD. A pause between strokes, offered when the band has been left or the stamina is gone.
471
    pub fn can_hold(&self) -> bool {
472
        self.eq > DANGER_EQ || self.stamina <= 2
473
    }
474
475
    pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {
476
        self.check_refractory(now)?;
477
        if self.story == Story::Pending {
478
            return Ok(self.branch_offer());
479
        }
480
        if self.items.cage {
481
            return Err(Problem::new(
482
                StatusCode::LOCKED,
483
                "caged",
484
                "The cage is locked. The key is not in the box.",
485
            ));
486
        }
487
        self.touch();
488
        self.turn += 1;
489
        self.here.turns += 1;
490
        self.step_away();
491
        self.realise();
492
        if self.oracle.chance_not(
493
            self.decision("rupture", "The pause is uneventful"),
494
            RUPTURE_HOLD,
495
        ) {
496
            return Err(self.rupture());
497
        }
498
        let was_below = self.suspicion < SUSPICION_LIMIT;
499
        if self.chance(
500
            self.decision(
501
                "reflection",
502
                "In the quiet afterwards, have you caught your own eye in the mirror?",
503
            ),
504
            REFLECTION,
505
        ) {
506
            self.eq = 0;
507
            self.shame += 1;
508
            self.notices
509
                .push("the count reached ten, and then the reflection started");
510
        } else {
511
            self.eq = (self.eq - HOLD_EQ).clamp(0, 100);
512
        }
513
        self.stamina = (self.stamina + 1).min(self.max_stamina());
514
        self.shed(FRICTION_HOLD);
515
        self.settle(was_below);
516
        Ok(Outcome::empty(StatusCode::NO_CONTENT))
517
    }
518
519
    /// The breathing technique from the forum's replies. It spends no stamina and the turn is
520
    /// not the floor's, but each floor allows it once.
521
    fn meditate(&mut self) -> Result<Outcome, Problem> {
522
        if self.here.meditated {
523
            return Err(Problem::new(
524
                StatusCode::CONFLICT,
525
                "already-meditated",
526
                "This floor has had its meditation.",
527
            ));
528
        }
529
        self.here.meditated = true;
530
        self.step_away();
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
}