atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
53bce0d930007b21fb1f130865979bbd959edcc5
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/run.rsRust447 lines13.5 KB
1
use std::{
2
    hash::{DefaultHasher, Hash, Hasher},
3
    time::{Instant, SystemTime},
4
};
5
6
use axum::http::StatusCode;
7
use serde_json::{Value, json};
8
9
use super::{
10
    Body, Branch, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice,
11
    Observer, Online, RunStatus, Standing, Story,
12
    balance::{
13
        BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, REFRACTORY_STAMINA,
14
        SECOND_WIND, SELF_REPORT_INFLATION, STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,
15
    },
16
    measure::round2,
17
    oracle::{Oracle, OracleTemplate, Roll},
18
};
19
use crate::problem::Problem;
20
21
pub struct Identity {
22
    /// The private id in the run's URL. Knowing it is the only authorisation a run has.
23
    pub(crate) id: String,
24
    pub(crate) public_id: String,
25
    pub(crate) seeded: bool,
26
    etag_salt: u64,
27
    pub(crate) version: u64,
28
    pub(crate) last_seen: Instant,
29
}
30
31
pub struct Run {
32
    pub(crate) ident: Identity,
33
    pub(crate) body: Body,
34
    pub(crate) standing: Standing,
35
    pub(crate) household: Household,
36
    pub(crate) online: Online,
37
    pub(crate) items: Items,
38
    pub(crate) clinic: Clinic,
39
    pub(crate) turn: u32,
40
    pub(crate) floor: Floor,
41
    pub(crate) here: FloorState,
42
    pub(crate) observed: Option<Observer>,
43
    pub(crate) notices: Vec<Notice>,
44
    pub(crate) ended: Option<Ending>,
45
    pub(super) oracle: Oracle,
46
}
47
48
impl Run {
49
    #[cfg(test)]
50
    pub fn new(id: String, public_id: String, seed: u64) -> Self {
51
        Self::with_oracle(id, public_id, seed, false, &OracleTemplate::Rng)
52
    }
53
54
    pub fn with_oracle(
55
        id: String,
56
        public_id: String,
57
        seed: u64,
58
        seeded: bool,
59
        template: &OracleTemplate,
60
    ) -> Self {
61
        let mut oracle = Oracle::build(template, seed);
62
        let length = round2(oracle.span(
63
            Run::unborn("length", "How long is it, in inches?"),
64
            4.5,
65
            6.5,
66
        ));
67
        let girth = round2(oracle.span(
68
            Run::unborn("girth", "How thick is it, in inches?"),
69
            4.0,
70
            5.0,
71
        ));
72
        let foreskin = round2(oracle.span(
73
            Run::unborn("foreskin", "How much foreskin is there, in inches?"),
74
            0.4,
75
            2.4,
76
        ));
77
        let inflation = f64::from(oracle.span_int(
78
            Run::unborn(
79
                "inflation",
80
                "How far does the self-report round up, in tenths of an inch?",
81
            ),
82
            *SELF_REPORT_INFLATION.start(),
83
            *SELF_REPORT_INFLATION.end(),
84
        )) / 10.0;
85
        let mut items = Items::default();
86
        if oracle.chance(
87
            Run::unborn(
88
                "starting-lube",
89
                "Is there a bottle of lube in the drawer already?",
90
            ),
91
            STARTING_LUBE,
92
        ) {
93
            let baby_oil = oracle.chance(
94
                Run::unborn(
95
                    "starting-bottle",
96
                    "Is the bottle baby oil, rather than lotion?",
97
                ),
98
                STARTING_BABY_OIL,
99
            );
100
            items.grant(if baby_oil {
101
                Item::BabyOil
102
            } else {
103
                Item::Lotion
104
            });
105
        }
106
        let etag_salt = oracle.u64();
107
        Self {
108
            ident: Identity {
109
                id,
110
                public_id,
111
                seeded,
112
                etag_salt,
113
                version: 0,
114
                last_seen: Instant::now(),
115
            },
116
            body: Body::new(length, girth, foreskin, inflation),
117
            standing: Standing::default(),
118
            household: Household::default(),
119
            online: Online::default(),
120
            items,
121
            clinic: Clinic::default(),
122
            turn: 0,
123
            floor: Floor::Bedroom,
124
            here: FloorState::default(),
125
            observed: None,
126
            notices: Vec::new(),
127
            ended: None,
128
            oracle,
129
        }
130
    }
131
132
    fn unborn(site: &'static str, ask: &'static str) -> Roll {
133
        Roll {
134
            state: "A new run is being created. No game has been played yet.".into(),
135
            site,
136
            ask,
137
        }
138
    }
139
140
    fn etag_at(&self, version: u64) -> String {
141
        let mut hasher = DefaultHasher::new();
142
        (self.ident.etag_salt, version).hash(&mut hasher);
143
        format!("\"{:016x}\"", hasher.finish())
144
    }
145
146
    pub fn etag(&self) -> String {
147
        self.etag_at(self.ident.version)
148
    }
149
150
    /// The ETag the run had when it was created, which the true ending asks for.
151
    pub fn origin_etag(&self) -> String {
152
        self.etag_at(0)
153
    }
154
155
    pub fn touch(&mut self) {
156
        self.ident.version += 1;
157
    }
158
159
    pub(super) fn begin_turn(&mut self) -> Result<(), Problem> {
160
        self.check_clock()?;
161
        self.touch();
162
        self.turn += 1;
163
        self.here.turns += 1;
164
        self.body.torn = 0.0;
165
        self.standing.at_turn_start = self.standing.suspicion;
166
        self.body.heat_at_turn_start = self.heat();
167
        self.realise(false);
168
        if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {
169
            self.notices.push(Notice::RunningLong(self.floor));
170
        } else if self.floor.timed() && self.here.turns + 1 == FLOOR_LIMIT {
171
            self.notices.push(Notice::LastTurn);
172
        }
173
        Ok(())
174
    }
175
176
    pub(super) fn shed(&mut self, friction: i32) {
177
        self.body.friction = (self.body.friction - friction).max(0);
178
    }
179
180
    /// Priapism holds EQ up no matter what else moves it.
181
    pub(super) fn reset_eq(&mut self, eq: i32) {
182
        let floor = if self.body.conditions.priapism_turns > 0 {
183
            PRIAPISM_EQ
184
        } else {
185
            0
186
        };
187
        self.body.eq = eq.max(floor);
188
    }
189
190
    pub(super) fn end_kitchen_visit(&mut self) {
191
        self.here.kitchen.melon_offered = false;
192
    }
193
194
    /// Going back to the jelq walks away from both the fridge and Pump-Bro.
195
    pub(super) fn step_away(&mut self) {
196
        self.end_kitchen_visit();
197
        self.here.shopping = false;
198
    }
199
200
    pub(super) fn fresh_nonce(&mut self) -> String {
201
        format!("{:016x}", self.oracle.u64())
202
    }
203
204
    /// One game decision. The dice ignore the state prose, so it is only written for the model.
205
    pub(super) fn decision(&self, site: &'static str, ask: &'static str) -> Roll {
206
        Roll {
207
            state: if self.oracle.listens() {
208
                self.oracle_state()
209
            } else {
210
                String::new()
211
            },
212
            site,
213
            ask,
214
        }
215
    }
216
217
    fn oracle_state(&self) -> String {
218
        let mut s = format!(
219
            "Floor {}, {}. Turn {}.\n\neq: {}\nstamina: {}\nfriction: {}\n",
220
            self.floor.number(),
221
            self.floor.place(),
222
            self.turn,
223
            self.body.eq,
224
            self.body.stamina,
225
            self.body.friction
226
        );
227
        let conditions = &self.body.conditions;
228
        for (active, line) in [
229
            (conditions.friction_burn, "friction burn: yes, untreated\n"),
230
            (conditions.priapism_turns > 0, "priapism: yes\n"),
231
            (conditions.peyronie, "peyronie's disease: yes\n"),
232
            (conditions.pulled_muscle, "pulled muscle: yes\n"),
233
        ] {
234
            if active {
235
                s.push_str(line);
236
            }
237
        }
238
        s.push_str(&format!(
239
            "suspicion: {}\nshame: {}\ngrip: {}\n",
240
            self.standing.suspicion,
241
            self.standing.shame,
242
            self.body.grip.name(),
243
        ));
244
        let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
245
        if !held.is_empty() {
246
            s.push_str(&format!("items: {}\n", held.join(", ")));
247
        }
248
        if self.household.mum.is_some() {
249
            s.push_str("Mum is at the door\n");
250
        }
251
        s
252
    }
253
254
    pub(super) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
255
        self.oracle.chance(roll, odds)
256
    }
