atagen

jelq

public

an http based roguelike rpg about jelqing

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