atagen

jelq

public

an http based roguelike rpg about jelqing

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

Blame: src/http/meta.rs

782069860236atagen2026-10-10 12:44 UTC1
//! Resources that do not belong to a single run.
782069860236atagen2026-10-10 12:44 UTC2
e807266c323batagen2026-10-12 09:46 UTC3
use std::time::Instant;
e807266c323batagen2026-10-12 09:46 UTC4
782069860236atagen2026-10-10 12:44 UTC5
use axum::{
782069860236atagen2026-10-10 12:44 UTC6
    Json,
782069860236atagen2026-10-10 12:44 UTC7
    http::{StatusCode, header},
782069860236atagen2026-10-10 12:44 UTC8
    response::{IntoResponse, Response},
782069860236atagen2026-10-10 12:44 UTC9
};
782069860236atagen2026-10-10 12:44 UTC10
use rand::Rng;
782069860236atagen2026-10-10 12:44 UTC11
use serde_json::{Value, json};
782069860236atagen2026-10-10 12:44 UTC12
782069860236atagen2026-10-10 12:44 UTC13
use super::{
782069860236atagen2026-10-10 12:44 UTC14
    Shared,
782069860236atagen2026-10-10 12:44 UTC15
    ctx::Ctx,
782069860236atagen2026-10-10 12:44 UTC16
    resource::{Resource, Verb},
782069860236atagen2026-10-10 12:44 UTC17
    run,
782069860236atagen2026-10-10 12:44 UTC18
};
782069860236atagen2026-10-10 12:44 UTC19
use crate::{
782069860236atagen2026-10-10 12:44 UTC20
    game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread},
782069860236atagen2026-10-10 12:44 UTC21
    headers::X_SEED,
782069860236atagen2026-10-10 12:44 UTC22
    problem::{PROBLEM_BASE, Problem},
782069860236atagen2026-10-10 12:44 UTC23
};
782069860236atagen2026-10-10 12:44 UTC24
782069860236atagen2026-10-10 12:44 UTC25
pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);
782069860236atagen2026-10-10 12:44 UTC26
pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);
782069860236atagen2026-10-10 12:44 UTC27
pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);
782069860236atagen2026-10-10 12:44 UTC28
pub static GRAVEYARD: Resource<Shared> = Resource::new(&[Verb::new("GET", graveyard)]);
782069860236atagen2026-10-10 12:44 UTC29
pub static LEADERBOARD: Resource<Shared> = Resource::new(&[Verb::new("GET", leaderboard)]);
782069860236atagen2026-10-10 12:44 UTC30
782069860236atagen2026-10-10 12:44 UTC31
fn index(_: &mut Shared, ctx: &Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC32
    let wants_html = ctx
782069860236atagen2026-10-10 12:44 UTC33
        .header(header::ACCEPT)
782069860236atagen2026-10-10 12:44 UTC34
        .is_some_and(|accept| accept.contains("text/html"));
782069860236atagen2026-10-10 12:44 UTC35
    if wants_html {
782069860236atagen2026-10-10 12:44 UTC36
        return (
782069860236atagen2026-10-10 12:44 UTC37
            StatusCode::SEE_OTHER,
782069860236atagen2026-10-10 12:44 UTC38
            [(header::LOCATION, "/ui/"), (header::VARY, "Accept")],
782069860236atagen2026-10-10 12:44 UTC39
        )
782069860236atagen2026-10-10 12:44 UTC40
            .into_response();
782069860236atagen2026-10-10 12:44 UTC41
    }
782069860236atagen2026-10-10 12:44 UTC42
    (
782069860236atagen2026-10-10 12:44 UTC43
        [(header::VARY, "Accept")],
782069860236atagen2026-10-10 12:44 UTC44
        Json(json!({
782069860236atagen2026-10-10 12:44 UTC45
            "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",
782069860236atagen2026-10-10 12:44 UTC46
            "start": { "href": "/runs", "method": "POST" },
782069860236atagen2026-10-10 12:44 UTC47
            "leaderboard": "/leaderboard",
782069860236atagen2026-10-10 12:44 UTC48
            "graveyard": "/graveyard",
782069860236atagen2026-10-10 12:44 UTC49
            "forum": FORUM_THREAD,
782069860236atagen2026-10-10 12:44 UTC50
            "ui": "/ui/",
782069860236atagen2026-10-10 12:44 UTC51
        })),
782069860236atagen2026-10-10 12:44 UTC52
    )
782069860236atagen2026-10-10 12:44 UTC53
        .into_response()
782069860236atagen2026-10-10 12:44 UTC54
}
782069860236atagen2026-10-10 12:44 UTC55
782069860236atagen2026-10-10 12:44 UTC56
fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC57
    let seeded = ctx.query("seed").is_some();
