atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
782069860236703e2dd3c52bb589b9e2d8d48176
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/http/run.rs

782069860236atagen2026-10-10 12:44 UTC1
//! Plumbing shared by every resource under `/runs/{id}`.
782069860236atagen2026-10-10 12:44 UTC2
782069860236atagen2026-10-10 12:44 UTC3
use axum::{
782069860236atagen2026-10-10 12:44 UTC4
    http::{HeaderName, HeaderValue, StatusCode, header},
782069860236atagen2026-10-10 12:44 UTC5
    response::{IntoResponse, Response},
782069860236atagen2026-10-10 12:44 UTC6
};
782069860236atagen2026-10-10 12:44 UTC7
use hyper_util::rt::TokioIo;
782069860236atagen2026-10-10 12:44 UTC8
use tokio::io::AsyncWriteExt;
782069860236atagen2026-10-10 12:44 UTC9
782069860236atagen2026-10-10 12:44 UTC10
use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource};
782069860236atagen2026-10-10 12:44 UTC11
use crate::{
782069860236atagen2026-10-10 12:44 UTC12
    game::{Floor, Run, balance::SUSPICION_LIMIT},
782069860236atagen2026-10-10 12:44 UTC13
    headers::{
782069860236atagen2026-10-10 12:44 UTC14
        X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_OBSERVED_BY, X_SELF_REPORTED_LENGTH, X_SHAME,
782069860236atagen2026-10-10 12:44 UTC15
        X_STAMINA, X_SUSPICION,
782069860236atagen2026-10-10 12:44 UTC16
    },
782069860236atagen2026-10-10 12:44 UTC17
    problem::{PROBLEM_BASE, Problem},
782069860236atagen2026-10-10 12:44 UTC18
    secret::Secret,
782069860236atagen2026-10-10 12:44 UTC19
};
782069860236atagen2026-10-10 12:44 UTC20
782069860236atagen2026-10-10 12:44 UTC21
pub const REL_BASE: &str = "https://jelq.dev/rels/";
782069860236atagen2026-10-10 12:44 UTC22
const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
782069860236atagen2026-10-10 12:44 UTC23
const LINKED: [&str; 8] = [
782069860236atagen2026-10-10 12:44 UTC24
    "floor",
782069860236atagen2026-10-10 12:44 UTC25
    "length",
782069860236atagen2026-10-10 12:44 UTC26
    "foreskin",
782069860236atagen2026-10-10 12:44 UTC27
    "inventory",
782069860236atagen2026-10-10 12:44 UTC28
    "techniques",
782069860236atagen2026-10-10 12:44 UTC29
    "drawer",
782069860236atagen2026-10-10 12:44 UTC30
    "grip",
782069860236atagen2026-10-10 12:44 UTC31
    "diagnosis",
782069860236atagen2026-10-10 12:44 UTC32
];
782069860236atagen2026-10-10 12:44 UTC33
782069860236atagen2026-10-10 12:44 UTC34
/// Which household hazards stand between a request and the run.
782069860236atagen2026-10-10 12:44 UTC35
#[derive(Clone, Copy, PartialEq, Eq)]
782069860236atagen2026-10-10 12:44 UTC36
pub enum Scope {
782069860236atagen2026-10-10 12:44 UTC37
    Home,
782069860236atagen2026-10-10 12:44 UTC38
    Pockets,
782069860236atagen2026-10-10 12:44 UTC39
}
782069860236atagen2026-10-10 12:44 UTC40
782069860236atagen2026-10-10 12:44 UTC41
pub struct RunResource {
782069860236atagen2026-10-10 12:44 UTC42
    pub scope: Scope,
782069860236atagen2026-10-10 12:44 UTC43
    pub resource: Resource<Run>,
782069860236atagen2026-10-10 12:44 UTC44
}
782069860236atagen2026-10-10 12:44 UTC45
782069860236atagen2026-10-10 12:44 UTC46
/// What the server writes over the connection after a `101`.
782069860236atagen2026-10-10 12:44 UTC47
#[derive(Clone)]
782069860236atagen2026-10-10 12:44 UTC48
pub struct Greeting(pub String);
782069860236atagen2026-10-10 12:44 UTC49
782069860236atagen2026-10-10 12:44 UTC50
pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
782069860236atagen2026-10-10 12:44 UTC51
    let mut runs = state.runs();
