atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
e807266c323bcc1fd00f59aa160b3b94d8168beb
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust452 lines12.8 KB
1
pub mod balance;
2
mod body;
3
mod clinic;
4
mod floors;
5
mod forum;
6
mod hazards;
7
mod household;
8
mod items;
9
mod kitchen;
10
pub(crate) mod oracle;
11
mod outcome;
12
mod shop;
13
mod state;
14
mod techniques;
15
mod turn;
16
mod utility;
17
18
pub use body::PatchOp;
19
pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
20
pub use items::{Item, Items, SHOP};
21
pub use outcome::{Outcome, Payload};
22
pub use state::{
23
    Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer, Story,
24
};
25
pub use techniques::{LUBRICANTS, Lube, Technique};
26
27
use std::{
28
    hash::{DefaultHasher, Hash, Hasher},
29
    time::{Duration, Instant, SystemTime},
30
};
31
32
use axum::http::{StatusCode, header};
33
use serde_json::{Value, json};
34
35
use crate::problem::Problem;
36
use balance::{
37
    BRANCH_TURN, HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
38
    STARTING_CASH, STARTING_EQ,
39
};
40
use oracle::{Oracle, OracleTemplate};
41
use state::Pending;
42
43
pub struct Run {
44
    /// The private id in the run's URL. Knowing it is the only authorisation a run has.
45
    pub id: String,
46
    pub public_id: String,
47
    etag_salt: u64,
48
    pub version: u64,
49
    pub turn: u32,
50
    pub floor: Floor,
51
    pub here: FloorState,
52
    pub length: f64,
53
    pub girth: f64,
54
    pub foreskin: Option<f64>,
55
    pub eq: i32,
56
    pub stamina: i32,
57
    pub suspicion: u8,
58
    pub shame: u32,
59
    pub shame_reported: u32,
60
    pub shame_total: u32,
61
    pub cash: u32,
62
    pub grip: Grip,
63
    pub friction: i32,
64
    pub jelq_grip: Option<Grip>,
65
    pub items: Items,
66
    pub conditions: Conditions,
67
    pub warmed_up: bool,
68
    pub put_applied: bool,
69
    pub story: Story,
70
    pub uli_taught: bool,
71
    pub kitchen_tellings: u8,
72
    pub mum: Option<MumVisit>,
73
    pub heard: Vec<String>,
74
    pub elder: Elder,
75
    pub clinic: Clinic,
76
    pub seeded: bool,
77
    pub filmed_at: Option<SystemTime>,
78
    pub observed: Option<Observer>,
79
    pub refractory: Duration,
80
    pub refractory_until: Option<Instant>,
81
    pub pending: Vec<Pending>,
82
    pub notices: Vec<&'static str>,
83
    pub ended: Option<Ending>,
84
    pub last_seen: Instant,
85
    oracle: Oracle,
86
}
87
88
pub fn round2(x: f64) -> f64 {
89
    (x * 100.0).round() / 100.0
90
}
91
92
fn round3(x: f64) -> f64 {
93
    (x * 1000.0).round() / 1000.0
94
}
95
96
fn honest(claimed: f64, actual: f64) -> bool {
97
    (claimed - actual).abs() < HONEST_TOLERANCE
98
}
99
100
fn not_extended() -> Problem {
101
    Problem::new(
102
        StatusCode::NOT_EXTENDED,
103
        "not-extended",
104
        "The requested extension was not applied. The server has recorded the attempt.",
105
    )
106
}
107
108
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
109
    let (given, rest) = authorization.trim().split_once(' ')?;
110
    given
111
        .eq_ignore_ascii_case(scheme)
112
        .then(|| rest.trim().trim_matches('"'))
