atagen

jelq

public

an http based roguelike rpg about jelqing

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