atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
9374b3933189ddeefe1c4e1c10256e0d8285b2c7
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/run.rsRust308 lines10.7 KB
1
//! Plumbing shared by every resource under `/runs/{id}`.
2
3
use axum::{
4
    http::{HeaderName, HeaderValue, StatusCode, header},
5
    response::{IntoResponse, Response},
6
};
7
use hyper_util::rt::TokioIo;
8
use tokio::io::AsyncWriteExt;
9
10
use super::{
11
    AppState, ctx::Ctx, meta, middleware::Compress, registry::hold, resource::Resource, runs,
12
};
13
use crate::{
14
    game::{
15
        Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
16
        balance::{FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},
17
    },
18
    headers::{
19
        X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
20
        X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,
21
    },
22
    locale::Locale,
23
    problem::{PROBLEM_BASE, Problem},
24
    records::Record,
25
    secret::Secret,
26
};
27
28
pub const REL_BASE: &str = "https://jelq.dev/rels/";
29
const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
30
/// Which household hazards stand between a request and the run.
31
#[derive(Clone, Copy, PartialEq, Eq)]
32
pub enum Scope {
33
    Home,
34
    Pockets,
35
}
36
37
pub struct RunResource {
38
    pub scope: Scope,
39
    pub resource: Resource<Run>,
40
}
41
42
/// What the server writes over the connection after a `101`.
43
#[derive(Clone)]
44
pub struct Greeting(pub String);
45
46
pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
47
    let id = ctx.param("id").to_owned();
48
    let Some(slot) = state.runs.get(&id) else {
49
        return match state.records.find(&id) {
50
            Some(Record {
51
                ending: Some(ending),
52
                turn,
53
                ..
54
            }) => gone(&ending.cause, turn),
55
            _ => Problem::new(
56
                StatusCode::NOT_FOUND,
57
                "no-such-run",
58
                "There is no such run. There may never have been.",
59
            )
60
            .into_response(),
61
        };
62
    };
63
    let mut guard = hold(&slot);
64
    let run = &mut *guard;
65
    if let Some(ending) = &run.ended {
66
        return gone(ending.cause.slug(), ending.turn);
67
    }
68
    run.last_seen = ctx.now;
69
    run.observed = None;
70
    let response =
71
        gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
72
    let response = finish(run, &ctx, &state.config.secret, response);
73
    if run.ended.is_some() || run.clinic.cured {
74
        state.records.save(&Record::of(run));
75
    }
76
    let ended = run.ended.is_some();
77
    drop(guard);
78
    if ended {
79
        state.runs.remove(&id);
80
    }
81
    greet(response, &mut ctx)
82
}
83
84
fn gone(cause: &str, turn: u32) -> Response {
85
    Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
86
        .with("cause", format!("{PROBLEM_BASE}{cause}"))
87
        .with("turn", turn)
88
        .with("graveyard", "/graveyard")
89
        .into_response()
90
}
91
92
fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
93
    if scope == Scope::Pockets {
94
        return None;
95
    }
96
    if run.floor == Floor::Hotel && !run.here.wifi_accepted {
97
        let portal = run.path("/portal");
98
        return Some(
99
            Problem::new(
100
                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
101
                "captive-portal",
102
                "The network requires authentication. The hotel would like you to agree to some terms.",
103
            )
104
            .with("portal", portal.as_str())
105
            .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\""))
106
            .into_response(),
107
        );
108
    }
109
    let authorization = ctx
110
        .header(header::AUTHORIZATION)
111
        .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text));
112
    run.answer_door(authorization.as_deref())
113
        .err()
114
        .map(IntoResponse::into_response)
