atagen

jelq

public

an http based roguelike rpg about jelqing

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