atagen

jelq

public

an http based roguelike rpg about jelqing

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