115
}
116
117
/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
118
/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
119
fn links(run: &Run) -> String {
120
    let thread_reachable = run.thread_reachable();
121
    let linked: [(&str, &RunResource, bool); 15] = [
122
        ("", &runs::RUN, true),
123
        ("floor", &runs::FLOOR, true),
124
        ("length", &runs::LENGTH, true),
125
        ("foreskin", &runs::FORESKIN, run.foreskin.is_some()),
126
        ("inventory", &runs::INVENTORY, true),
127
        ("techniques", &runs::TECHNIQUES, true),
128
        ("drawer", &runs::DRAWER, run.drawer_worth_searching()),
129
        ("kitchen", &runs::KITCHEN, {
130
            run.floor.has_kitchen() && run.kitchen_tellings < KITCHEN_TELLINGS
131
        }),
132
        ("re-read", &runs::REREAD, thread_reachable),
133
        ("grip", &runs::GRIP, true),
134
        ("shop", &runs::SHOP, run.floor.shop_open()),
135
        (
136
            "portal",
137
            &runs::PORTAL,
138
            run.floor == Floor::Hotel && !run.here.wifi_accepted,
139
        ),
140
        (
141
            "elder",
142
            &runs::ELDER,
143
            run.floor == Floor::Forum && run.elder != Elder::Beaten,
144
        ),
145
        ("footage", &runs::FOOTAGE, run.filmed_at.is_some()),
146
        ("diagnosis", &runs::DIAGNOSIS, run.floor == Floor::Clinic),
147
    ];
148
    linked
149
        .into_iter()
150
        .filter(|(_, _, relevant)| *relevant)
151
        .map(|(rel, target, _)| {
152
            let (path, name) = if rel.is_empty() {
153
                (run.path(""), "run")
154
            } else {
155
                (run.path(&format!("/{rel}")), rel)
156
            };
157
            let allow = target.resource.allow(run);
158
            format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
159
        })
160
        .chain(thread_reachable.then(|| {
161
            format!(
162
                "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
163
                meta::THREAD.allow_any()
164
            )
165
        }),)
166
        .chain((run.story == Story::Pending).then(|| {
167
            let choice = |slug: &str, title: &str| {
168
                format!(
169
                    "<{}>; rel=\"{REL_BASE}branch\"; title=\"{title}\"; allow=\"POST\"",
170
                    run.path(&format!("/branches/{slug}"))
171
                )
172
            };
173
            [choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ")
174
        }))
175
        .chain(run.techniques_now().into_iter().map(|technique| {
176
            let name = technique.name();
177
            let lubricant = technique
178
                .lubricant()
179
                .map_or(String::new(), |rule| format!("; lubricant=\"{rule}\""));
180
            format!(
181
                "<{}?technique={name}>; rel=\"{REL_BASE}technique\"; title=\"{name}\"; allow=\"JELQ\"{lubricant}",
182
                run.path("")
183
            )
184
        }))
185
        .chain(run.here.shopping.then(|| {
186
            SHOP.iter()
187
                .filter_map(|item| {
188
                    let price = run.price(*item)?;
189
                    Some(format!(
190
                        "<{}>; rel=\"{REL_BASE}shop-item\"; title=\"{} for {price}\"; allow=\"POST\"",
191
                        run.path(&format!("/shop/{}", item.slug())),
192
                        item.slug()
193
                    ))
194
                })
195
                .collect::<Vec<_>>()
196
                .join(", ")
197
        }))
198
        .collect::<Vec<_>>()
199
        .join(", ")
200
}
201
202
/// Shame lives in a signed cookie so it follows the player across runs. Each response adds this
203
/// run's new shame to whatever total the client presents, so two runs at once both count.
204
fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 {
205
    let presented = ctx
206
        .cookie("shame")
207
        .and_then(|signed| secret.verify("shame", signed))
208
        .and_then(|total| total.parse::<u32>().ok());
209
    let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported);
210
    run.shame_reported = run.shame;
211
    run.shame_total = total;
212
    total
213
}
214
215
pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
216
    let status = response.status();
217
    if status == StatusCode::NOT_MODIFIED {
218
        return response;
219
    }
220
    let shame = settle_shame(run, ctx, secret);
221
    let mut stats: Vec<(HeaderName, String)> = vec![
222
        if run.items.ruler {
223
            (X_LENGTH, format!("{:.2}", run.current_length()))
224
        } else {
225
            (
226
                X_SELF_REPORTED_LENGTH,
227
                format!("{:.2}", run.self_reported()),
228
            )
229
        },
230
        (X_GIRTH, format!("{:.2}", run.girth)),
231
        (
232
            X_FORESKIN,
233
            run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
234
        ),
235
        (X_FRICTION, format!("{}/{}", run.friction, FRICTION_MAX)),
236
        (X_EQ, run.eq.to_string()),
237
        (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),
238
        (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),
239
        (X_SHAME, shame.to_string()),
240
        (X_CASH, run.cash.to_string()),
241
        (X_FLOOR, run.floor.number().to_string()),
242
        (header::LINK, links(run)),
243
    ];
244
    if let Some(observer) = run.observed {
245
        stats.push((X_OBSERVED_BY, observer.name().into()));
246
    }
247
    if run.mum.is_some() {
248
        stats.push((header::VIA, "1.1 mum".into()));
249
        stats.push((header::VARY, "Mum".into()));
250
    }
251
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
252
    if run.conditions.pulled_muscle {
253
        stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));
254
    }
255
    if run.conditions.friction_burn {
256
        stats.push((header::WARNING, "199 - \"friction burn\"".into()));
257
    }
258
    for notice in run.notices.drain(..) {
259
        stats.push((header::WARNING, format!("199 - \"{notice}\"")));
260
    }
261
    if run.eq > 70 && run.ended.is_none() {
262
        stats.push((header::WARNING, "199 - \"it is turning purple\"".into()));
263
    }
264
    stats.push((
265
        header::SET_COOKIE,
266
        format!(
267
            "shame={}; {COOKIE_ATTRIBUTES}",
268
            secret.sign("shame", &shame.to_string())
269
        ),
270
    ));
271
    if run.items.ruler {
272
        stats.push((
273
            header::SET_COOKIE,
274
            format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")),
275
        ));
276
    }
277
    let headers = response.headers_mut();
278
    for (name, value) in stats {
279
        if let Ok(value) = HeaderValue::from_str(&value) {
280
            headers.append(name, value);
281
        }
282
    }
283
    let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
284
    if run.items.ring && !bodiless {
285
        response.extensions_mut().insert(Compress);
286
    }
287
    response
288
}
289
290
fn greet(response: Response, ctx: &mut Ctx) -> Response {
291
    if response.status() != StatusCode::SWITCHING_PROTOCOLS {
292
        return response;
293
    }
294
    let (Some(Greeting(text)), Some(upgrade)) = (
295
        response.extensions().get::<Greeting>().cloned(),
296
        ctx.upgrade.take(),
297
    ) else {
298
        return response;
299
    };
300
    tokio::spawn(async move {
301
        if let Ok(upgraded) = upgrade.await {
302
            let mut io = TokioIo::new(upgraded);
303
            let _ = io.write_all(text.as_bytes()).await;
304
            let _ = io.shutdown().await;
305
        }
306
    });
307
    response
308
}