atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
a8269407e054211fed82e710616e72cfa4f72961
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/run.rsRust406 lines12.1 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, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,
11
    Online, RunStatus, Standing, Story,
12
    balance::{
13
        BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
14
        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 mut items = Items::default();
78
        if oracle.chance(
79
            Run::unborn(
80
                "starting-lube",
81
                "Is there a bottle of lube in the drawer already?",
82
            ),
83
            STARTING_LUBE,
84
        ) {
85
            let baby_oil = oracle.chance(
86
                Run::unborn(
87
                    "starting-bottle",
88
                    "Is the bottle baby oil, rather than lotion?",
89
                ),
90
                STARTING_BABY_OIL,
91
            );
92
            items.grant(if baby_oil {
93
                Item::BabyOil
94
            } else {
95
                Item::Lotion
96
            });
97
        }
98
        let etag_salt = oracle.u64();
99
        Self {
100
            ident: Identity {
101
                id,
102
                public_id,
103
                seeded,
104
                etag_salt,
105
                version: 0,
106
                last_seen: Instant::now(),
107
            },
108
            body: Body::new(length, girth, foreskin),
109
            standing: Standing::default(),
110
            household: Household::default(),
111
            online: Online::default(),
112
            items,
113
            clinic: Clinic::default(),
114
            turn: 0,
115
            floor: Floor::Bedroom,
116
            here: FloorState::default(),
117
            observed: None,
118
            notices: Vec::new(),
119
            ended: None,
120
            oracle,
121
        }
122
    }
123
124
    fn unborn(site: &'static str, ask: &'static str) -> Roll {
125
        Roll {
126
            state: "A new run is being created. No game has been played yet.".into(),
127
            site,
128
            ask,
129
        }
130
    }
131
132
    fn etag_at(&self, version: u64) -> String {
133
        let mut hasher = DefaultHasher::new();
134
        (self.ident.etag_salt, version).hash(&mut hasher);
135
        format!("\"{:016x}\"", hasher.finish())
136
    }
137
138
    pub fn etag(&self) -> String {
139
        self.etag_at(self.ident.version)
140
    }
141
142
    /// The ETag the run had when it was created, which the true ending asks for.
143
    pub fn origin_etag(&self) -> String {
144
        self.etag_at(0)
145
    }
146
147
    pub fn touch(&mut self) {
148
        self.ident.version += 1;
149
    }
150
151
    pub(super) fn begin_turn(&mut self) -> Result<(), Problem> {
152
        if self.floor.timed() && self.here.turns + 1 >= FLOOR_LIMIT {
153
            return Err(self.overstay());
154
        }
155
        self.touch();
156
        self.turn += 1;
157
        self.here.turns += 1;
158
        self.body.torn = 0.0;
159
        self.standing.at_turn_start = self.standing.suspicion;
160
        self.realise();
161
        if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {
162
            self.notices.push(Notice::RunningLong(self.floor));
163
        } else if self.floor.timed() && self.here.turns + 1 == FLOOR_LIMIT {
164
            self.notices.push(Notice::LastTurn);
165
        }
166
        Ok(())
167
    }
168
169
    pub(super) fn shed(&mut self, friction: i32) {
170
        self.body.friction = (self.body.friction - friction).max(0);
171
    }
172
173
    /// Priapism holds EQ up no matter what else moves it.
174
    pub(super) fn reset_eq(&mut self, eq: i32) {
175
        let floor = if self.body.conditions.priapism_turns > 0 {
176
            PRIAPISM_EQ
177
        } else {
178
            0
179
        };
180
        self.body.eq = eq.max(floor);
181
    }
182
183
    pub(super) fn end_kitchen_visit(&mut self) {
184
        self.here.kitchen.melon_offered = false;
185
    }
186
187
    /// Going back to the jelq walks away from both the fridge and Pump-Bro.
188
    pub(super) fn step_away(&mut self) {
189
        self.end_kitchen_visit();
190
        self.here.shopping = false;
191
    }
192
193
    pub(super) fn fresh_nonce(&mut self) -> String {
194
        format!("{:016x}", self.oracle.u64())
195
    }
196
197
    /// One game decision. The dice ignore the state prose, so it is only written for the model.
198
    pub(super) fn decision(&self, site: &'static str, ask: &'static str) -> Roll {
199
        Roll {
200
            state: if self.oracle.listens() {
201
                self.oracle_state()
202
            } else {
203
                String::new()
204
            },
205
            site,
206
            ask,
207
        }
208
    }
209
210
    fn oracle_state(&self) -> String {
211
        let mut s = format!(
212
            "Floor {}, {}. Turn {}.\n\neq: {}\nstamina: {}\nfriction: {}\n",
213
            self.floor.number(),
214
            self.floor.place(),
215
            self.turn,
216
            self.body.eq,
217
            self.body.stamina,
218
            self.body.friction
219
        );
220
        let conditions = &self.body.conditions;
221
        for (active, line) in [
222
            (conditions.friction_burn, "friction burn: yes, untreated\n"),
223
            (conditions.priapism_turns > 0, "priapism: yes\n"),
224
            (conditions.peyronie, "peyronie's disease: yes\n"),
225
            (conditions.pulled_muscle, "pulled muscle: yes\n"),
226
        ] {
227
            if active {
228
                s.push_str(line);
229
            }
230
        }
231
        s.push_str(&format!(
232
            "suspicion: {}\nshame: {}\ngrip: {}\n",
233
            self.standing.suspicion,
234
            self.standing.shame,
235
            self.body.grip.name(),
236
        ));
237
        let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
238
        if !held.is_empty() {
239
            s.push_str(&format!("items: {}\n", held.join(", ")));
240
        }
241
        if self.household.mum.is_some() {
242
            s.push_str("Mum is at the door\n");
243
        }
244
        s
245
    }
