atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
99524cf9f17c87f6898b4227baea78d8cfe28ef9
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/jelq.rsRust611 lines21.3 KB
1
use std::{
2
    fmt::Write as _,
3
    time::{Duration, Instant, SystemTime},
4
};
5
6
use axum::http::{StatusCode, header};
7
use rand::Rng;
8
use serde_json::json;
9
10
use super::{
11
    CLINIC_ALLOW, FORUM_THREAD, Floor, Grip, Lube, Outcome, REFRACTORY, Run, SERVER_TIMING,
12
    SUSPICION_LIMIT, Technique, catalog::LubeRule, catalog::Profile, not_extended, round3,
13
};
14
use crate::{game::LUBRICANTS, problem::Problem};
15
16
const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
17
18
impl Run {
19
    pub fn jelq(
20
        &mut self,
21
        techniques: &[Technique],
22
        intensity: i32,
23
        lube: Option<Lube>,
24
        now: Instant,
25
    ) -> Result<Outcome, Problem> {
26
        self.check_refractory(now)?;
27
        if self.branch_pending {
28
            return Ok(self.branch_offer());
29
        }
30
        match self.floor {
31
            Floor::Forum => {
32
                return Ok(Outcome::json(
33
                    StatusCode::SEE_OTHER,
34
                    json!({ "detail": "Refer to the forum." }),
35
                )
36
                .header(header::LOCATION, FORUM_THREAD));
37
            }
38
            Floor::Clinic => {
39
                return Err(Problem::new(
40
                    StatusCode::METHOD_NOT_ALLOWED,
41
                    "not-in-the-clinic",
42
                    "JELQ is not performed in the clinic.",
43
                )
44
                .header(header::ALLOW, CLINIC_ALLOW));
45
            }
46
            _ => {}
47
        }
48
        if self.items.cage {
49
            return Err(Problem::new(
50
                StatusCode::LOCKED,
51
                "caged",
52
                "The cage is locked. The key is not in the box.",
53
            ));
54
        }
55
        match *techniques {
56
            [technique] => self.single(technique, intensity, lube, now),
57
            [a, b] => self.combo(a, b, intensity, lube, now),
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 single(
67
        &mut self,
68
        technique: Technique,
69
        intensity: i32,
70
        lube: Option<Lube>,
71
        now: Instant,
72
    ) -> Result<Outcome, Problem> {
73
        match technique {
74
            Technique::Warmup => return self.warm_up(),
75
            Technique::Ice => return self.ice(),
76
            Technique::Supplement => return self.supplement(),
77
            Technique::Edge => {}
78
            _ => self.edges = 0,
79
        }
80
        self.prepare(technique, lube)?;
81
        self.check_storage()?;
82
        self.floor_enemy()?;
83
        match technique {
84
            Technique::Edge => return self.edge(now),
85
            Technique::Pump => return self.pump(now),
86
            _ => {}
87
        }
88
        let profile = technique.profile().expect("training technique");
89
        self.commit(intensity, lube, now, 1);
90
        self.hazards(intensity)?;
91
        let result = self.roll(technique, &profile, intensity, lube);
92
        Ok(self.gossip(result?))
93
    }
94
95
    fn combo(
96
        &mut self,
97
        a: Technique,
98
        b: Technique,
99
        intensity: i32,
100
        lube: Option<Lube>,
101
        now: Instant,
102
    ) -> Result<Outcome, Problem> {
103
        let (Some(first), Some(second)) = (a.profile(), b.profile()) else {
104
            return Err(Problem::new(
105
                StatusCode::BAD_REQUEST,
106
                "combo-invalid",
107
                "Only training techniques can be combined.",
108
            ));
109
        };
110
        if a == b {
111
            return Err(Problem::new(
112
                StatusCode::BAD_REQUEST,
113
                "combo-invalid",
114
                "A technique cannot be combined with itself. The server has checked.",
115
            ));
116
        }
117
        self.edges = 0;
118
        self.prepare(a, lube)?;
119
        self.prepare(b, lube)?;
120
        self.check_storage()?;
121
        self.floor_enemy()?;
122
        self.commit(intensity, lube, now, 2);
123
        self.hazards(intensity)?;
124
125
        let mut statuses = Vec::new();
126
        for (technique, profile) in [(a, first), (b, second)] {
127
            let result = self.roll(technique, &profile, intensity, lube);
128
            if self.ended.is_some() {
129
                return result;
130
            }
131
            let status = match &result {
132
                Ok(outcome) => outcome.status(),
133
                Err(problem) => problem.status(),
134
            };
135
            statuses.push((technique, status));
136
        }
137
138
        let mut xml = String::from(
139
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",
140
        );
141
        for (technique, status) in statuses {
142
            let _ = write!(
143
                xml,
144
                "  <D:response>\n    <D:href>/runs/{}?technique={}</D:href>\n    <D:status>HTTP/1.1 {} {}</D:status>\n  </D:response>\n",
145
                self.id,
146
                technique.name(),
147
                status.as_u16(),
148
                status.canonical_reason().unwrap_or_default(),
149
            );
150
        }
151
        xml.push_str(
152
            "  <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",
153
        );
154
        Ok(Outcome::raw(
155
            StatusCode::MULTI_STATUS,
156
            "application/xml; charset=utf-8",
157
            xml,
158
        ))
159
    }
160
161
    fn prepare(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
162
        if technique == Technique::Uli && !self.forum_read {
163
            return Err(Problem::new(
164
                StatusCode::FORBIDDEN,
165
                "technique-locked",
166
                "This technique is discussed on the forum. You have not been to the forum.",
167
            ));
168
        }
169
        if !self.warmed_up {
170
            return Err(Problem::new(
171
                StatusCode::PRECONDITION_FAILED,
172
                "cold-start",
173
                "Warm-up was skipped. The server has noted this.",
174
            )
175
            .with("technique", Technique::Warmup.name()));
176
        }
177
        if technique == Technique::Pump && !self.items.pump_pro {
178
            return Err(Problem::new(
179
                StatusCode::FAILED_DEPENDENCY,
180
                "no-pump",
181
                "The pump technique depends on a pump.",
182
            ));
183
        }
184
        let Some(profile) = technique.profile() else {
185
            return Ok(());
186
        };
187
        match (profile.lube, lube) {
188
            (LubeRule::Required, None) => {
189
                return Err(Problem::new(
190
                    StatusCode::UNSUPPORTED_MEDIA_TYPE,
191
                    "lubricant-required",
192
                    format!(
193
                        "The {} technique requires a lubricant. None was declared.",
194
                        technique.name()
195
                    ),
196
                )
197
                .with("lubricants", json!(LUBRICANTS)));
198
            }
199
            (LubeRule::Forbidden, Some(_)) => {
200
                return Err(Problem::new(
201
                    StatusCode::UNSUPPORTED_MEDIA_TYPE,
202
                    "lubricant-declared",
203
                    "The dry technique was requested. A lubricant was declared.",
204
                ));
205
            }
206
            _ => {}
207
        }
208
        if let Some((min, max)) = profile.foreskin {
209
            self.check_foreskin(min, max)?;
210
        }
211
        Ok(())
212
    }
213
214
    fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
215
        let Some(f) = self.foreskin else {
216
            if min <= 0.0 {
217
                return Ok(());
218
            }
219
            return Err(Problem::new(
220
                StatusCode::GONE,
221
                "foreskin-gone",
222
                "This technique requires a foreskin. The foreskin is gone.",
223
            )
224
            .header(
225
                header::CONTENT_LOCATION,
226
                format!("/runs/{}/foreskin", self.id),
227
            ));
228
        };
229
        if f < min {
230
            return Err(Problem::new(
231
                StatusCode::LENGTH_REQUIRED,
232
                "foreskin-insufficient",
233
                format!(
234
                    "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."
235
                ),
236
            )
237
            .with("required", min)
238
            .with("available", super::round2(f)));
239
        }
240
        if f > max {
241
            return Err(Problem::new(
242
                StatusCode::PAYLOAD_TOO_LARGE,
243
                "foreskin-excessive",
244
                format!(
245
                    "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."
246
                ),
247
            )
248
            .with("maximum", max)
249
            .with("present", super::round2(f)));
250
        }
251
        Ok(())
252
    }
253
254
    fn check_storage(&self) -> Result<(), Problem> {
255
        if self.items.extender && self.girth > 5.0 {
256
            return Err(Problem::new(
257
                StatusCode::INSUFFICIENT_STORAGE,
258
                "jeans-full",
259
                "The jeans are full. The extender is occupying the remaining storage.",
260
            )
261
            .with("extender", format!("/runs/{}/inventory/extender", self.id)));
262
        }
263
        Ok(())
264
    }
265
266
    fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
267
        self.touch();
268
        self.turn += 1;
269
        self.floor_turns += 1;
270
        self.realize();
271
        if self.items.extender {
272
            self.length += 0.004;
273
        }
274
        self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
275
        if self.stamina <= 0 {
276
            self.stamina = 0;
277
            self.refractory_until = Some(now + *REFRACTORY);
278
        }
279
        let chill = if self.floor == Floor::LockerRoom {
280
            8
281
        } else {
282
            0
283
        };
284
        let wobble = self.rng.random_range(-3..=3);
285
        self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
286
287
        let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
288
        if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {
289
            suspicion *= 2;
290
        }
291
        if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {
292
            suspicion += 2;
293
        }
294
        let was_below = self.suspicion < SUSPICION_LIMIT;
295
        self.suspicion =
296
            (i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)) as u8;
297
        if self.floor.has_mom() && !self.mom_present {
298
            let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
299
            if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {
300
                self.mom_present = true;
301
                self.mom_strikes = 0;
302
                self.mom_errand = self.errand(summoned);
303
                self.nonce = self.fresh_nonce();
304
            }
305
        }
306
307
        if let Some(observer) = self.floor.observer()
308
            && self.rng.random_ratio(1, 6)
309
        {
310
            self.observed = Some(observer);
311
            self.shame += 1;
312
        }
313
        if self.turn == super::BRANCH_TURN {
314
            self.branch_pending = true;
315
        }
316
    }