782069860236atagen2026-10-10 12:44 UTC58
    let seed = match ctx.query("seed").map(str::parse::<u64>) {
782069860236atagen2026-10-10 12:44 UTC59
        None => rand::rng().random(),
782069860236atagen2026-10-10 12:44 UTC60
        Some(Ok(seed)) => seed,
782069860236atagen2026-10-10 12:44 UTC61
        Some(Err(_)) => {
782069860236atagen2026-10-10 12:44 UTC62
            return Problem::new(
782069860236atagen2026-10-10 12:44 UTC63
                StatusCode::BAD_REQUEST,
782069860236atagen2026-10-10 12:44 UTC64
                "bad-seed",
782069860236atagen2026-10-10 12:44 UTC65
                "The seed must be an unsigned 64-bit integer.",
782069860236atagen2026-10-10 12:44 UTC66
            )
782069860236atagen2026-10-10 12:44 UTC67
            .into_response();
782069860236atagen2026-10-10 12:44 UTC68
        }
782069860236atagen2026-10-10 12:44 UTC69
    };
782069860236atagen2026-10-10 12:44 UTC70
    let config = &state.config;
782069860236atagen2026-10-10 12:44 UTC71
    let mut runs = state.runs();
782069860236atagen2026-10-10 12:44 UTC72
    runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);
782069860236atagen2026-10-10 12:44 UTC73
    if runs.len() >= config.max_runs {
e807266c323batagen2026-10-12 09:46 UTC74
        let mut buried: Vec<(Instant, String)> = runs
e807266c323batagen2026-10-12 09:46 UTC75
            .iter()
e807266c323batagen2026-10-12 09:46 UTC76
            .filter(|(_, run)| run.ended.is_some())
e807266c323batagen2026-10-12 09:46 UTC77
            .map(|(id, run)| (run.last_seen, id.clone()))
e807266c323batagen2026-10-12 09:46 UTC78
            .collect();
e807266c323batagen2026-10-12 09:46 UTC79
        buried.sort_unstable();
e807266c323batagen2026-10-12 09:46 UTC80
        let excess = runs.len() + 1 - config.max_runs;
e807266c323batagen2026-10-12 09:46 UTC81
        for (_, id) in buried.into_iter().take(excess) {
e807266c323batagen2026-10-12 09:46 UTC82
            runs.remove(&id);
e807266c323batagen2026-10-12 09:46 UTC83
        }
e807266c323batagen2026-10-12 09:46 UTC84
    }
e807266c323batagen2026-10-12 09:46 UTC85
    if runs.len() >= config.max_runs {
782069860236atagen2026-10-10 12:44 UTC86
        return Problem::new(
782069860236atagen2026-10-10 12:44 UTC87
            StatusCode::SERVICE_UNAVAILABLE,
782069860236atagen2026-10-10 12:44 UTC88
            "server-full",
782069860236atagen2026-10-10 12:44 UTC89
            "The server is full. Everyone is in there.",
782069860236atagen2026-10-10 12:44 UTC90
        )
782069860236atagen2026-10-10 12:44 UTC91
        .header(header::RETRY_AFTER, "600")
782069860236atagen2026-10-10 12:44 UTC92
        .into_response();
782069860236atagen2026-10-10 12:44 UTC93
    }
782069860236atagen2026-10-10 12:44 UTC94
    let id = loop {
782069860236atagen2026-10-10 12:44 UTC95
        let id = format!("{:032x}", rand::rng().random::<u128>());
782069860236atagen2026-10-10 12:44 UTC96
        if !runs.contains_key(&id) {
782069860236atagen2026-10-10 12:44 UTC97
            break id;
782069860236atagen2026-10-10 12:44 UTC98
        }
782069860236atagen2026-10-10 12:44 UTC99
    };
