atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
35d571ccadc4c1643547ed0a6e9d3e1e99fc184f
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/forum.rsRust347 lines12.6 KB
1
use std::{sync::LazyLock, time::SystemTime};
2
3
use axum::http::{StatusCode, header};
4
use serde_json::{Value, json};
5
6
use super::{
7
    Cause, Floor, Item, Outcome, Run,
8
    balance::{
9
        EXAGGERATION, MEDITATION_ODDS, PATIENCE, PRINTABLE_RULER, RULER_SHAME, THREAD_READS,
10
        ULI_ODDS,
11
    },
12
    credentials::{Scheme, parse_credentials},
13
    measure::{exaggeration, honest},
14
};
15
use crate::{headers::X_FORUM_NOTE, problem::Problem};
16
17
pub const FORUM_THREAD: &str = "/forum/threads/2009";
18
pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";
19
static ELDER_POSTED: LazyLock<SystemTime> = LazyLock::new(|| {
20
    httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("the Elder's date is a valid HTTP-date")
21
});
22
23
const SPREADSHEET: &str = "\
24
date,length,girth,technique,notes,honest?
25
2007-01-03,7.50,5.50,wet,baseline,no
26
2007-06-11,7.50,5.50,wet,feeling fuller,no
27
2008-01-02,7.50,5.50,dry,new routine,no
28
2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
29
2009-06-14,7.50,5.50,wet,progress is slow but real,no
30
";
31
32
fn thread(detail: Option<&str>) -> Outcome {
33
    let mut body = forum_thread();
34
    if let Some(detail) = detail {
35
        body["detail"] = json!(detail);
36
    }
37
    Outcome::json(StatusCode::OK, body).header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)