317
318
    fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {
319
        if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {
320
            self.shame += 1;
321
            return Err(Problem::new(
322
                StatusCode::USE_PROXY,
323
                "friend",
324
                "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",
325
            ));
326
        }
327
        if self.peyronie && self.rng.random_ratio(1, 3) {
328
            return Err(Problem::new(
329
                StatusCode::MISDIRECTED_REQUEST,
330
                "misdirected",
331
                "The technique landed slightly to the left. It was meant for a different server.",
332
            ));
333
        }
334
        if intensity <= 2 && self.eq < 50 {
335
            self.eq = (self.eq - 10).max(0);
336
            return Err(Problem::new(
337
                StatusCode::REQUEST_TIMEOUT,
338
                "lost-it",
339
                "The request took too long. It was lost.",
340
            )
341
            .header(header::CONNECTION, "close"));
342
        }
343
        if self.eq > 95 {
344
            if self.rng.random_ratio(1, 3) {
345
                return Err(self.rupture());
346
            }
347
            self.shame += 5;
348
            self.next_floor(false);
349
            return Err(Problem::new(
350
                StatusCode::TOO_EARLY,
351
                "premature",
352
                "The floor has ended early. Everyone involved has agreed not to discuss it.",
353
            )
354
            .with("floor", self.floor.number()));
355
        }
356
        if self.eq < 30 {
357
            return Err(Problem::new(
358
                StatusCode::NOT_FOUND,
359
                "cold",
360
                "The resource exists. It is cold.",
361
            )
362
            .with("eq", self.eq));
363
        }
364
        Ok(())
365
    }
