atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
2ea5ef81f040ab9608f3421508d03314efb7f6d8
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust684 lines19.9 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},
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
}
159
160
enum Payload {
161
    Empty,
162
    Json(Value),
163
    Raw(&'static str, Vec<u8>),
164
}
165
166
pub struct Outcome {
167
    status: StatusCode,
168
    payload: Payload,
169
    headers: Vec<(HeaderName, String)>,
170
}
171
172
impl Outcome {
173
    pub fn empty(status: StatusCode) -> Self {
174
        Self {
175
            status,
176
            payload: Payload::Empty,
177
            headers: Vec::new(),
178
        }
179
    }
180
181
    pub fn json(status: StatusCode, body: Value) -> Self {
182
        Self {
183
            payload: Payload::Json(body),
184
            ..Self::empty(status)
185
        }
186
    }
187
188
    pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
189
        Self {
190
            payload: Payload::Raw(content_type, body.into()),
191
            ..Self::empty(status)
192
        }
193
    }
194
195
    pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
196
        self.headers.push((name, value.into()));
197
        self
198
    }
199
200
    pub fn status(&self) -> StatusCode {
201
        self.status
202
    }
203
}
204
205
impl IntoResponse for Outcome {
206
    fn into_response(self) -> Response {
207
        let mut response = match self.payload {
208
            // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.
209
            Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {
210
                let full = body.to_string().into_bytes();
211
                let half = full.len() / 2;
212
                let range = format!("bytes 0-{}/{}", half - 1, full.len());
213
                (
214
                    self.status,
215
                    [
216
                        (header::CONTENT_TYPE, "application/json".to_owned()),
217
                        (header::CONTENT_RANGE, range),
218
                    ],
219
                    full[..half].to_vec(),
220
                )
221
                    .into_response()
222
            }
223
            Payload::Json(body) => (self.status, Json(body)).into_response(),
224
            Payload::Raw(content_type, body) => {
225
                (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()
226
            }
227
            Payload::Empty => self.status.into_response(),
228
        };
229
        for (name, value) in self.headers {
230
            let value = HeaderValue::from_str(&value).expect("header values are visible ascii");
231
            response.headers_mut().append(name, value);
232
        }
233
        response
234
    }
235
}
236
237
pub struct Pending {
238
    due: u32,
239
    length: f64,
240
    girth: f64,
241
}
242
243
pub struct Ending {
244
    pub cause: &'static str,
245
    pub detail: String,
246
    pub turn: u32,
247
}
248
249
pub struct Run {
250
    pub id: String,
251
    pub origin_etag: String,
252
    pub version: u64,
253
    pub turn: u32,
254
    pub floor: Floor,
255
    pub floor_turns: u32,
256
    pub length: f64,
257
    pub temp_length: f64,
258
    pub girth: f64,
259
    pub foreskin: Option<f64>,
260
    pub eq: i32,
261
    pub stamina: i32,
262
    pub suspicion: u8,
263
    pub shame_base: u32,
264
    pub shame: u32,
265
    pub cash: u32,
266
    pub grip: Grip,
267
    pub items: Items,
268
    pub warmed_up: bool,
269
    pub teapot: bool,
270
    pub peyronie: bool,
271
    pub put_applied: bool,
272
    pub forum_read: bool,
273
    pub branch_pending: bool,
274
    pub mom_present: bool,
275
    pub mom_errand: &'static str,
276
    pub mom_strikes: u8,
277
    pub elder_strikes: u8,
278
    pub seeded: bool,
279
    pub observed: Option<&'static str>,
280
    pub measured: bool,
281
    pub cured: bool,
282
    pub roommate_told: bool,
283
    pub wifi_accepted: bool,
284
    pub verified: bool,
285
    pub elder_beaten: bool,
286
    pub edges: u8,
287
    pub drawer_searches: u8,
288
    pub intake_attempts: usize,
289
    pub nonce: String,
290
    pub heard: Vec<String>,
291
    pub refractory_until: Option<Instant>,
292
    pub pending: Vec<Pending>,
293
    pub ended: Option<Ending>,
294
    rng: StdRng,
295
}
296
297
pub fn round2(x: f64) -> f64 {
298
    (x * 100.0).round() / 100.0
299
}
300
301
fn round3(x: f64) -> f64 {
302
    (x * 1000.0).round() / 1000.0
303
}
304
305
fn honest(claimed: f64, actual: f64) -> bool {
306
    (claimed - actual).abs() < 0.06
307
}
308
309
fn not_extended() -> Problem {
310
    Problem::new(
311
        StatusCode::NOT_EXTENDED,
312
        "not-extended",
313
        "The requested extension was not applied. The server has recorded the attempt.",
314
    )
315
}
316
317
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
318
    let (given, rest) = authorization.trim().split_once(' ')?;
319
    given
320
        .eq_ignore_ascii_case(scheme)
321
        .then(|| rest.trim().trim_matches('"'))
322
}
323
324
pub fn etag_matches(condition: &str, etag: &str) -> bool {
325
    condition
326
        .split(',')
327
        .map(str::trim)
328
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
329
}
330
331
pub fn shaft(mm: usize) -> String {
332
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
333
}
334
335
impl Run {
336
    pub fn new(id: String, seed: u64, shame_base: u32) -> Self {
337
        let mut rng = StdRng::seed_from_u64(seed);
338
        let length = round2(rng.random_range(4.5..6.5));
339
        let girth = round2(rng.random_range(4.0..5.0));
340
        let foreskin = round2(rng.random_range(0.4..2.4));
341
        let mut run = Self {
342
            id,
343
            origin_etag: String::new(),
344
            version: 0,
345
            turn: 0,
346
            floor: Floor::Bedroom,
347
            floor_turns: 0,
348
            length,
349
            temp_length: 0.0,
350
            girth,
351
            foreskin: Some(foreskin),
352
            eq: 20,
353
            stamina: MAX_STAMINA,
354
            suspicion: 0,
355
            shame_base,
356
            shame: 0,
357
            cash: STARTING_CASH,
358
            grip: Grip::Overhand,
359
            items: Items {
360
                ice_packs: 1,
361
                ..Items::default()
362
            },
363
            warmed_up: false,
364
            teapot: false,
365
            peyronie: false,
366
            put_applied: false,
367
            forum_read: false,
368
            branch_pending: false,
369
            mom_present: false,
370
            mom_errand: "She heard a noise.",
371
            mom_strikes: 0,
372
            elder_strikes: 0,
373
            seeded: false,
374
            observed: None,
375
            measured: false,
376
            cured: false,
377
            roommate_told: false,
378
            wifi_accepted: false,
379
            verified: false,
380
            elder_beaten: false,
381
            edges: 0,
382
            drawer_searches: 0,
383
            intake_attempts: 0,
384
            nonce: String::new(),
385
            heard: Vec::new(),
386
            refractory_until: None,
387
            pending: Vec::new(),
388
            ended: None,
389
            rng,
390
        };
391
        run.origin_etag = run.etag();
392
        run.nonce = run.fresh_nonce();
393
        run
394
    }
395
396
    pub fn etag(&self) -> String {
397
        format!("\"{}-{}\"", self.id, self.version)
398
    }
399
400
    pub fn touch(&mut self) {
401
        self.version += 1;
402
    }
403
404
    fn fresh_nonce(&mut self) -> String {
405
        format!("{:016x}", self.rng.random::<u64>())
406
    }
407
408
    pub fn current_length(&self) -> f64 {
409
        self.length + self.temp_length
410
    }
411
412
    pub fn self_reported(&self) -> f64 {
413
        self.current_length() + SELF_REPORT_INFLATION
414
    }
415
416
    pub fn total_shame(&self) -> u32 {
417
        self.shame_base + self.shame
418
    }
419
420
    pub fn status(&self) -> &'static str {
421
        match (&self.ended, self.cured) {
422
            (Some(_), _) => "gone",
423
            (None, true) => "normal",
424
            (None, false) => "alive",
425
        }
426
    }