782069860236atagen2026-10-10 12:44 UTC100
    let public_id = format!("{:08x}", rand::rng().random::<u32>());
3f9091557ec3atagen2026-10-11 11:43 UTC101
    let mut run = Run::with_oracle(
3f9091557ec3atagen2026-10-11 11:43 UTC102
        id.clone(),
3f9091557ec3atagen2026-10-11 11:43 UTC103
        public_id,
3f9091557ec3atagen2026-10-11 11:43 UTC104
        config.secret.mix_seed(seed),
3f9091557ec3atagen2026-10-11 11:43 UTC105
        &config.oracle,
3f9091557ec3atagen2026-10-11 11:43 UTC106
    );
782069860236atagen2026-10-10 12:44 UTC107
    run.seeded = seeded;
782069860236atagen2026-10-10 12:44 UTC108
    run.last_seen = ctx.now;
782069860236atagen2026-10-10 12:44 UTC109
    let response = (
782069860236atagen2026-10-10 12:44 UTC110
        StatusCode::CREATED,
782069860236atagen2026-10-10 12:44 UTC111
        [
782069860236atagen2026-10-10 12:44 UTC112
            (header::LOCATION, run.path("")),
782069860236atagen2026-10-10 12:44 UTC113
            (header::ETAG, run.etag()),
782069860236atagen2026-10-10 12:44 UTC114
            (X_SEED, seed.to_string()),
782069860236atagen2026-10-10 12:44 UTC115
        ],
782069860236atagen2026-10-10 12:44 UTC116
        Json(run.view()),
782069860236atagen2026-10-10 12:44 UTC117
    )
782069860236atagen2026-10-10 12:44 UTC118
        .into_response();
782069860236atagen2026-10-10 12:44 UTC119
    let response = run::finish(&mut run, ctx, &config.secret, response);
782069860236atagen2026-10-10 12:44 UTC120
    runs.insert(id, run);
782069860236atagen2026-10-10 12:44 UTC121
    response
782069860236atagen2026-10-10 12:44 UTC122
}
782069860236atagen2026-10-10 12:44 UTC123
782069860236atagen2026-10-10 12:44 UTC124
fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC125
    match ctx.param("year").parse::<u32>() {
782069860236atagen2026-10-10 12:44 UTC126
        Ok(2009) => (
782069860236atagen2026-10-10 12:44 UTC127
            [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],
782069860236atagen2026-10-10 12:44 UTC128
            Json(forum_thread()),
782069860236atagen2026-10-10 12:44 UTC129
        )
782069860236atagen2026-10-10 12:44 UTC130
            .into_response(),
782069860236atagen2026-10-10 12:44 UTC131
        Ok(year) if year > 2009 => Problem::new(
782069860236atagen2026-10-10 12:44 UTC132
            StatusCode::NOT_FOUND,
782069860236atagen2026-10-10 12:44 UTC133
            "no-thread",
782069860236atagen2026-10-10 12:44 UTC134
            "Nobody has posted since 2009.",
782069860236atagen2026-10-10 12:44 UTC135
        )
782069860236atagen2026-10-10 12:44 UTC136
        .with("thread", FORUM_THREAD)
782069860236atagen2026-10-10 12:44 UTC137
        .into_response(),
782069860236atagen2026-10-10 12:44 UTC138
        _ => Problem::new(
782069860236atagen2026-10-10 12:44 UTC139
            StatusCode::NOT_FOUND,
782069860236atagen2026-10-10 12:44 UTC140
            "no-thread",
782069860236atagen2026-10-10 12:44 UTC141
            "The forum did not exist yet. The problem did.",
782069860236atagen2026-10-10 12:44 UTC142
        )
782069860236atagen2026-10-10 12:44 UTC143
        .with("thread", FORUM_THREAD)
782069860236atagen2026-10-10 12:44 UTC144
        .into_response(),
782069860236atagen2026-10-10 12:44 UTC145
    }
782069860236atagen2026-10-10 12:44 UTC146
}
782069860236atagen2026-10-10 12:44 UTC147
782069860236atagen2026-10-10 12:44 UTC148
fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC149
    let runs = state.runs();
782069860236atagen2026-10-10 12:44 UTC150
    let dead: Vec<Value> = runs
782069860236atagen2026-10-10 12:44 UTC151
        .values()
