atagen

jelq

public

an http based roguelike rpg about jelqing

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