atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
3f9091557ec3a198324147b05397e1bf5c50247a
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust440 lines12.4 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
    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 raid continues. Any other turn ends it.
243
    pub(super) fn end_kitchen_visit(&mut self) {
244
        self.here.melon_offered = false;
245
    }
246
247
    fn fresh_nonce(&mut self) -> String {
248
        format!("{:016x}", self.oracle.u64())
249
    }
250
251
    /// One game decision for the oracle, phrased for the model and stamped
252
    /// with the state as the player sees it.
253
    fn decision(&self, site: &'static str, ask: impl std::fmt::Display) -> oracle::Roll {
254
        oracle::Roll {
255
            state: self.oracle_state(),
256
            site,
257
            ask: ask.to_string(),
258
        }
259
    }
260
261
    fn oracle_state(&self) -> String {
262
        let mut s = String::new();
263
        let place = match self.floor {
264
            Floor::Bedroom => "the bedroom",
265
            Floor::Bathroom => "the bathroom",
266
            Floor::ChangingRoom => "the changing room",
267
            Floor::Nans => "Nan's house",
268
            Floor::Hotel => "the hotel room",
269
            Floor::Forum => "the forum floor",
270
            Floor::Clinic => "the clinic",
271
        };
272
        s.push_str(&format!(
273
            "Floor {}, {place}. Turn {}.\n\n",
274
            self.floor.number(),
275
            self.turn
276
        ));
277
        s.push_str(&format!(
278
            "eq: {}\nstamina: {}\nfriction: {}\n",
279
            self.eq, self.stamina, self.friction
280
        ));
281
        if self.conditions.friction_burn {
282
            s.push_str("friction burn: yes, untreated\n");
283
        }
284
        if self.conditions.priapism_turns > 0 {
285
            s.push_str("priapism: yes\n");
286
        }
287
        if self.conditions.peyronie {
288
            s.push_str("peyronie's disease: yes\n");
289
        }
290
        if self.conditions.pulled_muscle {
291
            s.push_str("pulled muscle: yes\n");
292
        }
293
        s.push_str(&format!(
294
            "suspicion: {}\nshame: {}\nwarmed up: {}\ngrip: {}\n",
295
            self.suspicion,
296
            self.shame,
297
            if self.warmed_up { "yes" } else { "no" },
298
            self.grip.name(),
299
        ));
300
        let held: Vec<&str> = self.items.owned().iter().map(|item| item.slug()).collect();
301
        if !held.is_empty() {
302
            s.push_str(&format!("items: {}\n", held.join(", ")));
303
        }
304
        if self.mum.is_some() {
305
            s.push_str("Mum is at the door\n");
306
        }
307
        s
308
    }
309
310
    fn chance(&mut self, roll: oracle::Roll, odds: u32) -> bool {
311
        self.oracle.chance(roll, odds)
312
    }
313
314
    pub fn path(&self, sub: &str) -> String {
315
        format!("/runs/{}{sub}", self.id)
316
    }
317
318
    pub fn current_length(&self) -> f64 {
319
        self.length + self.here.temp_length
320
    }
321
322
    pub fn self_reported(&self) -> f64 {
323
        self.current_length() + SELF_REPORT_INFLATION
324
    }
325
326
    pub fn max_stamina(&self) -> i32 {
327
        if self.conditions.pulled_muscle {
328
            PULLED_STAMINA
329
        } else {
330
            MAX_STAMINA
331
        }
332
    }
333
334
    pub fn forum_read(&self) -> bool {
335
        self.story == Story::ReadForum
336
    }
337
338
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
339
        let filmed = self.filmed_at?;
340
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
341
        Some((filmed, age))
342
    }
343
344
    pub fn status(&self) -> &'static str {
345
        match (&self.ended, self.clinic.cured) {
346
            (Some(_), _) => "gone",
347
            (None, true) => "normal",
348
            (None, false) => "alive",
349
        }
350
    }
351
352
    /// The parts of the run that its response headers do not already carry.
353
    pub fn view(&self) -> Value {
354
        let conditions: Vec<&str> = [
355
            (self.here.teapot, "short and stout"),
356
            (self.conditions.peyronie, "curved"),
357
            (self.items.cage, "caged"),
358
            (self.conditions.priapism_turns > 0, "priapism"),
359
            (self.conditions.pulled_muscle, "pulled muscle"),
360
            (self.filmed_at.is_some(), "viral"),
361
        ]
362
        .into_iter()
363
        .filter_map(|(active, name)| active.then_some(name))
364
        .collect();
365
        json!({
366
            "public_id": self.public_id,
367
            "status": self.status(),
368
            "turn": self.turn,
369
            "run_shame": self.shame,
370
            "grip": self.grip.name(),
371
            "warmed_up": self.warmed_up,
372
            "ranked": !self.seeded,
373
            "conditions": conditions,
374
        })
375
    }
376
377
    pub fn end(&mut self, cause: Cause) -> Problem {
378
        self.ended = Some(Ending {
379
            cause,
380
            turn: self.turn,
381
        });
382
        cause.problem().with("turn", self.turn)
383
    }
384
385
    /// Ends the refractory period once it has run out, whatever the request.
386
    pub fn recover(&mut self, now: Instant) {
387
        if self.refractory_until.is_some_and(|until| now >= until) {
388
            self.refractory_until = None;
389
            self.stamina = self.max_stamina();
390
            self.reset_eq(STARTING_EQ);
391
            self.warmed_up = false;
392
            self.touch();
393
        }
394
    }
395
396
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
397
        self.recover(now);
398
        let Some(until) = self.refractory_until else {
399
            return Ok(());
400
        };
401
        let seconds = until.saturating_duration_since(now).as_secs() + 1;
402
        Err(Problem::new(
403
            StatusCode::TOO_MANY_REQUESTS,
404
            "overtrained",
405
            "Stamina is exhausted. The refractory period is in effect.",
406
        )
407
        .header(header::RETRY_AFTER, seconds.to_string()))
408
    }
409
410
    fn exhaust(&mut self, now: Instant) {
411
        self.stamina = 0;
412
        self.refractory_until = Some(now + self.refractory);
413
    }
414
415
    fn realise(&mut self) {
416
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
417
            .into_iter()
418
            .partition(|p| p.due <= self.turn);
419
        self.pending = waiting;
420
        for p in due {
421
            self.length += p.length;
422
            self.girth += p.girth;
423
        }
424
    }
425
426
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
427
        self.length += length;
428
        self.girth += girth;
429
        if let Some(f) = &mut self.foreskin {
430
            *f += foreskin;
431
        }
432
    }
433
434
    fn rupture(&mut self) -> Problem {
435
        let cause = Cause::Ruptured {
436
            ice_unused: self.items.ice_packs > 0,
437
        };
438
        self.end(cause)
439
    }
440
}