atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
d2e314cf03d59c70462af9b534335ce881b718eb
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust718 lines21.1 KB
1
mod catalog;
2
mod encounters;
3
mod jelq;
4
5
pub use catalog::{LUBRICANTS, Lube, SHOP, ShopItem, Technique};
6
pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread};
7
8
use std::{
9
    sync::LazyLock,
10
    time::{Duration, Instant, SystemTime},
11
};
12
13
use axum::{
14
    Json,
15
    http::{HeaderName, HeaderValue, StatusCode, header},
16
    response::{IntoResponse, Response},
17
};
18
use rand::{Rng, SeedableRng, rngs::StdRng};
19
use serde_json::{Value, json};
20
21
use crate::problem::Problem;
22
23
pub const MAX_STAMINA: i32 = 10;
24
pub const SUSPICION_LIMIT: u8 = 5;
25
pub const BRANCH_TURN: u32 = 10;
26
pub const FLOOR_TURNS: u32 = 6;
27
pub const STARTING_CASH: u32 = 20;
28
pub const FLOOR_ALLOWANCE: u32 = 10;
29
pub static REFRACTORY: LazyLock<Duration> = LazyLock::new(|| {
30
    let seconds = std::env::var("JELQD_REFRACTORY_SECS")
31
        .ok()
32
        .and_then(|s| s.parse().ok())
33
        .unwrap_or(30);
34
    Duration::from_secs(seconds)
35
});
36
pub const SELF_REPORT_INFLATION: f64 = 1.5;
37
pub const MM_PER_INCH: f64 = 25.4;
38
pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";
39
pub const CLINIC_ALLOW: &str = "GET, HEAD, PUT, PATCH, DELETE, OPTIONS, TRACE";
40
41
pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
42
pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
43
44
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
45
pub enum Floor {
46
    Bedroom,
47
    Bathroom,
48
    LockerRoom,
49
    Grandmas,
50
    Hotel,
51
    Forum,
52
    Clinic,
53
}
54
55
impl Floor {
56
    const ALL: [Self; 7] = [
57
        Self::Bedroom,
58
        Self::Bathroom,
59
        Self::LockerRoom,
60
        Self::Grandmas,
61
        Self::Hotel,
62
        Self::Forum,
63
        Self::Clinic,
64
    ];
65
66
    pub fn number(self) -> usize {
67
        Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + 1
68
    }
69
70
    fn next(self) -> Option<Self> {
71
        Self::ALL.get(self.number()).copied()
72
    }
73
74
    pub fn name(self) -> &'static str {
75
        match self {
76
            Self::Bedroom => "the bedroom",
77
            Self::Bathroom => "the shared bathroom",
78
            Self::LockerRoom => "the gym locker room",
79
            Self::Grandmas => "grandma's house for Thanksgiving",
80
            Self::Hotel => "a business trip hotel",
81
            Self::Forum => "the forum",
82
            Self::Clinic => "the urologist's office",
83
        }
84
    }
85
86
    pub fn note(self) -> &'static str {
87
        match self {
88
            Self::Bedroom => "The door does not lock. The cat is present.",
89
            Self::Bathroom => "The water is hot. The roommate showers at eight.",
90
            Self::LockerRoom => {
91
                "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."
92
            }
93
            Self::Grandmas => {
94
                "The walls are thin. Mom is here, and she has been reading about Digest authentication."
95
            }
96
            Self::Hotel => {
97
                "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."
98
            }
99
            Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",
100
            Self::Clinic => "The urologist will see you now. He is bored.",
101
        }
102
    }
103
104
    fn observer(self) -> Option<&'static str> {
105
        match self {
106
            Self::Bedroom => Some("cat"),
107
            Self::LockerRoom => Some("greg"),
108
            Self::Grandmas => Some("grandma"),
109
            _ => None,
110
        }
111
    }
112
113
    pub fn shop_open(self) -> bool {
114
        matches!(self, Self::LockerRoom | Self::Hotel)
115
    }
116
117
    pub fn has_mom(self) -> bool {
118
        matches!(self, Self::Bedroom | Self::Grandmas)
119
    }
120
}
121
122
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
123
pub enum Grip {
124
    Overhand,
125
    Underhand,
126
}
127
128
impl Grip {
129
    pub fn name(self) -> &'static str {
130
        match self {
131
            Self::Overhand => "overhand",
132
            Self::Underhand => "underhand",
133
        }
134
    }