113
}
114
115
pub fn etag_matches(condition: &str, etag: &str) -> bool {
116
    condition
117
        .split(',')
118
        .map(str::trim)
119
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
120
}
121
122
pub fn shaft(mm: usize) -> String {
123
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
124
}
125
126
impl Run {
127
    #[cfg(test)]
128
    pub fn new(id: String, public_id: String, seed: u64) -> Self {
129
        Self::with_oracle(id, public_id, seed, &OracleTemplate::Rng)
130
    }
131
132
    pub fn with_oracle(
133
        id: String,
134
        public_id: String,
135
        seed: u64,
136
        template: &OracleTemplate,
137
    ) -> Self {
138
        let mut oracle = Oracle::build(template, seed);
139
        let length = round2(oracle.span(
140
            Run::unborn("length", "How long is it, in inches?"),
141
            4.5,
142
            6.5,
143
        ));
144
        let girth = round2(oracle.span(
145
            Run::unborn("girth", "How thick is it, in inches?"),
146
            4.0,
147
            5.0,
148
        ));
149
        let foreskin = round2(oracle.span(
150
            Run::unborn("foreskin", "How much foreskin is there, in inches?"),
151
            0.4,
152
            2.4,
153
        ));
154
        let etag_salt = oracle.u64();
155
        Self {
156
            id,
157
            public_id,
158
            etag_salt,
159
            version: 0,
160
            turn: 0,
161
            floor: Floor::Bedroom,
162
            here: FloorState::default(),
163
            length,
164
            girth,
165
            foreskin: Some(foreskin),
166
            eq: STARTING_EQ,
167
            stamina: MAX_STAMINA,
168
            suspicion: 0,
169
            shame: 0,
170
            shame_reported: 0,
171
            shame_total: 0,
172
            cash: STARTING_CASH,
173
            grip: Grip::Overhand,
174
            friction: 0,
175
            jelq_grip: None,
176
            items: Items::default(),
177
            conditions: Conditions::default(),
178
            warmed_up: false,
179
            put_applied: false,
180
            story: Story::Unopened,
181
            uli_taught: false,
182
            kitchen_tellings: 0,
183
            mum: None,
184
            heard: Vec::new(),
185
            notices: Vec::new(),
186
            elder: Elder::Unverified { strikes: 0 },
187
            clinic: Clinic::default(),
188
            seeded: false,
189
            filmed_at: None,
190
            observed: None,
191
            refractory: Duration::from_secs(30),
192
            refractory_until: None,
193
            pending: Vec::new(),
194
            ended: None,
195
            last_seen: Instant::now(),
196
            oracle,
197
        }
198
    }
199
200
    /// A roll for a run that does not exist yet, so there is no state to describe.
201
    fn unborn(site: &'static str, ask: &str) -> oracle::Roll {
202
        oracle::Roll {
203
            state: "A new run is being created. No game has been played yet.".into(),
204
            site,
205
            ask: ask.into(),
206
        }
207
    }
208
209
    fn etag_at(&self, version: u64) -> String {
210
        let mut hasher = DefaultHasher::new();
211
        (self.etag_salt, version).hash(&mut hasher);
212
        format!("\"{:016x}\"", hasher.finish())
213
    }
214
215
    pub fn etag(&self) -> String {
216
        self.etag_at(self.version)
217
    }
218
219
    /// The ETag the run had when it was created, which the true ending asks for.
220
    pub fn origin_etag(&self) -> String {
221
        self.etag_at(0)
222
    }
223
224
    pub fn touch(&mut self) {
225
        self.version += 1;
226
    }
227
228
    pub(super) fn shed(&mut self, friction: i32) {
229
        self.friction = (self.friction - friction).max(0);
230
    }
231
232
    /// Priapism holds EQ up no matter what else moves it.
233
    pub(super) fn reset_eq(&mut self, eq: i32) {
234
        let floor = if self.conditions.priapism_turns > 0 {
235
            PRIAPISM_EQ
236
        } else {
237
            0
238
        };
239
        self.eq = eq.max(floor);
240
    }
241
242
    /// A melon offer stands only while the raiding streak continues. Any other action ends it.
243
    pub(super) fn end_kitchen_visit(&mut self) {
244
        self.here.melon_offered = false;
245
    }
246
247
    /// Going back to the jelq walks away from both the fridge and Pump-Bro.
248
    pub(super) fn step_away(&mut self) {
249
        self.end_kitchen_visit();
250
        self.here.shopping = false;
251
    }
252
253
    fn fresh_nonce(&mut self) -> String {
254
        format!("{:016x}", self.oracle.u64())
255
    }
256
257
    /// One game decision for the oracle, phrased for the model and stamped
258
    /// with the state as the player sees it.
259
    fn decision(&self, site: &'static str, ask: impl std::fmt::Display) -> oracle::Roll {
260
        oracle::Roll {
261
            state: self.oracle_state(),
262
            site,
263
            ask: ask.to_string(),
264
        }
265
    }
266
267
    fn oracle_state(&self) -> String {
268
        let mut s = String::new();
269
        let place = match self.floor {
270
            Floor::Bedroom => "the bedroom",
271
            Floor::Bathroom => "the bathroom",
272
            Floor::ChangingRoom => "the changing room",
273
            Floor::Nans => "Nan's house",
274
            Floor::Hotel => "the hotel room",
275
            Floor::Forum => "the forum floor",
276
            Floor::Clinic => "the clinic",
277
        };
278
        s.push_str(&format!(
279
            "Floor {}, {place}. Turn {}.\n\n",
280
            self.floor.number(),
281
            self.turn
282
        ));
283
        s.push_str(&format!(
284
            "eq: {}\nstamina: {}\nfriction: {}\n",
285
            self.eq, self.stamina, self.friction
286
        ));
287
        if self.conditions.friction_burn {
288
            s.push_str("friction burn: yes, untreated\n");
289
        }
290
        if self.conditions.priapism_turns > 0 {
291
            s.push_str("priapism: yes\n");
292
        }
293
        if self.conditions.peyronie {
294
            s.push_str("peyronie's disease: yes\n");
295
        }
296
        if self.conditions.pulled_muscle {
297
            s.push_str("pulled muscle: yes\n");
298
        }
299
        s.push_str(&format!(
300
            "suspicion: {}\nshame: {}\nwarmed up: {}\ngrip: {}\n",
301
            self.suspicion,
302
            self.shame,
303
            if self.warmed_up { "yes" } else { "no" },
304
            self.grip.name(),
305
        ));
306
        let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
307
        if !held.is_empty() {
308
            s.push_str(&format!("items: {}\n", held.join(", ")));
309
        }
310
        if self.mum.is_some() {
311
            s.push_str("Mum is at the door\n");
312
        }
313
        s
314
    }
315
316
    fn chance(&mut self, roll: oracle::Roll, odds: u32) -> bool {
317
        self.oracle.chance(roll, odds)
318
    }
319
320
    pub fn path(&self, sub: &str) -> String {
321
        format!("/runs/{}{sub}", self.id)
322
    }
323
324
    pub fn current_length(&self) -> f64 {
325
        self.length + self.here.temp_length
326
    }
327
328
    pub fn self_reported(&self) -> f64 {
329
        self.current_length() + SELF_REPORT_INFLATION
330
    }
331
332
    pub fn max_stamina(&self) -> i32 {
333
        if self.conditions.pulled_muscle {
334
            PULLED_STAMINA
335
        } else {
336
            MAX_STAMINA
337
        }
338
    }
339
340
    pub fn forum_read(&self) -> bool {
341
        self.story == Story::ReadForum
342
    }
343
344
    pub fn thread_reachable(&self) -> bool {
345
        self.turn >= BRANCH_TURN
346
            || self.forum_read()
347
            || self.floor.number() >= Floor::Forum.number()
348
    }
349
350
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
351
        let filmed = self.filmed_at?;
352
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
353
        Some((filmed, age))
354
    }