38
}
39
40
pub fn forum_thread() -> Value {
41
    json!({
42
        "title": "The definitive jelqing FAQ (updated 2009)",
43
        "posts": [
44
            { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
45
            { "author": "TheElder", "body": "Keep it Ideal. Swollen is gambling. Straining is a cautionary tale." },
46
            { "author": "newbie_2009", "body": "everyone keeps saying uli. who is uli" },
47
            { "author": "TheElder", "body": "uli was a member here. He posted the technique in 2001 and never posted again. Some say he ascended. The search function says his account is gone." },
48
            { "author": "TheElder", "body": "We do not say his name in vain. We say it in girth." },
49
            { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },
50
            { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },
51
            { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },
52
            { "author": "newbie_2009", "body": "cant i just take off what i tell people. everyone adds an inch and a half" },
53
            { "author": "TheElder", "body": "Everyone adds a different amount. That is why there is a Ruler." },
54
            { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },
55
            { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },
56
        ],
57
    })
58
}
59
60
fn verification(detail: &str) -> Problem {
61
    Problem::new(
62
        StatusCode::PROXY_AUTHENTICATION_REQUIRED,
63
        "verification-required",
64
        detail,
65
    )
66
    .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")
67
}
68
69
/// The browser tab that opens on turn 10.
70
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
71
pub enum Story {
72
    Unopened,
73
    Pending,
74
    ReadForum,
75
    ClosedTab,
76
}
77
78
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
79
pub enum Branch {
80
    Forum,
81
    CloseTab,
82
}
83
84
impl Branch {
85
    pub fn slug(self) -> &'static str {
86
        match self {
87
            Self::Forum => "forum",
88
            Self::CloseTab => "close-tab",
89
        }
90
    }
91
}
92
93
impl std::str::FromStr for Branch {
94
    type Err = ();
95
96
    fn from_str(slug: &str) -> Result<Self, ()> {
97
        [Self::Forum, Self::CloseTab]
98
            .into_iter()
99
            .find(|branch| branch.slug() == slug)
100
            .ok_or(())
101
    }
102
}
103
104
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
105
pub enum Elder {
106
    Unverified { strikes: u8 },
107
    Verified,
108
    Beaten,
109
}
110
111
/// The run's life online, from the tab on turn 10 to the footage the neighbour took.
112
pub struct Online {
113
    pub(crate) story: Story,
114
    pub(crate) meditation_taught: bool,
115
    pub(crate) uli_taught: bool,
116
    pub(crate) elder: Elder,
117
    pub(crate) filmed_at: Option<SystemTime>,
118
}
119
120
impl Default for Online {
121
    fn default() -> Self {
122
        Self {
123
            story: Story::Unopened,
124
            meditation_taught: false,
125
            uli_taught: false,
126
            elder: Elder::Unverified { strikes: 0 },
127
            filmed_at: None,
128
        }
129
    }
130
}
131
132
impl Run {
133
    pub(super) fn branch_offer(&self) -> Outcome {
134
        let forum = self.path(&format!("/branches/{}", Branch::Forum.slug()));
135
        let close = self.path(&format!("/branches/{}", Branch::CloseTab.slug()));
136
        Outcome::json(
137
            StatusCode::MULTIPLE_CHOICES,
138
            json!({
139
                "detail": "A browser tab is open. It has been open for some time.",
140
                "choices": [
141
                    { "href": forum, "method": "POST", "title": "Read the forum" },
142
                    { "href": close, "method": "POST", "title": "Close the tab" },
143
                ],
144
            }),
145
        )
146
        .header(
147
            header::LINK,
148
            format!(
149
                "<{forum}>; rel=\"alternate\"; title=\"Read the forum\"; allow=\"POST\", <{close}>; rel=\"alternate\"; title=\"Close the tab\"; allow=\"POST\""
150
            ),
151
        )
152
    }
153
154
    pub fn choose(&mut self, choice: Branch) -> Result<Outcome, Problem> {
155
        if self.online.story != Story::Pending {
156
            return Err(Problem::new(
157
                StatusCode::CONFLICT,
158
                "no-decision-pending",
159
                "There is no decision pending.",
160
            ));
161
        }
162
        let (story, location, detail) = match choice {
163
            Branch::Forum => {
164
                self.standing.shame += 3;
165
                let detail = if self.learn_meditation() {
166
                    "The thread is from 2009. A technique called uli is discussed at length. Further down, someone explains how to breathe, and you write it down."
167
                } else {
168
                    "The thread is from 2009. A technique called uli is discussed at length."
169
                };
170
                (Story::ReadForum, self.path("/techniques"), detail)
171
            }
172
            Branch::CloseTab => {
173
                self.standing.suspicion = 0;
174
                (
175
                    Story::ClosedTab,
176
                    self.path(""),
177
                    "The tab is closed. Nobody saw it.",
178
                )
179
            }
180
        };
181
        self.online.story = story;
182
        self.touch();
183
        Ok(
184
            Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))
185
                .header(header::LOCATION, location),
186
        )
187
    }
188
189
    /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,
190
    /// after which the forum is down and the browser serves what it kept.
191
    pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
192
        if !self.thread_reachable() {
193
            return Err(Problem::new(
194
                StatusCode::NOT_FOUND,
195
                "no-thread",
196
                "There is no thread. Nobody has mentioned a forum yet.",
197
            ));
198
        }
199
        if self.online.story == Story::Pending {
200
            return Ok(thread(None));
201
        }
202
        self.end_kitchen_visit();
203
        let rulerless = self.floor == Floor::Forum && !self.items.kit.ruler;
204
        if self.online.uli_taught && !rulerless {
205
            return Ok(thread(None));
206
        }
