atagen

jelq

public

an http based roguelike rpg about jelqing

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