atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
c4d70621d377d45e3d418ac9197443c1b5d36399
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust450 lines12.5 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, volume};
19
pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
20
pub use household::vocabulary as excuse_vocabulary;
21
pub use items::{Item, Items, SHOP};
22
pub use outcome::{Outcome, Payload};
23
pub use state::{
24
    Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MumVisit, Observer,
25
    RunStatus, Story,
26
};
27
pub use techniques::{LUBRICANTS, Lube, Technique};
28
29
use std::{
30
    hash::{DefaultHasher, Hash, Hasher},
31
    time::{Instant, SystemTime},
32
};
33
34
use axum::http::StatusCode;
35
use serde_json::{Value, json};
36
37
use crate::problem::Problem;
38
use balance::{
39
    BRANCH_TURN, HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
40
    STARTING_CASH, STARTING_EQ,
41
};
42
use oracle::{Oracle, OracleTemplate};
43
use state::Pending;
44
45
pub struct Run {
46
    /// The private id in the run's URL. Knowing it is the only authorisation a run has.
47
    pub id: String,
48
    pub public_id: String,
49
    etag_salt: u64,
50
    pub version: u64,
51
    pub turn: u32,
52
    pub floor: Floor,
53
    pub here: FloorState,
54
    pub length: f64,
55
    pub girth: f64,
56
    pub foreskin: Option<f64>,
57
    pub eq: i32,
58
    pub stamina: i32,
59
    pub suspicion: u8,
60
    pub shame: u32,
61
    pub shame_reported: u32,
62
    pub shame_total: u32,
63
    pub cash: u32,
64
    pub grip: Grip,
65
    pub friction: i32,
66
    pub jelq_grip: Option<Grip>,
67
    pub items: Items,
68
    pub conditions: Conditions,
69
    pub warmed_up: bool,
70
    pub put_applied: bool,
71
    pub story: Story,
72
    pub uli_taught: bool,
73
    pub kitchen_tellings: u8,
74
    pub mum: Option<MumVisit>,
75
    pub heard: Vec<String>,
76
    pub elder: Elder,
77
    pub clinic: Clinic,
78
    pub seeded: bool,
79
    pub filmed_at: Option<SystemTime>,
80
    pub observed: Option<Observer>,
81
    pub exhausted: bool,
82
    pub pending: Vec<Pending>,
83
    pub notices: Vec<&'static str>,
84
    pub ended: Option<Ending>,
85
    pub last_seen: Instant,
86
    oracle: Oracle,
87
}
88
89
pub fn round2(x: f64) -> f64 {
90
    (x * 100.0).round() / 100.0
91
}
92
93
fn round3(x: f64) -> f64 {
94
    (x * 1000.0).round() / 1000.0
95
}
96
97
fn honest(claimed: f64, actual: f64) -> bool {
98
    (claimed - actual).abs() < HONEST_TOLERANCE
99
}
100
101
fn not_extended() -> Problem {
102
    Problem::new(
103
        StatusCode::NOT_EXTENDED,
104
        "not-extended",
105
        "The requested extension was not applied. The server has recorded the attempt.",
106
    )
107
}
108
109
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
110
    let (given, rest) = authorization.trim().split_once(' ')?;
111
    given
112
        .eq_ignore_ascii_case(scheme)
113
        .then(|| rest.trim().trim_matches('"'))