135
136
    pub fn parse(protocol: &str) -> Option<Self> {
137
        match protocol.to_ascii_lowercase().as_str() {
138
            "overhand" => Some(Self::Overhand),
139
            "underhand" => Some(Self::Underhand),
140
            _ => None,
141
        }
142
    }
143
}
144
145
#[derive(Default)]
146
pub struct Items {
147
    pub ruler: bool,
148
    pub ice_packs: u32,
149
    pub door_locks: u32,
150
    pub dnd_sign: bool,
151
    pub pump_pro: bool,
152
    pub extender: bool,
153
    pub ring: bool,
154
    pub supplements: u32,
155
    pub cage: bool,
156
    pub spreadsheet: bool,
157
    pub teapot_spout: bool,
158
    pub blinds: bool,
159
}
160
161
enum Payload {
162
    Empty,
163
    Json(Value),
164
    Raw(&'static str, Vec<u8>),
165
}
166
167
pub struct Outcome {
168
    status: StatusCode,
169
    payload: Payload,
170
    headers: Vec<(HeaderName, String)>,
171
}
172
173
impl Outcome {
174
    pub fn empty(status: StatusCode) -> Self {
175
        Self {
176
            status,
177
            payload: Payload::Empty,
178
            headers: Vec::new(),
179
        }
180
    }
181
182
    pub fn json(status: StatusCode, body: Value) -> Self {
183
        Self {
184
            payload: Payload::Json(body),
185
            ..Self::empty(status)
186
        }
187
    }
188
189
    pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
190
        Self {
191
            payload: Payload::Raw(content_type, body.into()),
192
            ..Self::empty(status)
193
        }
194
    }
195
196
    pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
197
        self.headers.push((name, value.into()));
198
        self
199
    }
200
201
    pub fn status(&self) -> StatusCode {
202
        self.status
203
    }