355
356
    pub fn status(&self) -> &'static str {
357
        match (&self.ended, self.clinic.cured) {
358
            (Some(_), _) => "gone",
359
            (None, true) => "normal",
360
            (None, false) => "alive",
361
        }
362
    }
363
364
    /// The parts of the run that its response headers do not already carry.
365
    pub fn view(&self) -> Value {
366
        let conditions: Vec<&str> = [
367
            (self.here.teapot, "short and stout"),
368
            (self.conditions.peyronie, "curved"),
369
            (self.items.cage, "caged"),
370
            (self.conditions.priapism_turns > 0, "priapism"),
371
            (self.conditions.pulled_muscle, "pulled muscle"),
372
            (self.filmed_at.is_some(), "viral"),
373
        ]
374
        .into_iter()
375
        .filter_map(|(active, name)| active.then_some(name))
376
        .collect();
377
        json!({
378
            "public_id": self.public_id,
379
            "status": self.status(),
380
            "turn": self.turn,
381
            "run_shame": self.shame,
382
            "grip": self.grip.name(),
383
            "warmed_up": self.warmed_up,
384
            "ranked": !self.seeded,
385
            "conditions": conditions,
386
        })
387
    }
388
389
    pub fn end(&mut self, cause: Cause) -> Problem {
390
        self.ended = Some(Ending {
391
            cause,
392
            turn: self.turn,
393
        });
394
        cause.problem().with("turn", self.turn)
395
    }
396
397
    /// Ends the refractory period once it has run out, whatever the request.
398
    pub fn recover(&mut self, now: Instant) {
399
        if self.refractory_until.is_some_and(|until| now >= until) {
400
            self.refractory_until = None;
401
            self.stamina = self.max_stamina();
402
            self.reset_eq(STARTING_EQ);
403
            self.warmed_up = false;
404
            self.touch();
405
        }
406
    }
407
408
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
409
        self.recover(now);
410
        let Some(until) = self.refractory_until else {
411
            return Ok(());
412
        };
413
        let seconds = until.saturating_duration_since(now).as_secs() + 1;
414
        Err(Problem::new(
415
            StatusCode::TOO_MANY_REQUESTS,
416
            "overtrained",
417
            "Stamina is exhausted. The refractory period is in effect.",
418
        )
419
        .header(header::RETRY_AFTER, seconds.to_string()))
420
    }
421
422
    fn exhaust(&mut self, now: Instant) {
423
        self.stamina = 0;
424
        self.refractory_until = Some(now + self.refractory);
425
    }
426
427
    fn realise(&mut self) {
428
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
429
            .into_iter()
430
            .partition(|p| p.due <= self.turn);
431
        self.pending = waiting;
432
        for p in due {
433
            self.length += p.length;
434
            self.girth += p.girth;
435
        }
436
    }
437
438
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
439
        self.length += length;
440
        self.girth += girth;
441
        if let Some(f) = &mut self.foreskin {
442
            *f += foreskin;
443
        }
444
    }
445
446
    fn rupture(&mut self) -> Problem {
447
        let cause = Cause::Ruptured {
448
            ice_unused: self.items.ice_packs > 0,
449
        };
450
        self.end(cause)
451
    }
452
}