atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
177535ac8abf29d1d6eaa1bd35a72ae370329b5f
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/jelq.rsRust606 lines21.1 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
        self.eq = (self.eq + intensity * 2 - 6 - chill).clamp(0, 100);
285
286
        let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
287
        if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {
288
            suspicion *= 2;
289
        }
290
        if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {
291
            suspicion += 2;
292
        }
293
        let was_below = self.suspicion < SUSPICION_LIMIT;
294
        self.suspicion =
295
            (i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)) as u8;
296
        if self.floor.has_mom() && !self.mom_present {
297
            let summoned = was_below && self.suspicion == SUSPICION_LIMIT;
298
            if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {
299
                self.mom_present = true;
300
                self.mom_errand = self.errand(summoned);
301
                self.nonce = self.fresh_nonce();
302
            }
303
        }
304
305
        if let Some(observer) = self.floor.observer()
306
            && self.rng.random_ratio(1, 6)
307
        {
308
            self.observed = Some(observer);
309
            self.shame += 1;
310
        }
311
        if self.turn == super::BRANCH_TURN {
312
            self.branch_pending = true;
313
        }
314
    }
315
316
    fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {
317
        if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {
318
            self.shame += 1;
319
            return Err(Problem::new(
320
                StatusCode::USE_PROXY,
321
                "friend",
322
                "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",
323
            ));
324
        }
325
        if self.peyronie && self.rng.random_ratio(1, 3) {
326
            return Err(Problem::new(
327
                StatusCode::MISDIRECTED_REQUEST,
328
                "misdirected",
329
                "The technique landed slightly to the left. It was meant for a different server.",
330
            ));
331
        }
332
        if intensity <= 2 && self.eq < 50 {
333
            self.eq = (self.eq - 10).max(0);
334
            return Err(Problem::new(
335
                StatusCode::REQUEST_TIMEOUT,
336
                "lost-it",
337
                "The request took too long. It was lost.",
338
            )
339
            .header(header::CONNECTION, "close"));
340
        }
341
        if self.eq > 95 {
342
            if self.rng.random_ratio(1, 3) {
343
                return Err(self.rupture());
344
            }
345
            self.shame += 5;
346
            self.next_floor();
347
            return Err(Problem::new(
348
                StatusCode::TOO_EARLY,
349
                "premature",
350
                "The floor has ended early. Everyone involved has agreed not to discuss it.",
351
            )
352
            .with("floor", self.floor.number()));
353
        }
354
        if self.eq < 30 {
355
            return Err(Problem::new(
356
                StatusCode::NOT_FOUND,
357
                "cold",
358
                "The resource exists. It is cold.",
359
            )
360
            .with("eq", self.eq));
361
        }
362
        Ok(())
363
    }
364
365
    fn roll(
366
        &mut self,
367
        technique: Technique,
368
        profile: &Profile,
369
        intensity: i32,
370
        lube: Option<Lube>,
371
    ) -> Result<Outcome, Problem> {
372
        let natural: i32 = self.rng.random_range(1..=20);
373
        let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };
374
        let grip = match (self.grip, technique) {
375
            (Grip::Underhand, Technique::Stretch) => 2,
376
            (Grip::Underhand, Technique::Wet) => -1,
377
            _ => 0,
378
        };
379
        let modifier = lube.map_or(0, Lube::modifier) + band + grip;
380
        let dc = profile.dc + intensity / 3;
381
        let total = natural + modifier;
382
        let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
383
        let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
384
385
        let scale = f64::from(intensity);
386
        let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
387
        if self.teapot {
388
            length *= 0.5;
389
            girth *= 1.5;
390
        }
391
        if self.items.ring {
392
            girth *= 2.0;
393
        }
394
        let rupture_eq = if self.items.ring { 80 } else { 90 };
395
396
        if natural == 1 {
397
            let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
398
            if touches_foreskin && self.foreskin.is_some() {
399
                self.foreskin = None;
400
                self.shame += 25;
401
                return Err(timed(
402
                    Problem::new(
403
                        StatusCode::GONE,
404
                        "foreskin-lost",
405
                        "The technique has removed the foreskin. It will not be returning.",
406
                    )
407
                    .header(
408
                        header::CONTENT_LOCATION,
409
                        format!("/runs/{}/foreskin", self.id),
410
                    ),
411
                ));
412
            }
413
            if self.eq > 70 {
414
                return Err(timed(self.rupture()));
415
            }
416
            if technique == Technique::Stretch && !self.teapot {
417
                self.teapot = true;
418
                self.items.teapot_spout = true;
419
                self.shame += 5;
420
                return Err(timed(Problem::new(
421
                    StatusCode::IM_A_TEAPOT,
422
                    "teapot",
423
                    "The subject is now short and stout. Length gains are halved for the rest of the floor.",
424
                )));
425
            }
426
            if technique == Technique::Uli && !self.peyronie {
427
                self.peyronie = true;
428
                self.shame += 5;
429
                return Err(timed(Problem::new(
430
                    StatusCode::MISDIRECTED_REQUEST,
431
                    "curvature",
432
                    "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",
433
                )));
434
            }
435
            return Err(timed(not_extended()));
436
        }
437
        if self.eq > rupture_eq && total < dc {
438
            return Err(timed(self.rupture()));
439
        }
440
        if natural == 20 {
441
            self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
442
            return Ok(Outcome::json(
443
                StatusCode::CREATED,
444
                json!({
445
                    "detail": "A permanent gain has been created.",
446
                    "length": round3(length * 2.0),
447
                    "girth": round3(girth * 2.0),
448
                }),
449
            )
450
            .header(header::LOCATION, format!("/runs/{}/length", self.id))
451
            .header(SERVER_TIMING, timing));
452
        }