427
428
    pub fn view(&self) -> Value {
429
        let conditions: Vec<&str> = [
430
            (self.teapot, "short and stout"),
431
            (self.peyronie, "curved"),
432
            (self.items.cage, "caged"),
433
        ]
434
        .into_iter()
435
        .filter_map(|(active, name)| active.then_some(name))
436
        .collect();
437
        let mut view = json!({
438
            "id": self.id,
439
            "status": self.status(),
440
            "turn": self.turn,
441
            "floor": { "number": self.floor.number(), "name": self.floor.name() },
442
            "self_reported_length": round2(self.self_reported()),
443
            "girth": round2(self.girth),
444
            "foreskin": self.foreskin.map(round2),
445
            "eq": self.eq,
446
            "stamina": self.stamina,
447
            "suspicion": self.suspicion,
448
            "shame": self.total_shame(),
449
            "cash": self.cash,
450
            "grip": self.grip.name(),
451
            "warmed_up": self.warmed_up,
452
            "ranked": !self.seeded,
453
            "conditions": conditions,
454
        });
455
        if self.items.ruler {
456
            view["length"] = json!(round2(self.current_length()));
457
        }
458
        view
459
    }
460
461
    pub fn floor_view(&self) -> Value {
462
        let id = &self.id;
463
        let turns_required = match self.floor {
464
            Floor::Forum | Floor::Clinic => Value::Null,
465
            _ => json!(FLOOR_TURNS),
466
        };
467
        let mut view = json!({
468
            "number": self.floor.number(),
469
            "name": self.floor.name(),
470
            "note": self.floor.note(),
471
            "turns": self.floor_turns,
472
            "turns_required": turns_required,
473
            "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" },
474
        });