366
367
    fn roll(
368
        &mut self,
369
        technique: Technique,
370
        profile: &Profile,
371
        intensity: i32,
372
        lube: Option<Lube>,
373
    ) -> Result<Outcome, Problem> {
374
        let natural: i32 = self.rng.random_range(1..=20);
375
        let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
376
        let grip = match (self.grip, technique) {
377
            (Grip::Underhand, Technique::Stretch) => 2,
378
            (Grip::Underhand, Technique::Wet) => -1,
379
            _ => 0,
380
        };
381
        let modifier = lube.map_or(0, Lube::modifier) + band + grip;
382
        let dc = profile.dc + intensity / 3;
383
        let total = natural + modifier;
384
        let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
385
        let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
386
387
        let scale = f64::from(intensity);
388
        let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
389
        if self.teapot {
390
            length *= 0.5;
391
            girth *= 1.5;
392
        }
393
        if self.items.ring {
394
            girth *= 2.0;
395
        }
396
        let rupture_eq = if self.items.ring { 80 } else { 90 };
397
398
        if natural == 1 {
399
            let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
400
            if touches_foreskin && self.foreskin.is_some() {
401
                self.foreskin = None;
402
                self.shame += 25;
403
                return Err(timed(
404
                    Problem::new(
405
                        StatusCode::GONE,
406
                        "foreskin-lost",
407
                        "The technique has removed the foreskin. It will not be returning.",
408
                    )
409
                    .header(
410
                        header::CONTENT_LOCATION,
411
                        format!("/runs/{}/foreskin", self.id),
412
                    ),
413
                ));
414
            }
415
            if self.eq > 70 {
416
                return Err(timed(self.rupture()));
417
            }
418
            if technique == Technique::Stretch && !self.teapot {
419
                self.teapot = true;
420
                self.items.teapot_spout = true;
421
                self.shame += 5;
422
                return Err(timed(Problem::new(
423
                    StatusCode::IM_A_TEAPOT,
424
                    "teapot",
425
                    "The subject is now short and stout. Length gains are halved for the rest of the floor.",
426
                )));
427
            }
428
            if technique == Technique::Uli && !self.peyronie {
429
                self.peyronie = true;
430
                self.shame += 5;
431
                return Err(timed(Problem::new(
432
                    StatusCode::MISDIRECTED_REQUEST,
433
                    "curvature",
434
                    "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
435
                )));
436
            }
437
            return Err(timed(not_extended()));
438
        }
439
        if natural == 2 && self.eq > 70 {
440
            return Err(timed(self.rupture()));
441
        }
442
        if self.eq > rupture_eq && total < dc {
443
            return Err(timed(self.rupture()));
444
        }
445
        if natural == 20 {
446
            self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
447
            return Ok(Outcome::json(
448
                StatusCode::CREATED,
449
                json!({
450
                    "detail": "A permanent gain has been created.",
451
                    "length": round3(length * 2.0),
452
                    "girth": round3(girth * 2.0),
453
                }),
454
            )
455
            .header(header::LOCATION, format!("/runs/{}/length", self.id))
456
            .header(SERVER_TIMING, timing));
457
        }
458
        if total < dc - 3 {
459
            return Err(timed(not_extended()));
460
        }
461
        if total < dc {
462
            return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
463
        }
464
465
        let outcome = if self.eq < 50 {
466
            self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
467
            Outcome::json(
468
                StatusCode::PARTIAL_CONTENT,
469
                json!({
470
                    "detail": "Partial gains were applied. EQ was below the band.",
471
                    "length": round3(length / 2.0),
472
                    "girth": round3(girth / 2.0),
473
                }),
474
            )
475
        } else if total >= dc + 5 {
476
            self.gain(length, girth, profile.foreskin_growth);
477
            Outcome::json(
478
                StatusCode::OK,
479
                json!({
480
                    "detail": "Gains were applied.",
481
                    "length": round3(length),
482
                    "girth": round3(girth),
483
                }),
484
            )
485
        } else {
486
            let eta = self.rng.random_range(1..=4);
487
            self.pending.push(super::Pending {
488
                due: self.turn + eta,
489
                length,
490
                girth,
491
            });
492
            self.gain(0.0, 0.0, profile.foreskin_growth);
493
            Outcome::json(
494
                StatusCode::ACCEPTED,
495
                json!({
496
                    "detail": "Gains have been queued. They may arrive.",
497
                    "eta_turns": eta,
498
                }),
499
            )
500
        };
501
        Ok(outcome.header(SERVER_TIMING, timing))
502
    }
