atagen

jelq

public

an http based roguelike rpg about jelqing

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