atagen

jelq

public

an http based roguelike rpg about jelqing

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