257
258
    pub fn path(&self, sub: &str) -> String {
259
        format!("/runs/{}{sub}", self.ident.id)
260
    }
261
262
    pub fn item_path(&self, item: Item) -> String {
263
        self.path(&format!("/inventory/{}", item.slug()))
264
    }
265
266
    pub fn shop_path(&self, item: Item) -> String {
267
        self.path(&format!("/shop/{}", item.slug()))
268
    }
269
270
    pub fn branch_path(&self, branch: Branch) -> String {
271
        self.path(&format!("/branches/{}", branch.slug()))
272
    }
273
274
    pub fn current_length(&self) -> f64 {
275
        self.body.length + self.here.temp_length
276
    }
277
278
    pub fn self_reported(&self) -> f64 {
279
        self.current_length() + self.body.inflation
280
    }
281
282
    pub fn shown_length(&self) -> f64 {
283
        if self.items.kit.ruler {
284
            self.current_length()
285
        } else {
286
            self.self_reported()
287
        }
288
    }
289
290
    pub fn max_stamina(&self) -> i32 {
291
        if self.body.conditions.pulled_muscle {
292
            PULLED_STAMINA
293
        } else {
294
            MAX_STAMINA
295
        }
296
    }
297
298
    pub fn forum_read(&self) -> bool {
299
        self.online.story == Story::ReadForum
300
    }