475
        match self.floor {
476
            Floor::LockerRoom => {
477
                view["others"] = json!([
478
                    { "name": "Greg", "self_reported_length": 9.5 },
479
                    { "name": "Dmitri", "self_reported_length": 8.75 },
480
                    { "name": "a man in a towel", "self_reported_length": 11.0 },
481
                ]);
482
            }
483
            Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")),
484
            Floor::Forum => {
485
                view["thread"] = json!(FORUM_THREAD);
486
                view["elder"] = json!(format!("/runs/{id}/elder"));
487
            }
488
            Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")),
489
            _ => {}
490
        }
491
        if self.floor.shop_open() {
492
            view["shop"] = json!(format!("/runs/{id}/shop"));
493
        }
494
        view
495
    }
496
497
    pub fn inventory(&self) -> Vec<(&'static str, &'static str)> {
498
        let i = &self.items;
499
        let owned = [
500
            (
501
                true,
502
                "warm-towel",
503
                "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
504
            ),
505
            (
506
                i.ice_packs > 0,
507
                "ice-pack",
508
                "Lowers EQ by 40. Use technique=ice.",
509
            ),
510
            (
511
                i.ruler,
512
                "ruler",
513
                "Measures honestly. The length resource now carries a Content-Length.",
514
            ),
515
            (
516
                i.door_locks > 0,
517
                "door-lock",
518
                "Keeps the roommate out once.",
519
            ),
520
            (
521
                i.dnd_sign,
522
                "do-not-disturb-sign",
523
                "Works on housekeeping. Mom does not read signs.",
524
            ),
525
            (
526
                i.pump_pro,
527
                "pump-pro",
528
                "Use technique=pump. Gains are temporary.",
529
            ),
530
            (
531
                i.extender,
532
                "extender",
533
                "Passive gains each turn. Takes up room. Can be removed with DELETE.",
534
            ),
535
            (
536
                i.ring,
537
                "cock-ring",
538
                "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.",
539
            ),
540
            (i.supplements > 0, "supplement", "Use technique=supplement."),
541
            (i.cage, "chastity-cage", "Locked."),
542
            (
543
                i.spreadsheet,
544
                "spreadsheet",
545
                "Charts your progress. Every line is flat.",
546
            ),
547
            (
548
                i.teapot_spout,
549
                "teapot-spout",
550
                "Does nothing. Cannot be dropped.",
551
            ),
552
        ];
553
        owned
554
            .into_iter()
555
            .filter_map(|(have, slug, description)| have.then_some((slug, description)))
556
            .collect()
557
    }
