atagen

jelq

public

an http based roguelike rpg about jelqing

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