207
        if self.here.thread_rolls >= THREAD_READS {
208
            let age = SystemTime::now()
209
                .duration_since(*ELDER_POSTED)
210
                .map_or(0, |age| age.as_secs());
211
            return Ok(thread(None)
212
                .header(header::AGE, age.to_string())
213
                .header(header::WARNING, r#"112 - "Disconnected operation""#));
214
        }
215
        self.here.thread_rolls += 1;
216
        self.touch();
217
        if rulerless
218
            && self.chance(
219
                self.decision(
220
                    "printable-ruler",
221
                    "Does this read surface a member's printable ruler?",
222
                ),
223
                PRINTABLE_RULER,
224
            )
225
        {
226
            self.items.grant(Item::Ruler);
227
            self.standing.shame += RULER_SHAME;
228
            return Ok(thread(Some(
229
                "A member has attached a printable ruler. Print at 100%. Do not scale to fit. You now know.",
230
            ))
231
            .header(header::LOCATION, self.path("/inventory/ruler")));
232
        }
233
        if self.online.uli_taught {
234
            return Ok(thread(None));
235
        }
236
        let detail = if !self.online.meditation_taught {
237
            if !self.learn_meditation() {
238
                return Ok(thread(None));
239
            }
240
            "Between two arguments about pumps, a reply explains how to breathe. In through the nose, out through the mouth. You write it down."
241
        } else {
242
            if !self.chance(
243
                self.decision(
244
                    "teach-uli",
245
                    "Does this read finally surface the 2009 reply that teaches uli?",
246
                ),
247
                ULI_ODDS,
248
            ) {
249
                return Ok(thread(None));
250
            }
251
            self.online.uli_taught = true;
252
            "Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it."
253
        };
254
        Ok(thread(Some(detail)))
255
    }
256
257
    fn learn_meditation(&mut self) -> bool {
258
        let taught = self.chance(
259
            self.decision(
260
                "teach-meditation",
261
                "Does the thread surface the reply that teaches the breathing technique?",
262
            ),
263
            MEDITATION_ODDS,
264
        );
265
        self.online.meditation_taught |= taught;
266
        taught
267
    }
268
269
    fn elder_strike(&mut self, problem: Problem) -> Problem {
270
        self.touch();
271
        let Elder::Unverified { strikes } = &mut self.online.elder else {
272
            return problem;
273
        };
274
        *strikes += 1;
275
        if *strikes < PATIENCE {
276
            return problem;
277
        }
278
        self.end(Cause::Banned)
279
    }
280
281
    /// Failed verification counts toward a ban even on GET, the way repeated bad credentials lock an account.
282
    fn verify_member(&mut self, proxy_authorization: Option<&str>) -> Result<(), Problem> {
283
        if !matches!(self.online.elder, Elder::Unverified { .. }) {
284
            return Ok(());
285
        }
286
        let Some(credentials) = proxy_authorization else {
287
            return Err(verification("The forum requires verification."));
288
        };
289
        let Some(claimed) =
290
            parse_credentials(credentials, Scheme::Ruler).and_then(|v| v.parse::<f64>().ok())
291
        else {
292
            return Err(verification("The forum requires verification, in inches."));
293
        };
294
        let actual = self.current_length();
295
        if claimed >= actual + EXAGGERATION {
296
            self.standing.shame += 2;
297
            return Err(self.elder_strike(exaggeration()));
298
        }
299
        if !honest(claimed, actual) {
300
            self.standing.shame += 1;
301
            let failed = verification("Verification failed. The forum can tell.");
302
            if self.girth_respected() {
303
                return Err(failed.header(
304
                    X_FORUM_NOTE,
305
                    "The mods have let this one go. Girth is also a dimension.",
306
                ));
307
            }
308
            return Err(self.elder_strike(failed));
309
        }
310
        self.online.elder = Elder::Verified;
311
        self.touch();
312
        Ok(())
313
    }
314
315
    pub fn elder(
316
        &mut self,
317
        proxy_authorization: Option<&str>,
318
        if_modified_since: Option<&str>,
319
    ) -> Result<Outcome, Problem> {
320
        if self.floor != Floor::Forum {
321
            return Err(Problem::new(
322
                StatusCode::NOT_FOUND,
323
                "elder-absent",
324
                "The Elder only posts on the forum.",
325
            ));
326
        }
327
        self.verify_member(proxy_authorization)?;
328
329
        let current = if_modified_since
330
            .and_then(|d| httpdate::parse_http_date(d).ok())
331
            .is_some_and(|since| since >= *ELDER_POSTED);
332
        if current {
333
            if self.online.elder != Elder::Beaten {
334
                self.online.elder = Elder::Beaten;
335
                self.touch();
336
            }
337
            return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
338
                .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));
339
        }
340
        let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
341
            .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);
342
        if self.online.filmed_at.is_some() {
343
            return Ok(spreadsheet.header(X_FORUM_NOTE, "We have seen the footage."));
344
        }
345
        Ok(spreadsheet)
346
    }
347
}