453
        if total < dc - 3 {
454
            return Err(timed(not_extended()));
455
        }
456
        if total < dc {
457
            return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));
458
        }
459
460
        let outcome = if self.eq < 50 {
461
            self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
462
            Outcome::json(
463
                StatusCode::PARTIAL_CONTENT,
464
                json!({
465
                    "detail": "Partial gains were applied. EQ was below the band.",
466
                    "length": round3(length / 2.0),
467
                    "girth": round3(girth / 2.0),
468
                }),
469
            )
470
        } else if total >= dc + 5 {
471
            self.gain(length, girth, profile.foreskin_growth);
472
            Outcome::json(
473
                StatusCode::OK,
474
                json!({
475
                    "detail": "Gains were applied.",
476
                    "length": round3(length),
477
                    "girth": round3(girth),
478
                }),
479
            )
480
        } else {
481
            let eta = self.rng.random_range(1..=4);
482
            self.pending.push(super::Pending {
483
                due: self.turn + eta,
484
                length,
485
                girth,
486
            });
487
            self.gain(0.0, 0.0, profile.foreskin_growth);
488
            Outcome::json(
489
                StatusCode::ACCEPTED,
490
                json!({
491
                    "detail": "Gains have been queued. They may arrive.",
492
                    "eta_turns": eta,
493
                }),
494
            )
495
        };
496
        Ok(outcome.header(SERVER_TIMING, timing))
497
    }
498
499
    fn gossip(&mut self, outcome: Outcome) -> Outcome {
500
        let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);
501
        if !self.roommate_told || !reportable {
502
            return outcome;
503
        }
504
        self.roommate_told = false;
505
        let mut outcome = outcome;
506
        outcome.status = StatusCode::ALREADY_REPORTED;
507
        if let super::Payload::Json(body) = &mut outcome.payload {
508
            body["detail"] = json!("The roommate has already told everyone.");
509
        }
510
        outcome
511
    }
512
513
    fn warm_up(&mut self) -> Result<Outcome, Problem> {
514
        if self.floor == Floor::Hotel && self.rng.random_ratio(1, 3) {
515
            return Err(Problem::new(
516
                StatusCode::SERVICE_UNAVAILABLE,
517
                "service-unavailable",
518
                "Service is unavailable. The server estimates next week. The server is being pessimistic.",
519
            )
520
            .header(
521
                header::RETRY_AFTER,
522
                httpdate::fmt_http_date(SystemTime::now() + WEEK),
523
            ));
524
        }
525
        let (boost, detail) = if self.floor == Floor::Bathroom {
526
            (
527
                35,
528
                "Warmed up. The shower is hot. The roommate's will not be.",
529
            )
530
        } else {
531
            (25, "Warmed up. The towel has cooled.")
532
        };
533
        self.touch();
534
        self.warmed_up = true;
535
        self.eq = (self.eq + boost).min(100);
536
        Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
537
    }
538
539
    fn ice(&mut self) -> Result<Outcome, Problem> {
540
        if self.items.ice_packs == 0 {
541
            return Err(Problem::new(
542
                StatusCode::FAILED_DEPENDENCY,
543
                "no-ice",
544
                "The ice technique depends on an ice pack. There are none.",
545
            ));
546
        }
547
        self.touch();
548
        self.items.ice_packs -= 1;
549
        self.eq = (self.eq - 40).max(0);
550
        Ok(Outcome::json(
551
            StatusCode::OK,
552
            json!({ "detail": "EQ has been reduced. The ice pack is gone." }),
553
        ))
554
    }
555
556
    fn supplement(&mut self) -> Result<Outcome, Problem> {
557
        if self.items.supplements == 0 {
558
            return Err(Problem::new(
559
                StatusCode::FAILED_DEPENDENCY,
560
                "no-supplement",
561
                "The supplement technique depends on a supplement.",
562
            ));
563
        }
564
        self.items.supplements -= 1;
565
        self.touch();
566
        Err(Problem::new(
567
            StatusCode::BAD_GATEWAY,
568
            "bad-gateway",
569
            "The upstream returned an invalid response.",
570
        )
571
        .with("upstream", "Pump-Bro"))
572
    }
573
574
    fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {
575
        self.commit(5, None, now, 1);
576
        self.edges += 1;
577
        self.eq = (self.eq + 11).min(100);
578
        self.girth += 0.004;
579
        if self.edges >= 3 {
580
            self.edges = 0;
581
            self.stamina = 0;
582
            self.refractory_until = Some(now + REFRACTORY);
583
            return Err(Problem::new(
584
                StatusCode::LOOP_DETECTED,
585
                "edging-loop",
586
                "Edging has looped. The server detected an infinite loop and terminated it.",
587
            ));
588
        }
589
        self.hazards(5)?;
590
        Ok(Outcome::json(
591
            StatusCode::OK,
592
            json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),
593
        ))
594
    }
595
596
    fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {
597
        self.commit(4, None, now, 1);
598
        self.hazards(4)?;
599
        self.temp_length += 0.5;
600
        Ok(Outcome::json(
601
            StatusCode::TEMPORARY_REDIRECT,
602
            json!({ "detail": "Gains applied. They are temporary.", "length": 0.5 }),
603
        )
604
        .header(header::LOCATION, format!("/runs/{}/length", self.id)))
605
    }
606
}