204
}
205
206
impl IntoResponse for Outcome {
207
    fn into_response(self) -> Response {
208
        let mut response = match self.payload {
209
            // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
210
            Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {
211
                let full = body.to_string().into_bytes();
212
                let half = full.len() / 2;
213
                let range = format!("bytes 0-{}/{}", half - 1, full.len());
214
                (
215
                    self.status,
216
                    [
217
                        (header::CONTENT_TYPE, "application/json".to_owned()),
218
                        (header::CONTENT_RANGE, range),
219
                    ],
220
                    full[..half].to_vec(),
221
                )
222
                    .into_response()
223
            }
224
            Payload::Json(body) => (self.status, Json(body)).into_response(),
225
            Payload::Raw(content_type, body) => {
226
                (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()
227
            }
228
            Payload::Empty => self.status.into_response(),
229
        };
230
        for (name, value) in self.headers {
231
            let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
232
            response.headers_mut().append(name, value);
233
        }
234
        response
235
    }
236
}
237
238
pub struct Pending {
239
    due: u32,
240
    length: f64,
241
    girth: f64,
242
}
243
244
pub struct Ending {
245
    pub cause: &'static str,
246
    pub detail: String,
247
    pub turn: u32,
248
}
249
250
pub struct Run {
251
    pub id: String,
252
    pub origin_etag: String,
253
    pub version: u64,
254
    pub turn: u32,
255
    pub floor: Floor,
256
    pub floor_turns: u32,
257
    pub length: f64,
258
    pub temp_length: f64,
259
    pub girth: f64,
260
    pub foreskin: Option<f64>,
261
    pub eq: i32,
262
    pub stamina: i32,
263
    pub suspicion: u8,
264
    pub shame_base: u32,
265
    pub shame: u32,
266
    pub cash: u32,
267
    pub grip: Grip,
268
    pub items: Items,
269
    pub warmed_up: bool,
270
    pub teapot: bool,
271
    pub peyronie: bool,
272
    pub put_applied: bool,
273
    pub forum_read: bool,
274
    pub branch_pending: bool,
275
    pub mom_present: bool,
276
    pub mom_errand: &'static str,
277
    pub mom_strikes: u8,
278
    pub elder_strikes: u8,
279
    pub seeded: bool,
280
    pub towel_stolen: bool,
281
    pub pulled_muscle: bool,
282
    pub priapism_turns: u8,
283
    pub filmed_at: Option<SystemTime>,
284
    pub observed: Option<&'static str>,
285
    pub measured: bool,
286
    pub cured: bool,
287
    pub roommate_told: bool,
288
    pub wifi_accepted: bool,
289
    pub verified: bool,
290
    pub elder_beaten: bool,
291
    pub edges: u8,
292
    pub drawer_searches: u8,
293
    pub intake_attempts: usize,
294
    pub nonce: String,
295
    pub heard: Vec<String>,
296
    pub refractory_until: Option<Instant>,
297
    pub pending: Vec<Pending>,
298
    pub ended: Option<Ending>,
299
    rng: StdRng,
300
}
301
302
pub fn round2(x: f64) -> f64 {
303
    (x * 100.0).round() / 100.0
304
}
305
306
fn round3(x: f64) -> f64 {
307
    (x * 1000.0).round() / 1000.0
308
}
309
310
fn honest(claimed: f64, actual: f64) -> bool {
311
    (claimed - actual).abs() < 0.06
312
}
313
314
fn not_extended() -> Problem {
315
    Problem::new(
316
        StatusCode::NOT_EXTENDED,
317
        "not-extended",
318
        "The requested extension was not applied. The server has recorded the attempt.",
319
    )
320
}
321
322
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
323
    let (given, rest) = authorization.trim().split_once(' ')?;
324
    given
325
        .eq_ignore_ascii_case(scheme)
326
        .then(|| rest.trim().trim_matches('"'))
327
}
328
329
pub fn etag_matches(condition: &str, etag: &str) -> bool {
330
    condition
331
        .split(',')
332
        .map(str::trim)
333
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
334
}
335
336
pub fn shaft(mm: usize) -> String {
337
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
338
}
339
340
impl Run {
341
    pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
342
        let mut rng = StdRng::seed_from_u64(seed);
343
        let length = round2(rng.random_range(4.5..6.5));
344
        let girth = round2(rng.random_range(4.0..5.0));
345
        let foreskin = round2(rng.random_range(0.4..2.4));
346
        let mut run = Self {
347
            id,
348
            origin_etag: String::new(),
349
            version: 0,
350
            turn: 0,
351
            floor: Floor::Bedroom,
352
            floor_turns: 0,
353
            length,
354
            temp_length: 0.0,
355
            girth,
356
            foreskin: Some(foreskin),
357
            eq: 20,
358
            stamina: MAX_STAMINA,
359
            suspicion: 0,
360
            shame_base,
361
            shame: 0,
362
            cash: STARTING_CASH,
363
            grip: Grip::Overhand,
364
            items: Items {
365
                ice_packs: 1,
366
                ..Items::default()
367
            },
368
            warmed_up: false,
369
            teapot: false,
370
            peyronie: false,
371
            put_applied: false,
372
            forum_read: false,
373
            branch_pending: false,
374
            mom_present: false,
375
            mom_errand: "She heard a noise.",
376
            mom_strikes: 0,
377
            elder_strikes: 0,
378
            seeded: false,
379
            towel_stolen: false,
380
            pulled_muscle: false,
381
            priapism_turns: 0,
382
            filmed_at: None,
383
            observed: None,
384
            measured: false,
385
            cured: false,
386
            roommate_told: false,
387
            wifi_accepted: false,
388
            verified: false,
389
            elder_beaten: false,
390
            edges: 0,
391
            drawer_searches: 0,
392
            intake_attempts: 0,
393
            nonce: String::new(),
394
            heard: Vec::new(),
395
            refractory_until: None,
396
            pending: Vec::new(),
397
            ended: None,
398
            rng,
399
        };
400
        run.origin_etag = run.etag();
401
        run.nonce = run.fresh_nonce();
402
        run
403
    }
404
405
    pub fn etag(&self) -> String {
406
        format!("\"{}-{}\"", self.id, self.version)
407
    }
408
409
    pub fn touch(&mut self) {
410
        self.version += 1;
411
    }
412
413
    fn fresh_nonce(&mut self) -> String {
414
        format!("{:016x}", self.rng.random::<u64>())
415
    }
416
417
    pub fn current_length(&self) -> f64 {
418
        self.length + self.temp_length
419
    }
420
421
    pub fn self_reported(&self) -> f64 {
422
        self.current_length() + SELF_REPORT_INFLATION
423
    }
424
425
    pub fn max_stamina(&self) -> i32 {
426
        if self.pulled_muscle { 7 } else { MAX_STAMINA }
427
    }
428
429
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
430
        let filmed = self.filmed_at?;
431
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
432
        Some((filmed, age))
433
    }
434
435
    pub fn total_shame(&self) -> u32 {
436
        self.shame_base + self.shame
437
    }
438
439
    pub fn status(&self) -> &'static str {
440
        match (&self.ended, self.cured) {
441
            (Some(_), _) => "gone",
442
            (None, true) => "normal",
443
            (None, false) => "alive",
444
        }
