atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
c4d70621d377d45e3d418ac9197443c1b5d36399
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/game/forum.rsRust246 lines9.5 KB
1
use std::time::SystemTime;
2
3
use axum::http::{StatusCode, header};
4
use serde_json::{Value, json};
5
6
use super::{
7
    Cause, Elder, Floor, Outcome, Run, Story,
8
    balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},
9
    body::exaggeration,
10
    honest, parse_credentials,
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
17
const SPREADSHEET: &str = "\
18
date,length,girth,technique,notes,honest?
19
2007-01-03,7.50,5.50,wet,baseline,no
20
2007-06-11,7.50,5.50,wet,feeling fuller,no
21
2008-01-02,7.50,5.50,dry,new routine,no
22
2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no
23
2009-06-14,7.50,5.50,wet,progress is slow but real,no
24
";
25
26
fn nothing_new() -> Outcome {
27
    Outcome::empty(StatusCode::NOT_MODIFIED).header(
28
        header::WARNING,
29
        r#"199 - "Nothing new has been posted since 2009.""#,
30
    )
31
}
32
33
pub fn forum_thread() -> Value {
34
    json!({
35
        "title": "The definitive jelqing FAQ (updated 2009)",
36
        "posts": [
37
            { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },
38
            { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },
39
            { "author": "newbie_2009", "body": "everyone keeps saying uli. who is uli" },
40
            { "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." },
41
            { "author": "TheElder", "body": "We do not say his name in vain. We say it in girth." },
42
            { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },
43
            { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },
44
            { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },
45
            { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },
46
            { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },
47
        ],
48
    })
49
}
50
51
fn verification(detail: &str) -> Problem {
52
    Problem::new(
53
        StatusCode::PROXY_AUTHENTICATION_REQUIRED,
54
        "verification-required",
55
        detail,
56
    )
57
    .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")
58
}
59
60
impl Run {
61
    pub(super) fn branch_offer(&self) -> Outcome {
62
        let forum = self.path("/branches/forum");
63
        let close = self.path("/branches/close-tab");
64
        Outcome::json(
65
            StatusCode::MULTIPLE_CHOICES,
66
            json!({
67
                "detail": "A browser tab is open. It has been open for some time.",
68
                "choices": [
69
                    { "href": forum, "method": "POST", "title": "Read the forum" },
70
                    { "href": close, "method": "POST", "title": "Close the tab" },
71
                ],
72
            }),
73
        )
74
        .header(
75
            header::LINK,
76
            format!(
77
                "<{forum}>; rel=\"alternate\"; title=\"Read the forum\"; allow=\"POST\", <{close}>; rel=\"alternate\"; title=\"Close the tab\"; allow=\"POST\""
78
            ),
79
        )
80
    }
81
82
    pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {
83
        if self.story != Story::Pending {
84
            return Err(Problem::new(
85
                StatusCode::CONFLICT,
86
                "no-decision-pending",
87
                "There is no decision pending.",
88
            ));
89
        }
90
        let (story, location, detail) = match choice {
91
            "forum" => {
92
                self.shame += 3;
93
                let detail =
94
                    "The thread is from 2009. A technique called uli is discussed at length.";
95
                (Story::ReadForum, self.path("/techniques"), detail)
96
            }
97
            "close-tab" => {
98
                self.suspicion = 0;
99
                (
100
                    Story::ClosedTab,
101
                    self.path(""),
102
                    "The tab is closed. Nobody saw it.",
103
                )
104
            }
105
            _ => {
106
                return Err(Problem::new(
107
                    StatusCode::NOT_FOUND,
108
                    "no-such-choice",
109
                    "That was not one of the choices.",
110
                ));
111
            }
112
        };
113
        self.story = story;
114
        self.touch();
115
        Ok(
116
            Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))
117
                .header(header::LOCATION, location),
118
        )
119
    }
120
121
    /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,
122
    /// after which the forum is down and the browser serves what it kept.
123
    pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
124
        if self.story == Story::Pending {
125
            return Ok(self.branch_offer());
126
        }