782069860236atagen2026-10-10 12:44 UTC52
    let Some(run) = runs.get_mut(ctx.param("id")) else {
782069860236atagen2026-10-10 12:44 UTC53
        return Problem::new(
782069860236atagen2026-10-10 12:44 UTC54
            StatusCode::NOT_FOUND,
782069860236atagen2026-10-10 12:44 UTC55
            "no-such-run",
782069860236atagen2026-10-10 12:44 UTC56
            "There is no such run. There may never have been.",
782069860236atagen2026-10-10 12:44 UTC57
        )
782069860236atagen2026-10-10 12:44 UTC58
        .into_response();
782069860236atagen2026-10-10 12:44 UTC59
    };
782069860236atagen2026-10-10 12:44 UTC60
    if let Some(ending) = &run.ended {
782069860236atagen2026-10-10 12:44 UTC61
        return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
782069860236atagen2026-10-10 12:44 UTC62
            .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug()))
782069860236atagen2026-10-10 12:44 UTC63
            .with("turn", ending.turn)
782069860236atagen2026-10-10 12:44 UTC64
            .with("graveyard", "/graveyard")
782069860236atagen2026-10-10 12:44 UTC65
            .into_response();
782069860236atagen2026-10-10 12:44 UTC66
    }
782069860236atagen2026-10-10 12:44 UTC67
    run.last_seen = ctx.now;
782069860236atagen2026-10-10 12:44 UTC68
    run.observed = None;
782069860236atagen2026-10-10 12:44 UTC69
    let response =
782069860236atagen2026-10-10 12:44 UTC70
        gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
782069860236atagen2026-10-10 12:44 UTC71
    let response = finish(run, &ctx, &state.config.secret, response);
782069860236atagen2026-10-10 12:44 UTC72
    drop(runs);
782069860236atagen2026-10-10 12:44 UTC73
    greet(response, &mut ctx)
782069860236atagen2026-10-10 12:44 UTC74
}
782069860236atagen2026-10-10 12:44 UTC75
782069860236atagen2026-10-10 12:44 UTC76
fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
782069860236atagen2026-10-10 12:44 UTC77
    if scope == Scope::Pockets {
782069860236atagen2026-10-10 12:44 UTC78
        return None;
782069860236atagen2026-10-10 12:44 UTC79
    }
782069860236atagen2026-10-10 12:44 UTC80
    if run.floor == Floor::Hotel && !run.here.wifi_accepted {
782069860236atagen2026-10-10 12:44 UTC81
        let portal = run.path("/portal");
782069860236atagen2026-10-10 12:44 UTC82
        return Some(
782069860236atagen2026-10-10 12:44 UTC83
            Problem::new(
782069860236atagen2026-10-10 12:44 UTC84
                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
782069860236atagen2026-10-10 12:44 UTC85
                "captive-portal",
782069860236atagen2026-10-10 12:44 UTC86
                "The network requires authentication. The hotel would like you to agree to some terms.",
782069860236atagen2026-10-10 12:44 UTC87
            )
782069860236atagen2026-10-10 12:44 UTC88
            .with("portal", portal.as_str())
782069860236atagen2026-10-10 12:44 UTC89
            .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\""))
782069860236atagen2026-10-10 12:44 UTC90
            .into_response(),
782069860236atagen2026-10-10 12:44 UTC91
        );
782069860236atagen2026-10-10 12:44 UTC92
    }
782069860236atagen2026-10-10 12:44 UTC93
    run.answer_door(ctx.header(header::AUTHORIZATION))
782069860236atagen2026-10-10 12:44 UTC94
        .err()
782069860236atagen2026-10-10 12:44 UTC95
        .map(IntoResponse::into_response)