114
}
115
116
pub fn etag_matches(condition: &str, etag: &str) -> bool {
117
    condition
118
        .split(',')
119
        .map(str::trim)
120
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
121
}
122
123
pub fn shaft(mm: usize) -> String {
124
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
125
}
126
127
impl Run {
128
    #[cfg(test)]
129
    pub fn new(id: String, public_id: String, seed: u64) -> Self {
130
        Self::with_oracle(id, public_id, seed, &OracleTemplate::Rng)
131
    }
132
133
    pub fn with_oracle(
134
        id: String,
135
        public_id: String,
136
        seed: u64,
137
        template: &OracleTemplate,
138
    ) -> Self {
139
        let mut oracle = Oracle::build(template, seed);
140
        let length = round2(oracle.span(
141
            Run::unborn("length", "How long is it, in inches?"),
142
            4.5,
143
            6.5,
144
        ));
145
        let girth = round2(oracle.span(
146
            Run::unborn("girth", "How thick is it, in inches?"),
147
            4.0,
148
            5.0,
149
        ));
150
        let foreskin = round2(oracle.span(
151
            Run::unborn("foreskin", "How much foreskin is there, in inches?"),
152
            0.4,
153
            2.4,
154
        ));
155
        let etag_salt = oracle.u64();
156
        Self {
157
            id,
158
            public_id,
159
            etag_salt,
160
            version: 0,
161
            turn: 0,
162
            floor: Floor::Bedroom,
163
            here: FloorState::default(),
164
            length,
165
            girth,
166
            foreskin: Some(foreskin),
167
            eq: STARTING_EQ,
168
            stamina: MAX_STAMINA,
169
            suspicion: 0,
170
            shame: 0,
171
            shame_reported: 0,
172
            shame_total: 0,
173
            cash: STARTING_CASH,
174
            grip: Grip::Overhand,
175
            friction: 0,
176
            jelq_grip: None,
177
            items: Items::default(),
178
            conditions: Conditions::default(),
179
            warmed_up: false,
180
            put_applied: false,
181
            story: Story::Unopened,
182
            uli_taught: false,
183
            kitchen_tellings: 0,
184
            mum: None,
185
            heard: Vec::new(),
186
            notices: Vec::new(),
187
            elder: Elder::Unverified { strikes: 0 },
188
            clinic: Clinic::default(),
189
            seeded: false,
190
            filmed_at: None,
191
            observed: None,
192
            exhausted: false,
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) -> RunStatus {
357
        if self.ended.is_some() {
358
            RunStatus::Gone
359
        } else if self.clinic.cured {
360
            RunStatus::Normal
361
        } else if self.clinic.prescribed {
362
            RunStatus::Prescribed
363
        } else {
364
            RunStatus::Alive
365
        }
366
    }
367
368
    /// The parts of the run that its response headers do not already carry.
369
    pub fn view(&self) -> Value {
370
        let conditions: Vec<&str> = [
371
            (self.here.teapot, "short and stout"),
372
            (self.conditions.peyronie, "curved"),
373
            (self.items.cage, "caged"),
374
            (self.conditions.priapism_turns > 0, "priapism"),
375
            (self.conditions.pulled_muscle, "pulled muscle"),
376
            (self.filmed_at.is_some(), "viral"),
377
        ]
378
        .into_iter()
379
        .filter_map(|(active, name)| active.then_some(name))
380
        .collect();
381
        json!({
382
            "public_id": self.public_id,
383
            "status": self.status().name(),
384
            "turn": self.turn,
385
            "run_shame": self.shame,
386
            "grip": self.grip.name(),
387
            "warmed_up": self.warmed_up,
388
            "ranked": !self.seeded,
389
            "conditions": conditions,
390
        })
391
    }
392
393
    pub fn end(&mut self, cause: Cause) -> Problem {
394
        self.ended = Some(Ending {
395
            cause,
396
            turn: self.turn,
397
        });
398
        cause.problem().with("turn", self.turn)
399
    }
400
401
    /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.
402
    fn recover(&mut self) {
403
        self.exhausted = false;
404
        self.stamina = self.max_stamina();
405
        self.reset_eq(STARTING_EQ);
406
        self.warmed_up = false;
407
    }
408
409
    fn check_exhausted(&self) -> Result<(), Problem> {
410
        if !self.exhausted {
411
            return Ok(());
412
        }
413
        Err(Problem::new(
414
            StatusCode::TOO_MANY_REQUESTS,
415
            "overtrained",
416
            "Stamina is exhausted. HOLD for the refractory period.",
417
        ))
418
    }
419
420
    fn exhaust(&mut self) {
421
        self.stamina = 0;
422
        self.exhausted = true;
423
    }
424
425
    fn realise(&mut self) {
426
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
427
            .into_iter()
428
            .partition(|p| p.due <= self.turn);
429
        self.pending = waiting;
430
        for p in due {
431
            self.length += p.length;
432
            self.girth += p.girth;
433
        }
434
    }
435
436
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
437
        self.length += length;
438
        self.girth += girth;
439
        if let Some(f) = &mut self.foreskin {
440
            *f += foreskin;
441
        }
442
    }
443
444
    fn rupture(&mut self) -> Problem {
445
        let cause = Cause::Ruptured {
446
            ice_unused: self.items.ice_packs > 0,
447
        };
448
        self.end(cause)
449
    }
450
}