782069860236atagen2026-10-10 12:44 UTC152
        .filter_map(|run| {
782069860236atagen2026-10-10 12:44 UTC153
            let ending = run.ended.as_ref()?;
782069860236atagen2026-10-10 12:44 UTC154
            Some(json!({
782069860236atagen2026-10-10 12:44 UTC155
                "public_id": run.public_id,
782069860236atagen2026-10-10 12:44 UTC156
                "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),
782069860236atagen2026-10-10 12:44 UTC157
                "detail": ending.cause.detail(),
782069860236atagen2026-10-10 12:44 UTC158
                "turn": ending.turn,
782069860236atagen2026-10-10 12:44 UTC159
            }))
782069860236atagen2026-10-10 12:44 UTC160
        })
782069860236atagen2026-10-10 12:44 UTC161
        .collect();
782069860236atagen2026-10-10 12:44 UTC162
    Json(json!({ "runs": dead })).into_response()
782069860236atagen2026-10-10 12:44 UTC163
}
782069860236atagen2026-10-10 12:44 UTC164
058650ec79d6atagen2026-10-12 10:49 UTC165
#[derive(Clone, Copy, PartialEq, Eq)]
058650ec79d6atagen2026-10-12 10:49 UTC166
enum Sort {
058650ec79d6atagen2026-10-12 10:49 UTC167
    SelfReported,
058650ec79d6atagen2026-10-12 10:49 UTC168
    ContentLength,
058650ec79d6atagen2026-10-12 10:49 UTC169
    /// Self-reported length against real girth, the only metric the forum respects.
058650ec79d6atagen2026-10-12 10:49 UTC170
    Volume,
058650ec79d6atagen2026-10-12 10:49 UTC171
}
058650ec79d6atagen2026-10-12 10:49 UTC172
782069860236atagen2026-10-10 12:44 UTC173
fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
058650ec79d6atagen2026-10-12 10:49 UTC174
    let sort = match ctx.query("sort") {
058650ec79d6atagen2026-10-12 10:49 UTC175
        None | Some("self-reported-length") => Sort::SelfReported,
058650ec79d6atagen2026-10-12 10:49 UTC176
        Some("volume") => Sort::Volume,
782069860236atagen2026-10-10 12:44 UTC177
        Some("content-length") => match ctx.cookie("ruler") {
058650ec79d6atagen2026-10-12 10:49 UTC178
            Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => {
058650ec79d6atagen2026-10-12 10:49 UTC179
                Sort::ContentLength
058650ec79d6atagen2026-10-12 10:49 UTC180
            }
782069860236atagen2026-10-10 12:44 UTC181
            Some(_) => {
782069860236atagen2026-10-10 12:44 UTC182
                return Problem::new(
782069860236atagen2026-10-10 12:44 UTC183
                    StatusCode::BAD_REQUEST,
782069860236atagen2026-10-10 12:44 UTC184
                    "exaggeration",
782069860236atagen2026-10-10 12:44 UTC185
                    "The server has measured. Lying is not a technique.",
782069860236atagen2026-10-10 12:44 UTC186
                )
782069860236atagen2026-10-10 12:44 UTC187
                .with("cookie", "ruler")
782069860236atagen2026-10-10 12:44 UTC188
                .into_response();
782069860236atagen2026-10-10 12:44 UTC189
            }
782069860236atagen2026-10-10 12:44 UTC190
            None => {
782069860236atagen2026-10-10 12:44 UTC191
                return Problem::new(
782069860236atagen2026-10-10 12:44 UTC192
                    StatusCode::FORBIDDEN,
782069860236atagen2026-10-10 12:44 UTC193
                    "ruler-required",
782069860236atagen2026-10-10 12:44 UTC194
                    "Sorting by the truth requires a Ruler.",
782069860236atagen2026-10-10 12:44 UTC195
                )
782069860236atagen2026-10-10 12:44 UTC196
                .into_response();
782069860236atagen2026-10-10 12:44 UTC197
            }
782069860236atagen2026-10-10 12:44 UTC198
        },
782069860236atagen2026-10-10 12:44 UTC199
        Some(_) => {
782069860236atagen2026-10-10 12:44 UTC200
            return Problem::new(
782069860236atagen2026-10-10 12:44 UTC201
                StatusCode::BAD_REQUEST,
782069860236atagen2026-10-10 12:44 UTC202
                "unknown-sort",
058650ec79d6atagen2026-10-12 10:49 UTC203
                "The leaderboard sorts by self-reported-length, volume or content-length.",
782069860236atagen2026-10-10 12:44 UTC204
            )
782069860236atagen2026-10-10 12:44 UTC205
            .into_response();
782069860236atagen2026-10-10 12:44 UTC206
        }
782069860236atagen2026-10-10 12:44 UTC207
    };