503
504
    fn gossip(&mut self, outcome: Outcome) -> Outcome {
505
        let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
506
        if !self.roommate_told || !reportable {
507
            return outcome;
508
        }
509
        self.roommate_told = false;
510
        let mut outcome = outcome;
511
        outcome.status = StatusCode::ALREADY_REPORTED;
512
        if let super::Payload::Json(body) = &mut outcome.payload {
513
            body["detail"] = json!("The roommate has already told everyone.");
514
        }
515
        outcome
516
    }
517
518
    fn warm_up(&mut self) -> Result<Outcome, Problem> {
519
        if self.floor == Floor::Hotel && self.rng.random_ratio(1, 3) {
520
            return Err(Problem::new(
521
                StatusCode::SERVICE_UNAVAILABLE,
522
                "service-unavailable",
523
                "Service is unavailable. The server estimates next week. The server is being pessimistic.",
524
            )
525
            .header(
526
                header::RETRY_AFTER,
527
                httpdate::fmt_http_date(SystemTime::now() + WEEK),
528
            ));
529
        }
530
        let (boost, detail) = if self.floor == Floor::Bathroom {
531
            (
532
                35,
533
                "Warmed up. The shower is hot. The roommate's will not be.",
534
            )
535
        } else {
536
            (25, "Warmed up. The towel has cooled.")
537
        };
538
        self.touch();
539
        self.warmed_up = true;
540
        self.eq = (self.eq + boost).min(100);
541
        Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
542
    }