445
    }
446
447
    pub fn view(&self) -> Value {
448
        let conditions: Vec<&str> = [
449
            (self.teapot, "short and stout"),
450
            (self.peyronie, "curved"),
451
            (self.items.cage, "caged"),
452
            (self.priapism_turns > 0, "priapism"),
453
            (self.pulled_muscle, "pulled muscle"),
454
            (self.filmed_at.is_some(), "viral"),
455
        ]
456
        .into_iter()
457
        .filter_map(|(active, name)| active.then_some(name))
458
        .collect();
459
        let mut view = json!({
460
            "id": self.id,
461
            "status": self.status(),
462
            "turn": self.turn,
463
            "floor": { "number": self.floor.number(), "name": self.floor.name() },
464
            "self_reported_length": round2(self.self_reported()),
465
            "girth": round2(self.girth),
466
            "foreskin": self.foreskin.map(round2),
467
            "eq": self.eq,
468
            "stamina": self.stamina,
469
            "suspicion": self.suspicion,
470
            "shame": self.total_shame(),
471
            "cash": self.cash,
472
            "grip": self.grip.name(),
473
            "warmed_up": self.warmed_up,
474
            "ranked": !self.seeded,
475
            "conditions": conditions,
476
        });
477
        if self.items.ruler {
478
            view["length"] = json!(round2(self.current_length()));
479
        }
480
        view
481
    }
482
483
    pub fn floor_view(&self) -> Value {
484
        let id = &self.id;
485
        let turns_required = match self.floor {
486
            Floor::Forum | Floor::Clinic => Value::Null,
487
            _ => json!(FLOOR_TURNS),
488
        };
489
        let mut view = json!({
490
            "number": self.floor.number(),
491
            "name": self.floor.name(),
492
            "note": self.floor.note(),
493
            "turns": self.floor_turns,
494
            "turns_required": turns_required,
495
            "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" },
496
        });
497
        match self.floor {
498
            Floor::LockerRoom => {
499
                view["others"] = json!([
500
                    { "name": "Greg", "self_reported_length": 9.5 },
501
                    { "name": "Dmitri", "self_reported_length": 8.75 },
502
                    { "name": "a man in a towel", "self_reported_length": 11.0 },
503
                ]);
504
            }
505
            Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")),
506
            Floor::Forum => {
507
                view["thread"] = json!(FORUM_THREAD);
508
                view["elder"] = json!(format!("/runs/{id}/elder"));
509
            }
510
            Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")),
511
            _ => {}
512
        }
513
        if self.floor.shop_open() {
514
            view["shop"] = json!(format!("/runs/{id}/shop"));
515
        }
516
        view
517
    }
518
519
    pub fn inventory(&self) -> Vec<(&'static str, &'static str)> {
520
        let i = &self.items;
521
        let owned = [
522
            (
523
                true,
524
                "warm-towel",
525
                "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
526
            ),
527
            (
528
                i.ice_packs > 0,
529
                "ice-pack",
530
                "Lowers EQ by 40. Use technique=ice.",
531
            ),
532
            (
533
                i.ruler,
534
                "ruler",
535
                "Measures honestly. The length resource now carries a Content-Length.",
536
            ),
537
            (
538
                i.door_locks > 0,
539
                "door-lock",
540
                "Keeps the roommate out once.",
541
            ),
542
            (
543
                i.dnd_sign,
544
                "do-not-disturb-sign",
545
                "Works on housekeeping. Mom does not read signs.",
546
            ),
547
            (
548
                i.pump_pro,
549
                "pump-pro",
550
                "Use technique=pump. Gains are temporary.",
551
            ),
552
            (
553
                i.extender,
554
                "extender",
555
                "Passive gains each turn. Takes up room. Can be removed with DELETE.",
556
            ),
557
            (
558
                i.ring,
559
                "cock-ring",
560
                "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.",
561
            ),
562
            (i.supplements > 0, "supplement", "Use technique=supplement."),
563
            (i.cage, "chastity-cage", "Locked."),
564
            (
565
                i.spreadsheet,
566
                "spreadsheet",
567
                "Charts your progress. Every line is flat.",
568
            ),
569
            (
570
                i.teapot_spout,
571
                "teapot-spout",
572
                "Does nothing. Cannot be dropped.",
573
            ),
574
            (
575
                i.blinds,
576
                "window-blinds",
577
                "The neighbour has a good camera. The blinds are better.",
578
            ),
579
        ];
580
        owned
581
            .into_iter()
582
            .filter_map(|(have, slug, description)| have.then_some((slug, description)))
583
            .collect()
584
    }
