atagen

jelq

public

an http based roguelike rpg about jelqing

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