atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
f3a1913c1299571fbfcb552007761bae715c4434
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/run.rsRust254 lines8.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::{
11
    AppState, ctx::Ctx, links, middleware::Compress, registry::Lock, resource::Resource, shame,
12
};
13
use crate::{
14
    game::{
15
        Cause, Gauges, Run, Shown,
16
        balance::{FRICTION_MAX, SUSPICION_LIMIT},
17
    },
18
    headers::{
19
        X_CASH, X_DANGER, X_EQ, X_FLOOR, X_FLOOR_TURN, X_FORESKIN, X_FRICTION, X_GAINS_ARRIVED,
20
        X_GIRTH, X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA,
21
        X_SUSPICION, X_TURN,
22
    },
23
    locale::Locale,
24
    problem::{PROBLEM_BASE, Problem},
25
    records::Record,
26
    secret::Secret,
27
};
28
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 records_unavailable(error: &rusqlite::Error) -> Response {
46
    Problem::new(
47
        StatusCode::SERVICE_UNAVAILABLE,
48
        "records-unavailable",
49
        "The records are unavailable. Nothing has been forgotten, it is only out of reach.",
50
    )
51
    .with("error", error.to_string())
52
    .into_response()
53
}
54
55
pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
56
    let id = ctx.param("id").to_owned();
57
    let Some(slot) = state.runs.get(&id) else {
58
        return match state.records.find(&id) {
59
            Ok(Some(Record {
60
                cause: Some(cause),
61
                turn,
62
                ..
63
            })) => gone(cause.slug(), turn),
64
            Ok(_) => Problem::new(
65
                StatusCode::NOT_FOUND,
66
                "no-such-run",
67
                "There is no such run. There may never have been.",
68
            )
69
            .into_response(),
70
            Err(error) => records_unavailable(&error),
71
        };
72
    };
73
    let (response, record) = match Lock::take(&slot) {
74
        Lock::Poisoned(mut run) => {
75
            run.lose();
76
            let response = gone(Cause::Lost.slug(), run.turn);
77
            (response, Some(Record::of(&run)))
78
        }
79
        Lock::Held(mut run) => {
80
            if let Some(ending) = &run.ended {
81
                return gone(ending.cause.slug(), ending.turn);
82
            }
83
            run.seen(ctx.now());
84
            let mut response = gate(&mut run, &ctx, target.scope)
85
                .unwrap_or_else(|| target.resource.dispatch(&mut run, &ctx));
86
            if let Some(ending) = &run.ended {
87
                response
88
                    .headers_mut()
89
                    .insert(X_RUN_OVER, HeaderValue::from_static(ending.cause.slug()));
90
            }
91
            let response = finish(&mut run, &ctx, &state.config.secret, response);
92
            let record = run.status().finished().then(|| Record::of(&run));
93
            (response, record)
94
        }
95
    };
96
    if let Some(record) = record {
97
        if let Err(error) = state.records.save(&record) {
98
            return records_unavailable(&error);
99
        }
100
        if record.cause.is_some() {
101
            state.runs.remove(&id);
102
        }
103
    }
104
    greet(response, &mut ctx)
105
}
106
107
fn gone(cause: &str, turn: u32) -> Response {
108
    Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
109
        .with("cause", format!("{PROBLEM_BASE}{cause}"))
110
        .with("turn", turn)
111
        .with("graveyard", "/graveyard")
112
        .header(X_RUN_OVER, cause)
113
        .into_response()
114
}
115
116
fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
117
    if scope == Scope::Pockets {
118
        return None;
119
    }
120
    if run.needs_portal() {
121
        let portal = run.path("/portal");
122
        return Some(
123
            Problem::new(
124
                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
125
                "captive-portal",
126
                "The network requires authentication. The hotel would like you to agree to some terms.",
127
            )
128
            .with("portal", portal.as_str())
129
            .header(
130
                header::LINK,
131
                format!("<{portal}>; rel=\"{}portal\"", links::REL_BASE),
132
            )
133
            .into_response(),
134
        );
135
    }
136
    let authorization = ctx
137
        .header(header::AUTHORIZATION)
138
        .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text));
139
    run.answer_door(authorization.as_deref())
140
        .err()
141
        .map(IntoResponse::into_response)