558
559
    pub fn end(&mut self, cause: &'static str, detail: &str) {
560
        self.ended = Some(Ending {
561
            cause,
562
            detail: detail.into(),
563
            turn: self.turn,
564
        });
565
    }
566
567
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
568
        let Some(until) = self.refractory_until else {
569
            return Ok(());
570
        };
571
        if now < until {
572
            let seconds = (until - now).as_secs() + 1;
573
            return Err(Problem::new(
574
                StatusCode::TOO_MANY_REQUESTS,
575
                "overtrained",
576
                "Stamina is exhausted. The refractory period is in effect.",
577
            )
578
            .header(header::RETRY_AFTER, seconds.to_string()));
579
        }
580
        self.refractory_until = None;
581
        self.stamina = MAX_STAMINA;
582
        self.eq = 20;
583
        self.warmed_up = false;
584
        self.touch();
585
        Ok(())
586
    }
587
588
    fn realize(&mut self) {
589
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
590
            .into_iter()
591
            .partition(|p| p.due <= self.turn);
592
        self.pending = waiting;
593
        for p in due {
594
            self.length += p.length;
595
            self.girth += p.girth;
596
        }
597
    }
598
599
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
600
        self.length += length;
601
        self.girth += girth;
602
        if let Some(f) = &mut self.foreskin {
603
            *f += foreskin;
604
        }
605
    }
606
607
    fn rupture(&mut self) -> Problem {
608
        let detail = if self.items.ice_packs > 0 {
609
            "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."
610
        } else {
611
            "A vessel has exceeded its rated pressure. The run has ended."
612
        };
613
        self.end("vascular-event", detail);
614
        Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)
615
            .with("eq_at_failure", self.eq)
616
            .with("turn", self.turn)
617
    }
618
619
    fn next_floor(&mut self, paid: bool) {
620
        let Some(next) = self.floor.next() else {
621
            return;
622
        };
623
        self.floor = next;
624
        self.floor_turns = 0;
625
        self.temp_length = 0.0;
626
        self.teapot = false;
627
        self.warmed_up = false;
628
        self.eq = 20;
629
        self.suspicion = 0;
630
        self.mom_present = false;
631
        self.roommate_told = false;
632
        self.drawer_searches = 0;
633
        self.edges = 0;
634
        if paid {
635
            self.cash += FLOOR_ALLOWANCE;
636
        }
637
        self.nonce = self.fresh_nonce();
638
        self.touch();
639
    }
640
641
    pub fn advance(&mut self) -> Result<Outcome, Problem> {
642
        match self.floor {
643
            Floor::Clinic => {
644
                return Err(Problem::new(
645
                    StatusCode::CONFLICT,
646
                    "no-further-floor",
647
                    "There is no further floor. There is only the diagnosis.",
648
                ));
649
            }
650
            Floor::Forum if !self.elder_beaten => {
651
                return Err(Problem::new(
652
                    StatusCode::CONFLICT,
653
                    "elder-unaddressed",
654
                    "The Elder has not been addressed.",
655
                )
656
                .with("elder", format!("/runs/{}/elder", self.id)));
657
            }
658
            Floor::Forum => {}
659
            _ if self.floor_turns < FLOOR_TURNS => {
660
                return Err(Problem::new(
661
                    StatusCode::TOO_EARLY,
662
                    "floor-not-over",
663
                    format!(
664
                        "The floor is not over. {} turns remain.",
665
                        FLOOR_TURNS - self.floor_turns
666
                    ),
667
                ));
668
            }
669
            _ => {}
670
        }
671
        let reverted = self.temp_length;
672
        self.next_floor(true);
673
        let mut body = json!({
674
            "detail": format!("You have arrived at {}.", self.floor.name()),
675
            "floor": self.floor.number(),
676
            "note": self.floor.note(),
677
        });
678
        if reverted > 0.0 {
679
            body["reverted"] = json!(round2(reverted));
680
        }
681
        Ok(Outcome::json(StatusCode::SEE_OTHER, body)
682
            .header(header::LOCATION, format!("/runs/{}/floor", self.id)))
683
    }
684
}