atagen

jelq

public

an http based roguelike rpg about jelqing

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