782069860236atagen2026-10-10 12:44 UTC208
    let include_seeded = ctx.query("seeded") == Some("include");
782069860236atagen2026-10-10 12:44 UTC209
    let runs = state.runs();
782069860236atagen2026-10-10 12:44 UTC210
    let mut entries: Vec<&Run> = runs
782069860236atagen2026-10-10 12:44 UTC211
        .values()
782069860236atagen2026-10-10 12:44 UTC212
        .filter(|run| include_seeded || !run.seeded)
782069860236atagen2026-10-10 12:44 UTC213
        .collect();
058650ec79d6atagen2026-10-12 10:49 UTC214
    let key = |run: &Run| match sort {
058650ec79d6atagen2026-10-12 10:49 UTC215
        Sort::SelfReported => run.self_reported(),
058650ec79d6atagen2026-10-12 10:49 UTC216
        Sort::ContentLength => run.current_length(),
058650ec79d6atagen2026-10-12 10:49 UTC217
        Sort::Volume => run.volume(run.self_reported()),
782069860236atagen2026-10-10 12:44 UTC218
    };
782069860236atagen2026-10-10 12:44 UTC219
    entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
782069860236atagen2026-10-10 12:44 UTC220
    let entries: Vec<Value> = entries
782069860236atagen2026-10-10 12:44 UTC221
        .into_iter()
782069860236atagen2026-10-10 12:44 UTC222
        .map(|run| {
782069860236atagen2026-10-10 12:44 UTC223
            let mut entry = json!({
782069860236atagen2026-10-10 12:44 UTC224
                "public_id": run.public_id,
782069860236atagen2026-10-10 12:44 UTC225
                "status": run.status(),
782069860236atagen2026-10-10 12:44 UTC226
                "floor": run.floor.number(),
782069860236atagen2026-10-10 12:44 UTC227
                "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
782069860236atagen2026-10-10 12:44 UTC228
                "ranked": !run.seeded,
782069860236atagen2026-10-10 12:44 UTC229
                "viral": run.filmed_at.is_some(),
782069860236atagen2026-10-10 12:44 UTC230
            });
058650ec79d6atagen2026-10-12 10:49 UTC231
            if sort == Sort::Volume {
058650ec79d6atagen2026-10-12 10:49 UTC232
                entry["volume"] = json!((run.volume(run.self_reported()) * 100.0).round() / 100.0);
058650ec79d6atagen2026-10-12 10:49 UTC233
            }
058650ec79d6atagen2026-10-12 10:49 UTC234
            if sort == Sort::ContentLength {
782069860236atagen2026-10-10 12:44 UTC235
                entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());
782069860236atagen2026-10-10 12:44 UTC236
            }
782069860236atagen2026-10-10 12:44 UTC237
            entry
782069860236atagen2026-10-10 12:44 UTC238
        })
782069860236atagen2026-10-10 12:44 UTC239
        .collect();
782069860236atagen2026-10-10 12:44 UTC240
    Json(json!({ "entries": entries })).into_response()
782069860236atagen2026-10-10 12:44 UTC241
}
782069860236atagen2026-10-10 12:44 UTC242
782069860236atagen2026-10-10 12:44 UTC243
pub async fn not_found() -> Problem {
782069860236atagen2026-10-10 12:44 UTC244
    Problem::new(
782069860236atagen2026-10-10 12:44 UTC245
        StatusCode::NOT_FOUND,
782069860236atagen2026-10-10 12:44 UTC246
        "not-found",
782069860236atagen2026-10-10 12:44 UTC247
        "The resource does not exist. This is not about you.",
782069860236atagen2026-10-10 12:44 UTC248
    )
782069860236atagen2026-10-10 12:44 UTC249
}