atagen

jelq

public

an http based roguelike rpg about jelqing

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