atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
782069860236703e2dd3c52bb589b9e2d8d48176
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/mod.rsRust315 lines8.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 outcome;
10
mod shop;
11
mod state;
12
mod techniques;
13
mod turn;
14
mod utility;
15
16
pub use body::PatchOp;
17
pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
18
pub use items::{Item, Items};
19
pub use outcome::{Outcome, Payload};
20
pub use state::{
21
    Cause, Clinic, Conditions, Elder, Ending, Floor, FloorState, Grip, MomVisit, Observer, Story,
22
};
23
pub use techniques::{LUBRICANTS, Lube, Technique};
24
25
use std::{
26
    hash::{DefaultHasher, Hash, Hasher},
27
    time::{Duration, Instant, SystemTime},
28
};
29
30
use axum::http::{StatusCode, header};
31
use rand::{Rng, SeedableRng, rngs::StdRng};
32
use serde_json::{Value, json};
33
34
use crate::problem::Problem;
35
use balance::{
36
    HONEST_TOLERANCE, MAX_STAMINA, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_CASH,
37
    STARTING_EQ,
38
};
39
use state::Pending;
40
41
pub struct Run {
42
    /// The private id in the run's URL. Knowing it is the only authorization a run has.
43
    pub id: String,
44
    pub public_id: String,
45
    etag_salt: u64,
46
    pub version: u64,
47
    pub turn: u32,
48
    pub floor: Floor,
49
    pub here: FloorState,
50
    pub length: f64,
51
    pub girth: f64,
52
    pub foreskin: Option<f64>,
53
    pub eq: i32,
54
    pub stamina: i32,
55
    pub suspicion: u8,
56
    pub shame: u32,
57
    pub shame_reported: u32,
58
    pub shame_total: u32,
59
    pub cash: u32,
60
    pub grip: Grip,
61
    pub items: Items,
62
    pub conditions: Conditions,
63
    pub warmed_up: bool,
64
    pub put_applied: bool,
65
    pub story: Story,
66
    pub mom: Option<MomVisit>,
67
    pub heard: Vec<String>,
68
    pub elder: Elder,
69
    pub clinic: Clinic,
70
    pub seeded: bool,
71
    pub filmed_at: Option<SystemTime>,
72
    pub observed: Option<Observer>,
73
    pub refractory: Duration,
74
    pub refractory_until: Option<Instant>,
75
    pub pending: Vec<Pending>,
76
    pub ended: Option<Ending>,
77
    pub last_seen: Instant,
78
    rng: StdRng,
79
}
80
81
pub fn round2(x: f64) -> f64 {
82
    (x * 100.0).round() / 100.0
83
}
84
85
fn round3(x: f64) -> f64 {
86
    (x * 1000.0).round() / 1000.0
87
}
88
89
fn honest(claimed: f64, actual: f64) -> bool {
90
    (claimed - actual).abs() < HONEST_TOLERANCE
91
}
92
93
fn not_extended() -> Problem {
94
    Problem::new(
95
        StatusCode::NOT_EXTENDED,
96
        "not-extended",
97
        "The requested extension was not applied. The server has recorded the attempt.",
98
    )
99
}
100
101
pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {
102
    let (given, rest) = authorization.trim().split_once(' ')?;
103
    given
104
        .eq_ignore_ascii_case(scheme)
105
        .then(|| rest.trim().trim_matches('"'))
106
}
107
108
pub fn etag_matches(condition: &str, etag: &str) -> bool {
109
    condition
110
        .split(',')
111
        .map(str::trim)
112
        .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)
