atagen

jelq

public

an http based roguelike rpg about jelqing

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