543
544
    fn ice(&mut self) -> Result<Outcome, Problem> {
545
        if self.items.ice_packs == 0 {
546
            return Err(Problem::new(
547
                StatusCode::FAILED_DEPENDENCY,
548
                "no-ice",
549
                "The ice technique depends on an ice pack. There are none.",
550
            ));
551
        }
552
        self.touch();
553
        self.items.ice_packs -= 1;
554
        self.eq = (self.eq - 40).max(0);
555
        Ok(Outcome::json(
556
            StatusCode::OK,
557
            json!({ "detail": "EQ has been reduced. The ice pack is gone." }),
558
        ))
559
    }
560
561
    fn supplement(&mut self) -> Result<Outcome, Problem> {
562
        if self.items.supplements == 0 {
563
            return Err(Problem::new(
564
                StatusCode::FAILED_DEPENDENCY,
565
                "no-supplement",
566
                "The supplement technique depends on a supplement.",
567
            ));
568
        }
569
        self.items.supplements -= 1;
570
        self.touch();
571
        Err(Problem::new(
572
            StatusCode::BAD_GATEWAY,
573
            "bad-gateway",
574
            "The upstream returned an invalid response.",
575
        )
576
        .with("upstream", "Pump-Bro"))
577
    }
578
579
    fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {
580
        self.commit(5, None, now, 1);
581
        self.edges += 1;
582
        self.eq = (self.eq + 11).min(100);
583
        self.girth += 0.004;
584
        if self.edges >= 3 {
585
            self.edges = 0;
586
            self.stamina = 0;
587
            self.refractory_until = Some(now + *REFRACTORY);
588
            return Err(Problem::new(
589
                StatusCode::LOOP_DETECTED,
590
                "edging-loop",
591
                "Edging has looped. The server detected an infinite loop and terminated it.",
592
            ));
593
        }
594
        self.hazards(5)?;
595
        Ok(Outcome::json(
596
            StatusCode::OK,
597
            json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),
598
        ))
599
    }
600
601
    fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {
602
        self.commit(4, None, now, 1);
603
        self.hazards(4)?;
604
        self.temp_length += 0.5;
605
        Ok(Outcome::json(
606
            StatusCode::TEMPORARY_REDIRECT,
607
            json!({ "detail": "Gains applied. They are temporary.", "length": 0.5 }),
608
        )
609
        .header(header::LOCATION, format!("/runs/{}/length", self.id)))
610
    }
611
}