113
}
114
115
pub fn shaft(mm: usize) -> String {
116
    format!("8{}D", "=".repeat(mm.saturating_sub(2)))
117
}
118
119
impl Run {
120
    pub fn new(id: String, public_id: String, seed: u64) -> Self {
121
        let mut rng = StdRng::seed_from_u64(seed);
122
        let length = round2(rng.random_range(4.5..6.5));
123
        let girth = round2(rng.random_range(4.0..5.0));
124
        let foreskin = round2(rng.random_range(0.4..2.4));
125
        let etag_salt = rng.random();
126
        Self {
127
            id,
128
            public_id,
129
            etag_salt,
130
            version: 0,
131
            turn: 0,
132
            floor: Floor::Bedroom,
133
            here: FloorState::default(),
134
            length,
135
            girth,
136
            foreskin: Some(foreskin),
137
            eq: STARTING_EQ,
138
            stamina: MAX_STAMINA,
139
            suspicion: 0,
140
            shame: 0,
141
            shame_reported: 0,
142
            shame_total: 0,
143
            cash: STARTING_CASH,
144
            grip: Grip::Overhand,
145
            items: Items::default(),
146
            conditions: Conditions::default(),
147
            warmed_up: false,
148
            put_applied: false,
149
            story: Story::Unopened,
150
            mom: None,
151
            heard: Vec::new(),
152
            elder: Elder::Unverified { strikes: 0 },
153
            clinic: Clinic::default(),
154
            seeded: false,
155
            filmed_at: None,
156
            observed: None,
157
            refractory: Duration::from_secs(30),
158
            refractory_until: None,
159
            pending: Vec::new(),
160
            ended: None,
161
            last_seen: Instant::now(),
162
            rng,
163
        }
164
    }
165
166
    fn etag_at(&self, version: u64) -> String {
167
        let mut hasher = DefaultHasher::new();
168
        (self.etag_salt, version).hash(&mut hasher);
169
        format!("\"{:016x}\"", hasher.finish())
170
    }
171
172
    pub fn etag(&self) -> String {
173
        self.etag_at(self.version)
174
    }
175
176
    /// The ETag the run had when it was created, which the true ending asks for.
177
    pub fn origin_etag(&self) -> String {
178
        self.etag_at(0)
179
    }
180
181
    pub fn touch(&mut self) {
182
        self.version += 1;
183
    }
184
185
    fn fresh_nonce(&mut self) -> String {
186
        format!("{:016x}", self.rng.random::<u64>())
187
    }
188
189
    fn chance(&mut self, odds: u32) -> bool {
190
        self.rng.random_ratio(1, odds)
191
    }
192
193
    pub fn path(&self, sub: &str) -> String {
194
        format!("/runs/{}{sub}", self.id)
195
    }
196
197
    pub fn current_length(&self) -> f64 {
198
        self.length + self.here.temp_length
199
    }
200
201
    pub fn self_reported(&self) -> f64 {
202
        self.current_length() + SELF_REPORT_INFLATION
203
    }
204
205
    pub fn max_stamina(&self) -> i32 {
206
        if self.conditions.pulled_muscle {
207
            PULLED_STAMINA
208
        } else {
209
            MAX_STAMINA
210
        }
211
    }
212
213
    pub fn forum_read(&self) -> bool {
214
        self.story == Story::ReadForum
215
    }
216
217
    pub fn footage(&self) -> Option<(SystemTime, u64)> {
218
        let filmed = self.filmed_at?;
219
        let age = filmed.elapsed().map_or(0, |d| d.as_secs());
220
        Some((filmed, age))
221
    }
222
223
    pub fn status(&self) -> &'static str {
224
        match (&self.ended, self.clinic.cured) {
225
            (Some(_), _) => "gone",
226
            (None, true) => "normal",
227
            (None, false) => "alive",
228
        }
229
    }
230
231
    /// The parts of the run that its response headers do not already carry.
232
    pub fn view(&self) -> Value {
233
        let conditions: Vec<&str> = [
234
            (self.here.teapot, "short and stout"),
235
            (self.conditions.peyronie, "curved"),
236
            (self.items.cage, "caged"),
237
            (self.conditions.priapism_turns > 0, "priapism"),
238
            (self.conditions.pulled_muscle, "pulled muscle"),
239
            (self.filmed_at.is_some(), "viral"),
240
        ]
241
        .into_iter()
242
        .filter_map(|(active, name)| active.then_some(name))
243
        .collect();
244
        json!({
245
            "public_id": self.public_id,
246
            "status": self.status(),
247
            "turn": self.turn,
248
            "run_shame": self.shame,
249
            "grip": self.grip.name(),
250
            "warmed_up": self.warmed_up,
251
            "ranked": !self.seeded,
252
            "conditions": conditions,
253
        })
254
    }
255
256
    pub fn end(&mut self, cause: Cause) -> Problem {
257
        self.ended = Some(Ending {
258
            cause,
259
            turn: self.turn,
260
        });
261
        cause.problem().with("turn", self.turn)
262
    }
263
264
    fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
265
        let Some(until) = self.refractory_until else {
266
            return Ok(());
267
        };
268
        if now < until {
269
            let seconds = (until - now).as_secs() + 1;
270
            return Err(Problem::new(
271
                StatusCode::TOO_MANY_REQUESTS,
272
                "overtrained",
273
                "Stamina is exhausted. The refractory period is in effect.",
274
            )
275
            .header(header::RETRY_AFTER, seconds.to_string()));
276
        }
277
        self.refractory_until = None;
278
        self.stamina = self.max_stamina();
279
        self.eq = STARTING_EQ;
280
        self.warmed_up = false;
281
        self.touch();
282
        Ok(())
283
    }
284
285
    fn exhaust(&mut self, now: Instant) {
286
        self.stamina = 0;
287
        self.refractory_until = Some(now + self.refractory);
288
    }
289
290
    fn realize(&mut self) {
291
        let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
292
            .into_iter()
293
            .partition(|p| p.due <= self.turn);
294
        self.pending = waiting;
295
        for p in due {
296
            self.length += p.length;
297
            self.girth += p.girth;
298
        }
299
    }
300
301
    fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
302
        self.length += length;
303
        self.girth += girth;
304
        if let Some(f) = &mut self.foreskin {
305
            *f += foreskin;
306
        }
307
    }
308
309
    fn rupture(&mut self) -> Problem {
310
        let cause = Cause::Ruptured {
311
            ice_unused: self.items.ice_packs > 0,
312
        };
313
        self.end(cause)
314
    }
315
}