585
586
    pub fn end(&mut self, cause: &'static str, detail: &str) {
587
        self.ended = Some(Ending {
588
            cause,
589
            detail: detail.into(),
590
            turn: self.turn,
591
        });
592
    }
593
594
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
595
        let Some(until) = self.refractory_until else {
596
            return Ok(());
597
        };
598
        if now < until {
599
            let seconds = (until - now).as_secs() + 1;
600
            return Err(Problem::new(
601
                StatusCode::TOO_MANY_REQUESTS,
602
                "overtrained",
603
                "Stamina is exhausted. The refractory period is in effect.",
604
            )
605
            .header(header::RETRY_AFTER, seconds.to_string()));
606
        }
607
        self.refractory_until = None;
608
        self.stamina = self.max_stamina();
609
        self.eq = 20;
610
        self.warmed_up = false;
611
        self.touch();
612
        Ok(())
613
    }
614
615
    fn realize(&mut self) {
616
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
617
            .into_iter()
618
            .partition(|p| p.due <= self.turn);
619
        self.pending = waiting;
620
        for p in due {
621
            self.length += p.length;
622
            self.girth += p.girth;
623
        }
624
    }
625
626
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
627
        self.length += length;
628
        self.girth += girth;
629
        if let Some(f) = &mut self.foreskin {
630
            *f += foreskin;
631
        }
632
    }
633
634
    fn rupture(&mut self) -> Problem {
635
        let detail = if self.items.ice_packs > 0 {
636
            "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
637
        } else {
638
            "A vessel has exceeded its rated pressure. The run has ended."
639
        };
640
        self.end("vascular-event", detail);
641
        Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
642
            .with("eq_at_failure", self.eq)
643
            .with("turn", self.turn)
644
    }
645
646
    fn next_floor(&mut self, paid: bool) {
647
        let Some(next) = self.floor.next() else {
648
            return;
649
        };
650
        self.floor = next;
651
        self.floor_turns = 0;
652
        self.temp_length = 0.0;
653
        self.teapot = false;
654
        self.warmed_up = false;
655
        self.eq = 20;
656
        self.suspicion = 0;
657
        self.mom_present = false;
658
        self.roommate_told = false;
659
        self.drawer_searches = 0;
660
        self.towel_stolen = false;
661
        self.edges = 0;
662
        if paid {
663
            self.cash += FLOOR_ALLOWANCE;
664
        }
665
        self.nonce = self.fresh_nonce();
666
        self.touch();
667
    }
668
669
    pub fn advance(&mut self) -> Result<Outcome, Problem> {
670
        match self.floor {
671
            Floor::Clinic => {
672
                return Err(Problem::new(
673
                    StatusCode::CONFLICT,
674
                    "no-further-floor",
675
                    "There is no further floor. There is only the diagnosis.",
676
                ));
677
            }
678
            Floor::Forum if !self.elder_beaten => {
679
                return Err(Problem::new(
680
                    StatusCode::CONFLICT,
681
                    "elder-unaddressed",
682
                    "The Elder has not been addressed.",
683
                )
684
                .with("elder", format!("/runs/{}/elder", self.id)));
685
            }
686
            Floor::Forum => {}
687
            _ if self.floor_turns < FLOOR_TURNS => {
688
                return Err(Problem::new(
689
                    StatusCode::TOO_EARLY,
690
                    "floor-not-over",
691
                    format!(
692
                        "The floor is not over. {} turns remain.",
693
                        FLOOR_TURNS - self.floor_turns
694
                    ),
695
                ));
696
            }
697
            _ => {}
698
        }
699
        if self.rng.random_ratio(1, 200) {
700
            let detail =
701
                "The request conflicts with the current state of the zipper. The run has ended.";
702
            self.end("zipper", detail);
703
            return Err(Problem::new(StatusCode::CONFLICT, "zipper", detail));
704
        }
705
        let reverted = self.temp_length;
706
        self.next_floor(true);
707
        let mut body = json!({
708
            "detail": format!("You have arrived at {}.", self.floor.name()),
709
            "floor": self.floor.number(),
710
            "note": self.floor.note(),
711
        });
712
        if reverted > 0.0 {
713
            body["reverted"] = json!(round2(reverted));
714
        }
715
        Ok(Outcome::json(StatusCode::SEE_OTHER, body)
716
            .header(header::LOCATION, format!("/runs/{}/floor", self.id)))
717
    }
718
}