782069860236atagen2026-10-10 12:44 UTC96
}
782069860236atagen2026-10-10 12:44 UTC97
782069860236atagen2026-10-10 12:44 UTC98
fn links(run: &Run) -> String {
782069860236atagen2026-10-10 12:44 UTC99
    let mut links = vec![format!("<{}>; rel=\"{REL_BASE}run\"", run.path(""))];
782069860236atagen2026-10-10 12:44 UTC100
    let shop = run.floor.shop_open().then_some("shop");
782069860236atagen2026-10-10 12:44 UTC101
    for rel in LINKED.into_iter().chain(shop) {
782069860236atagen2026-10-10 12:44 UTC102
        links.push(format!(
782069860236atagen2026-10-10 12:44 UTC103
            "<{}>; rel=\"{REL_BASE}{rel}\"",
782069860236atagen2026-10-10 12:44 UTC104
            run.path(&format!("/{rel}"))
782069860236atagen2026-10-10 12:44 UTC105
        ));
782069860236atagen2026-10-10 12:44 UTC106
    }
782069860236atagen2026-10-10 12:44 UTC107
    links.join(", ")
782069860236atagen2026-10-10 12:44 UTC108
}
782069860236atagen2026-10-10 12:44 UTC109
782069860236atagen2026-10-10 12:44 UTC110
/// Shame lives in a signed cookie so it follows the player across runs. Each response adds this
782069860236atagen2026-10-10 12:44 UTC111
/// run's new shame to whatever total the client presents, so two runs at once both count.
782069860236atagen2026-10-10 12:44 UTC112
fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 {
782069860236atagen2026-10-10 12:44 UTC113
    let presented = ctx
782069860236atagen2026-10-10 12:44 UTC114
        .cookie("shame")
782069860236atagen2026-10-10 12:44 UTC115
        .and_then(|signed| secret.verify("shame", signed))
782069860236atagen2026-10-10 12:44 UTC116
        .and_then(|total| total.parse::<u32>().ok());
782069860236atagen2026-10-10 12:44 UTC117
    let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported);
782069860236atagen2026-10-10 12:44 UTC118
    run.shame_reported = run.shame;
782069860236atagen2026-10-10 12:44 UTC119
    run.shame_total = total;
782069860236atagen2026-10-10 12:44 UTC120
    total
782069860236atagen2026-10-10 12:44 UTC121
}
782069860236atagen2026-10-10 12:44 UTC122
782069860236atagen2026-10-10 12:44 UTC123
pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
782069860236atagen2026-10-10 12:44 UTC124
    let status = response.status();
782069860236atagen2026-10-10 12:44 UTC125
    if status == StatusCode::NOT_MODIFIED {
782069860236atagen2026-10-10 12:44 UTC126
        return response;
782069860236atagen2026-10-10 12:44 UTC127
    }
782069860236atagen2026-10-10 12:44 UTC128
    let shame = settle_shame(run, ctx, secret);
782069860236atagen2026-10-10 12:44 UTC129
    let mut stats: Vec<(HeaderName, String)> = vec![
782069860236atagen2026-10-10 12:44 UTC130
        (
782069860236atagen2026-10-10 12:44 UTC131
            X_SELF_REPORTED_LENGTH,
782069860236atagen2026-10-10 12:44 UTC132
            format!("{:.2}", run.self_reported()),
782069860236atagen2026-10-10 12:44 UTC133
        ),
782069860236atagen2026-10-10 12:44 UTC134
        (X_GIRTH, format!("{:.2}", run.girth)),
782069860236atagen2026-10-10 12:44 UTC135
        (
782069860236atagen2026-10-10 12:44 UTC136
            X_FORESKIN,
782069860236atagen2026-10-10 12:44 UTC137
            run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
782069860236atagen2026-10-10 12:44 UTC138
        ),
782069860236atagen2026-10-10 12:44 UTC139
        (X_EQ, run.eq.to_string()),
782069860236atagen2026-10-10 12:44 UTC140
        (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),
782069860236atagen2026-10-10 12:44 UTC141
        (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),
782069860236atagen2026-10-10 12:44 UTC142
        (X_SHAME, shame.to_string()),
782069860236atagen2026-10-10 12:44 UTC143
        (X_CASH, run.cash.to_string()),
782069860236atagen2026-10-10 12:44 UTC144
        (X_FLOOR, run.floor.number().to_string()),
782069860236atagen2026-10-10 12:44 UTC145
        (header::LINK, links(run)),
782069860236atagen2026-10-10 12:44 UTC146
    ];
782069860236atagen2026-10-10 12:44 UTC147
    if let Some(observer) = run.observed {
782069860236atagen2026-10-10 12:44 UTC148
        stats.push((X_OBSERVED_BY, observer.name().into()));
782069860236atagen2026-10-10 12:44 UTC149
    }
782069860236atagen2026-10-10 12:44 UTC150
    if run.mom.is_some() {
782069860236atagen2026-10-10 12:44 UTC151
        stats.push((header::VIA, "1.1 mom".into()));
782069860236atagen2026-10-10 12:44 UTC152
        stats.push((header::VARY, "Mom".into()));
782069860236atagen2026-10-10 12:44 UTC153
    }