301
302
    pub fn thread_reachable(&self) -> bool {
303
        self.turn >= BRANCH_TURN
304
            || self.forum_read()
305
            || self.floor.number() >= Floor::Forum.number()
306
    }
307
308
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
309
        let filmed = self.online.filmed_at?;
310
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
311
        Some((filmed, age))
312
    }
313
314
    pub fn status(&self) -> RunStatus {
315
        if self.ended.is_some() {
316
            RunStatus::Gone
317
        } else if self.clinic.cured {
318
            RunStatus::Normal
319
        } else if self.clinic.prescribed {
320
            RunStatus::Prescribed
321
        } else {
322
            RunStatus::Alive
323
        }
324
    }
325
326
    /// The parts of the run that its response headers do not already carry.
327
    pub fn view(&self) -> Value {
328
        let conditions: Vec<&str> = [
329
            (self.here.teapot, "short and stout"),
330
            (self.body.conditions.peyronie, "curved"),
331
            (self.items.kit.cage, "caged"),
332
            (self.body.conditions.priapism_turns > 0, "priapism"),
333
            (self.body.conditions.pulled_muscle, "pulled muscle"),
334
            (self.online.filmed_at.is_some(), "viral"),
335
        ]
336
        .into_iter()
337
        .filter_map(|(active, name)| active.then_some(name))
338
        .collect();
339
        json!({
340
            "public_id": self.ident.public_id,
341
            "status": self.status().name(),
342
            "turn": self.turn,
343
            "run_shame": self.standing.shame,
344
            "grip": self.body.grip.name(),
345
            "ranked": !self.ident.seeded,
346
            "conditions": conditions,
347
        })
348
    }
349
350
    pub fn end(&mut self, cause: Cause) -> Problem {
351
        self.ended = Some(Ending {
352
            cause,
353
            turn: self.turn,
354
        });
355
        cause.problem().with("turn", self.turn)
356
    }
357
358
    /// The refractory period is the next HOLD. Returns whether it rolled the second wind that refills stamina outright.
359
    pub(super) fn recover(&mut self) -> bool {
360
        self.body.exhausted = false;
361
        let second_wind = self.chance(
362
            self.decision(
363
                "second-wind",
364
                "Does the refractory period end in a second wind?",
365
            ),
366
            SECOND_WIND,
367
        );
368
        self.body.stamina = if second_wind {
369
            self.max_stamina()
370
        } else {
371
            let (lo, hi) = REFRACTORY_STAMINA;
372
            let back = self.oracle.span_int(
373
                self.decision(
374
                    "refractory",
375
                    "How much stamina comes back after the refractory period?",
376
                ),
377
                lo,
378
                hi,
379
            );
380
            back.min(self.max_stamina())
381
        };
382
        self.reset_eq(STARTING_EQ);
383
        second_wind
384
    }
385
386
    pub(super) fn check_exhausted(&self) -> Result<(), Problem> {
387
        if !self.body.exhausted {
388
            return Ok(());
389
        }
390
        Err(Problem::new(
391
            StatusCode::TOO_MANY_REQUESTS,
392
            "overtrained",
393
            "Stamina is exhausted. HOLD for the refractory period.",
394
        ))
395
    }
396
397
    pub(super) fn exhaust(&mut self) {
398
        self.body.stamina = 0;
399
        self.body.exhausted = true;
400
    }
401
402
    /// Leaving a floor lets time pass, so everything still queued arrives at once.
403
    pub(super) fn realise(&mut self, everything: bool) {
404
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.body.pending)
405
            .into_iter()
406
            .partition(|p| everything || p.due <= self.turn);
407
        self.body.pending = waiting;
408
        for p in due {
409
            let foreskin = self.gain(p.length, p.girth, p.foreskin);
410
            let arrived = self.body.arrived.get_or_insert_default();
411
            arrived.length += p.length;
412
            arrived.girth += p.girth;
413
            if let Some(grown) = foreskin {
414
                *arrived.foreskin.get_or_insert(0.0) += grown;
415
            }
416
        }
417
    }
418
419
    /// The foreskin change it returns nets out whatever friction tore off this turn.
420
    pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) -> Option<f64> {
421
        self.body.length += length;
422
        self.body.girth += girth;
423
        let torn = std::mem::take(&mut self.body.torn);
424
        let grown = match &mut self.body.foreskin {
425
            Some(f) => {
426
                *f += foreskin;
427
                foreskin
428
            }
429
            None => 0.0,
430
        };
431
        (grown != 0.0 || torn != 0.0).then_some(grown - torn)
432
    }
433
434
    pub(super) fn rupture(&mut self) -> Problem {
435
        let cause = Cause::Ruptured {
436
            ice_unused: self.items.stores.ice_packs > 0,
437
        };
438
        self.end(cause)
439
    }
440
441
    /// A run whose lock was poisoned by a panic mid-turn cannot be trusted to carry on.
442
    pub fn lose(&mut self) {
443
        if self.ended.is_none() {
444
            self.end(Cause::Lost);
445
        }
446
    }
447
}