127
        if !self.thread_reachable() {
128
            return Err(Problem::new(
129
                StatusCode::NOT_FOUND,
130
                "no-thread",
131
                "There is no thread to re-read. Nobody has mentioned a forum yet.",
132
            ));
133
        }
134
        self.end_kitchen_visit();
135
        if self.uli_taught {
136
            return Ok(Outcome::json(StatusCode::OK, forum_thread()));
137
        }
138
        if self.here.thread_rolls >= ULI_READS {
139
            let age = httpdate::parse_http_date(ELDER_LAST_MODIFIED)
140
                .ok()
141
                .and_then(|posted| SystemTime::now().duration_since(posted).ok())
142
                .map_or(0, |age| age.as_secs());
143
            return Ok(Outcome::json(StatusCode::OK, forum_thread())
144
                .header(header::AGE, age.to_string())
145
                .header(header::WARNING, r#"112 - "Disconnected operation""#));
146
        }
147
        self.here.thread_rolls += 1;
148
        self.touch();
149
        if self.chance(
150
            self.decision(
151
                "teach-uli",
152
                "Does this reread finally surface the 2009 reply that teaches uli?",
153
            ),
154
            ULI_ODDS,
155
        ) {
156
            self.uli_taught = true;
157
            return Ok(Outcome::json(
158
                StatusCode::OK,
159
                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." }),
160
            )
161
            .header(header::LOCATION, self.path("/techniques")));
162
        }
163
        Ok(nothing_new())
164
    }
165
166
    fn elder_strike(&mut self, problem: Problem) -> Problem {
167
        self.touch();
168
        let Elder::Unverified { strikes } = &mut self.elder else {
169
            return problem;
170
        };
171
        *strikes += 1;
172
        if *strikes < PATIENCE {
173
            return problem;
174
        }
175
        self.end(Cause::Banned)
176
    }
177
178
    /// Failed verification counts toward a ban even on GET, the way repeated bad credentials lock an account.
179
    fn verify_member(&mut self, proxy_authorization: Option<&str>) -> Result<(), Problem> {
180
        if !matches!(self.elder, Elder::Unverified { .. }) {
181
            return Ok(());
182
        }
183
        let Some(credentials) = proxy_authorization else {
184
            return Err(verification("The forum requires verification."));
185
        };
186
        let Some(claimed) =
187
            parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())
188
        else {
189
            return Err(verification("The forum requires verification, in inches."));
190
        };
191
        let actual = self.current_length();
192
        if claimed >= actual + EXAGGERATION {
193
            self.shame += 2;
194
            return Err(self.elder_strike(exaggeration()));
195
        }
196
        if !honest(claimed, actual) {
197
            self.shame += 1;
198
            let failed = verification("Verification failed. The forum can tell.");
199
            if self.girth_respected() {
200
                return Err(failed.header(
201
                    X_FORUM_NOTE,
202
                    "The mods have let this one go. Girth is also a dimension.",
203
                ));
204
            }
205
            return Err(self.elder_strike(failed));
206
        }
207
        self.elder = Elder::Verified;
208
        self.touch();
209
        Ok(())
210
    }
211
212
    pub fn elder(
213
        &mut self,
214
        proxy_authorization: Option<&str>,
215
        if_modified_since: Option<&str>,
216
    ) -> Result<Outcome, Problem> {
217
        if self.floor != Floor::Forum {
218
            return Err(Problem::new(
219
                StatusCode::NOT_FOUND,
220
                "elder-absent",
221
                "The Elder only posts on the forum.",
222
            ));
223
        }
224
        self.verify_member(proxy_authorization)?;
225
226
        let last_modified =
227
            httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");
228
        let current = if_modified_since
229
            .and_then(|d| httpdate::parse_http_date(d).ok())
230
            .is_some_and(|since| since >= last_modified);
231
        if current {
232
            if self.elder != Elder::Beaten {
233
                self.elder = Elder::Beaten;
234
                self.touch();
235
            }
236
            return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)
237
                .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));
238
        }
239
        let spreadsheet = Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)
240
            .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED);
241
        if self.filmed_at.is_some() {
242
            return Ok(spreadsheet.header(X_FORUM_NOTE, "We have seen the footage."));
243
        }
244
        Ok(spreadsheet)
245
    }
246
}