782069860236atagen2026-10-10 12:44 UTC154
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
782069860236atagen2026-10-10 12:44 UTC155
    if run.conditions.pulled_muscle {
782069860236atagen2026-10-10 12:44 UTC156
        stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));
782069860236atagen2026-10-10 12:44 UTC157
    }
782069860236atagen2026-10-10 12:44 UTC158
    if run.eq > 70 && run.ended.is_none() {
782069860236atagen2026-10-10 12:44 UTC159
        stats.push((header::WARNING, "199 - \"it is turning purple\"".into()));
782069860236atagen2026-10-10 12:44 UTC160
    }
782069860236atagen2026-10-10 12:44 UTC161
    stats.push((
782069860236atagen2026-10-10 12:44 UTC162
        header::SET_COOKIE,
782069860236atagen2026-10-10 12:44 UTC163
        format!(
782069860236atagen2026-10-10 12:44 UTC164
            "shame={}; {COOKIE_ATTRIBUTES}",
782069860236atagen2026-10-10 12:44 UTC165
            secret.sign("shame", &shame.to_string())
782069860236atagen2026-10-10 12:44 UTC166
        ),
782069860236atagen2026-10-10 12:44 UTC167
    ));
782069860236atagen2026-10-10 12:44 UTC168
    if run.items.ruler {
782069860236atagen2026-10-10 12:44 UTC169
        stats.push((
782069860236atagen2026-10-10 12:44 UTC170
            header::SET_COOKIE,
782069860236atagen2026-10-10 12:44 UTC171
            format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")),
782069860236atagen2026-10-10 12:44 UTC172
        ));
782069860236atagen2026-10-10 12:44 UTC173
    }
782069860236atagen2026-10-10 12:44 UTC174
    let headers = response.headers_mut();
782069860236atagen2026-10-10 12:44 UTC175
    for (name, value) in stats {
782069860236atagen2026-10-10 12:44 UTC176
        if let Ok(value) = HeaderValue::from_str(&value) {
782069860236atagen2026-10-10 12:44 UTC177
            headers.append(name, value);
782069860236atagen2026-10-10 12:44 UTC178
        }
782069860236atagen2026-10-10 12:44 UTC179
    }
782069860236atagen2026-10-10 12:44 UTC180
    let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
782069860236atagen2026-10-10 12:44 UTC181
    if run.items.ring && !bodiless {
782069860236atagen2026-10-10 12:44 UTC182
        response.extensions_mut().insert(Compress);
782069860236atagen2026-10-10 12:44 UTC183
    }
782069860236atagen2026-10-10 12:44 UTC184
    response
782069860236atagen2026-10-10 12:44 UTC185
}
782069860236atagen2026-10-10 12:44 UTC186
782069860236atagen2026-10-10 12:44 UTC187
fn greet(response: Response, ctx: &mut Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC188
    if response.status() != StatusCode::SWITCHING_PROTOCOLS {
782069860236atagen2026-10-10 12:44 UTC189
        return response;
782069860236atagen2026-10-10 12:44 UTC190
    }
782069860236atagen2026-10-10 12:44 UTC191
    let (Some(Greeting(text)), Some(upgrade)) = (
782069860236atagen2026-10-10 12:44 UTC192
        response.extensions().get::<Greeting>().cloned(),
782069860236atagen2026-10-10 12:44 UTC193
        ctx.upgrade.take(),
782069860236atagen2026-10-10 12:44 UTC194
    ) else {
782069860236atagen2026-10-10 12:44 UTC195
        return response;
782069860236atagen2026-10-10 12:44 UTC196
    };
782069860236atagen2026-10-10 12:44 UTC197
    tokio::spawn(async move {
782069860236atagen2026-10-10 12:44 UTC198
        if let Ok(upgraded) = upgrade.await {
782069860236atagen2026-10-10 12:44 UTC199
            let mut io = TokioIo::new(upgraded);
782069860236atagen2026-10-10 12:44 UTC200
            let _ = io.write_all(text.as_bytes()).await;
782069860236atagen2026-10-10 12:44 UTC201
            let _ = io.shutdown().await;
782069860236atagen2026-10-10 12:44 UTC202
        }
782069860236atagen2026-10-10 12:44 UTC203
    });
782069860236atagen2026-10-10 12:44 UTC204
    response
782069860236atagen2026-10-10 12:44 UTC205
}