atagen

jelq

public

an http based roguelike rpg about jelqing

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