246
247
    pub(super) fn chance(&mut self, roll: Roll, odds: u32) -> bool {
248
        self.oracle.chance(roll, odds)
249
    }
250
251
    pub fn path(&self, sub: &str) -> String {
252
        format!("/runs/{}{sub}", self.ident.id)
253
    }
254
255
    pub fn current_length(&self) -> f64 {
256
        self.body.length + self.here.temp_length
257
    }
258
259
    pub fn self_reported(&self) -> f64 {
260
        self.current_length() + SELF_REPORT_INFLATION
261
    }
262
263
    pub fn shown_length(&self) -> f64 {
264
        if self.items.kit.ruler {
265
            self.current_length()
266
        } else {
267
            self.self_reported()
268
        }
269
    }
270
271
    pub fn max_stamina(&self) -> i32 {
272
        if self.body.conditions.pulled_muscle {
273
            PULLED_STAMINA
274
        } else {
275
            MAX_STAMINA
276
        }
277
    }
278
279
    pub fn forum_read(&self) -> bool {
280
        self.online.story == Story::ReadForum
281
    }
282
283
    pub fn thread_reachable(&self) -> bool {
284
        self.turn >= BRANCH_TURN
285
            || self.forum_read()
286
            || self.floor.number() >= Floor::Forum.number()
287
    }
288
289
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
290
        let filmed = self.online.filmed_at?;
291
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
292
        Some((filmed, age))
293
    }
294
295
    pub fn status(&self) -> RunStatus {
296
        if self.ended.is_some() {
297
            RunStatus::Gone
298
        } else if self.clinic.cured {
299
            RunStatus::Normal
300
        } else if self.clinic.prescribed {
301
            RunStatus::Prescribed
302
        } else {
303
            RunStatus::Alive
304
        }
305
    }
306
307
    /// The parts of the run that its response headers do not already carry.
308
    pub fn view(&self) -> Value {
309
        let conditions: Vec<&str> = [
310
            (self.here.teapot, "short and stout"),
311
            (self.body.conditions.peyronie, "curved"),
312
            (self.items.kit.cage, "caged"),
313
            (self.body.conditions.priapism_turns > 0, "priapism"),
314
            (self.body.conditions.pulled_muscle, "pulled muscle"),
315
            (self.online.filmed_at.is_some(), "viral"),
316
        ]
317
        .into_iter()
318
        .filter_map(|(active, name)| active.then_some(name))
319
        .collect();
320
        json!({
321
            "public_id": self.ident.public_id,
322
            "status": self.status().name(),
323
            "turn": self.turn,
324
            "run_shame": self.standing.shame,
325
            "grip": self.body.grip.name(),
326
            "ranked": !self.ident.seeded,
327
            "conditions": conditions,
328
        })
329
    }
330
331
    pub fn end(&mut self, cause: Cause) -> Problem {
332
        self.ended = Some(Ending {
333
            cause,
334
            turn: self.turn,
335
        });
336
        cause.problem().with("turn", self.turn)
337
    }
338
339
    /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.
340
    pub(super) fn recover(&mut self) {
341
        self.body.exhausted = false;
342
        self.body.stamina = self.max_stamina();
343
        self.reset_eq(STARTING_EQ);
344
    }
345
346
    pub(super) fn check_exhausted(&self) -> Result<(), Problem> {
347
        if !self.body.exhausted {
348
            return Ok(());
349
        }
350
        Err(Problem::new(
351
            StatusCode::TOO_MANY_REQUESTS,
352
            "overtrained",
353
            "Stamina is exhausted. HOLD for the refractory period.",
354
        ))
355
    }
356
357
    pub(super) fn exhaust(&mut self) {
358
        self.body.stamina = 0;
359
        self.body.exhausted = true;
360
    }
361
362
    pub(super) fn realise(&mut self) {
363
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.body.pending)
364
            .into_iter()
365
            .partition(|p| p.due <= self.turn);
366
        self.body.pending = waiting;
367
        for p in due {
368
            let foreskin = self.gain(p.length, p.girth, p.foreskin);
369
            let arrived = self.body.arrived.get_or_insert_default();
370
            arrived.length += p.length;
371
            arrived.girth += p.girth;
372
            if let Some(grown) = foreskin {
373
                *arrived.foreskin.get_or_insert(0.0) += grown;
374
            }
375
        }
376
    }
377
378
    /// The foreskin change it returns nets out whatever friction tore off this turn.
379
    pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) -> Option<f64> {
380
        self.body.length += length;
381
        self.body.girth += girth;
382
        let torn = std::mem::take(&mut self.body.torn);
383
        let grown = match &mut self.body.foreskin {
384
            Some(f) => {
385
                *f += foreskin;
386
                foreskin
387
            }
388
            None => 0.0,
389
        };
390
        (grown != 0.0 || torn != 0.0).then_some(grown - torn)
391
    }
392
393
    pub(super) fn rupture(&mut self) -> Problem {
394
        let cause = Cause::Ruptured {
395
            ice_unused: self.items.stores.ice_packs > 0,
396
        };
397
        self.end(cause)
398
    }
399
400
    /// A run whose lock was poisoned by a panic mid-turn cannot be trusted to carry on.
401
    pub fn lose(&mut self) {
402
        if self.ended.is_none() {
403
            self.end(Cause::Lost);
404
        }
405
    }
406
}