142
}
143
144
fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
145
    let mut stats = vec![
146
        match gauges.length {
147
            Shown::Measured(length) => (X_LENGTH, format!("{length:.2}")),
148
            Shown::SelfReported(length) => (X_SELF_REPORTED_LENGTH, format!("{length:.2}")),
149
        },
150
        (X_GIRTH, format!("{:.2}", gauges.girth)),
151
        (
152
            X_FORESKIN,
153
            gauges.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
154
        ),
155
        (X_FRICTION, format!("{}/{FRICTION_MAX}", gauges.friction)),
156
        (X_EQ, gauges.arousal.name().into()),
157
        (
158
            X_STAMINA,
159
            format!("{}/{}", gauges.stamina, gauges.max_stamina),
160
        ),
161
        (
162
            X_SUSPICION,
163
            format!("{}/{SUSPICION_LIMIT}", gauges.suspicion),
164
        ),
165
        (X_SHAME, shame.to_string()),
166
        (X_CASH, gauges.cash.to_string()),
167
        (X_FLOOR, gauges.floor.to_string()),
168
        (X_TURN, gauges.turn.to_string()),
169
        (
170
            X_FLOOR_TURN,
171
            gauges
172
                .floor_limit
173
                .map_or(gauges.floor_turns.to_string(), |limit| {
174
                    format!("{}/{limit}", gauges.floor_turns)
175
                }),
176
        ),
177
    ];
178
    if !gauges.dangers.is_empty() {
179
        let dangers: Vec<String> = gauges
180
            .dangers
181
            .iter()
182
            .map(|(gauge, danger)| format!("{gauge}={}", danger.name()))
183
            .collect();
184
        stats.push((X_DANGER, dangers.join(", ")));
185
    }
186
    if let Some(observer) = gauges.observed {
187
        stats.push((X_OBSERVED_BY, observer.name().into()));
188
    }
189
    if let Some(challenge) = &gauges.mum_challenge {
190
        stats.push((header::VIA, "1.1 mum".into()));
191
        stats.push((header::VARY, "Mum".into()));
192
        stats.push((header::WWW_AUTHENTICATE, challenge.clone()));
193
    }
194
    if let Some(arrived) = &gauges.arrived {
195
        let mut gains = format!("length={:.3}, girth={:.3}", arrived.length, arrived.girth);
196
        if let Some(foreskin) = arrived.foreskin {
197
            gains.push_str(&format!(", foreskin={foreskin:.3}"));
198
        }
199
        stats.push((X_GAINS_ARRIVED, gains));
200
    }
201
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
202
    for warning in &gauges.warnings {
203
        stats.push((header::WARNING, format!("199 - \"{warning}\"")));
204
    }
205
    stats
206
}
207
208
pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
209
    let status = response.status();
210
    if status == StatusCode::NOT_MODIFIED {
211
        return response;
212
    }
213
    let (shame, shame_cookie) = shame::settle(run, ctx, secret);
214
    let gauges = run.gauges();
215
    let link = links::header(run);
216
    let headers = response.headers_mut();
217
    for (name, value) in stats(&gauges, shame) {
218
        if let Ok(value) = HeaderValue::from_str(&value) {
219
            headers.append(name, value);
220
        }
221
    }
222
    if let Ok(link) = HeaderValue::from_str(&link) {
223
        headers.append(header::LINK, link);
224
    }
225
    headers.append(header::SET_COOKIE, shame_cookie);
226
    if gauges.ruler {
227
        headers.append(header::SET_COOKIE, shame::ruler_cookie(secret));
228
    }
229
    let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
230
    if gauges.ring && !bodiless {
231
        response.extensions_mut().insert(Compress);
232
    }
233
    response
234
}
235
236
fn greet(response: Response, ctx: &mut Ctx) -> Response {
237
    if response.status() != StatusCode::SWITCHING_PROTOCOLS {
238
        return response;
239
    }
240
    let (Some(Greeting(text)), Some(upgrade)) = (
241
        response.extensions().get::<Greeting>().cloned(),
242
        ctx.take_upgrade(),
243
    ) else {
244
        return response;
245
    };
246
    tokio::spawn(async move {
247
        if let Ok(upgraded) = upgrade.await {
248
            let mut io = TokioIo::new(upgraded);
249
            let _ = io.write_all(text.as_bytes()).await;
250
            let _ = io.shutdown().await;
251
        }
252
    });
253
    response
254
}