an http based roguelike rpg about jelqing
Blame: src/http/leaderboard.rs
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 1 | //! The public listings, paged fifty at a time with RFC 8288 `next` and `prev` links. |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 2 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 3 | use axum::{ |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 4 | Json, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 5 | http::{StatusCode, header}, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 6 | response::{IntoResponse, Response}, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 7 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 8 | use serde_json::{Value, json}; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 9 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 10 | use super::{Shared, ctx::Ctx, run::records_unavailable}; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 11 | use crate::{ |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 12 | game::{balance::MM_PER_INCH, volume}, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 13 | problem::{PROBLEM_BASE, Problem}, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 14 | records::{Ranking, Record}, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 15 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 16 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 17 | const PAGE: usize = 50; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 18 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 19 | fn page(ctx: &Ctx) -> Result<usize, Problem> { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 20 | match ctx.query("page").map(str::parse::<usize>) { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 21 | None => Ok(1), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 22 | Some(Ok(page)) if page >= 1 => Ok(page), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 23 | Some(_) => Err(Problem::new( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 24 | StatusCode::BAD_REQUEST, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 25 | "bad-page", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 26 | "Pages are counted from 1.", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 27 | )), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 28 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 29 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 30 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 31 | /// `query` is everything but the page, already encoded, so the links keep the caller's sort. |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 32 | fn paged(body: Value, path: &str, query: &str, page: usize, full: bool) -> Response { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 33 | let link = |page: usize, rel: &str| { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 34 | let separator = if query.is_empty() { "" } else { "&" }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 35 | format!("<{path}?{query}{separator}page={page}>; rel=\"{rel}\"") |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 36 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 37 | let mut links = Vec::new(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 38 | if page > 1 { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 39 | links.push(link(page - 1, "prev")); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 40 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 41 | if full { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 42 | links.push(link(page + 1, "next")); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 43 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 44 | let mut response = Json(body).into_response(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 45 | if links.is_empty() { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 46 | return response; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 47 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 48 | if let Ok(value) = links.join(", ").parse() { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 49 | response.headers_mut().insert(header::LINK, value); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 50 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 51 | response |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 52 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 53 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 54 | pub fn graveyard(state: &mut Shared, ctx: &Ctx) -> Response { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 55 | let page = match page(ctx) { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 56 | Ok(page) => page, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 57 | Err(problem) => return problem.into_response(), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 58 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 59 | let dead = match state.records.dead((page - 1) * PAGE, PAGE) { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 60 | Ok(dead) => dead, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 61 | Err(error) => return records_unavailable(&error), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 62 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 63 | let full = dead.len() == PAGE; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 64 | let runs: Vec<Value> = dead |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 65 | .into_iter() |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 66 | .filter_map(|record| { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 67 | let cause = record.cause?; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 68 | Some(json!({ |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 69 | "public_id": record.public_id, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 70 | "cause": format!("{PROBLEM_BASE}{}", cause.slug()), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 71 | "detail": cause.detail(), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 72 | "turn": record.turn, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 73 | })) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 74 | }) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 75 | .collect(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 76 | paged(json!({ "runs": runs }), "/graveyard", "", page, full) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 77 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 78 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 79 | fn ranking(state: &Shared, ctx: &Ctx) -> Result<Ranking, Problem> { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 80 | match ctx.query("sort") { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 81 | None | Some("self-reported-length") => Ok(Ranking::SelfReported), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 82 | Some("volume") => Ok(Ranking::Volume), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 83 | Some("content-length") => match ctx.cookie("ruler") { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 84 | Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 85 | Ok(Ranking::ContentLength) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 86 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 87 | Some(_) => Err(Problem::new( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 88 | StatusCode::BAD_REQUEST, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 89 | "exaggeration", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 90 | "The server has measured. Lying is not a technique.", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 91 | ) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 92 | .with("cookie", "ruler")), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 93 | None => Err(Problem::new( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 94 | StatusCode::FORBIDDEN, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 95 | "ruler-required", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 96 | "Sorting by the truth requires a Ruler.", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 97 | )), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 98 | }, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 99 | Some(_) => Err(Problem::new( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 100 | StatusCode::BAD_REQUEST, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 101 | "unknown-sort", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 102 | "The leaderboard sorts by self-reported-length, volume or content-length.", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 103 | )), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 104 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 105 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 106 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 107 | fn score(ranking: Ranking, record: &Record) -> f64 { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 108 | match ranking { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 109 | Ranking::SelfReported => record.self_reported, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 110 | Ranking::ContentLength => record.length, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 111 | Ranking::Volume => volume(record.self_reported, record.girth), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 112 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 113 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 114 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 115 | fn entry(ranking: Ranking, record: &Record) -> Value { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 116 | let mut entry = json!({ |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 117 | "public_id": record.public_id, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 118 | "status": record.status.name(), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 119 | "floor": record.floor, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 120 | "self_reported_length": (record.self_reported * 100.0).round() / 100.0, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 121 | "ranked": !record.seeded, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 122 | "viral": record.viral, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 123 | }); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 124 | match ranking { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 125 | Ranking::Volume => { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 126 | entry["volume"] = json!((score(ranking, record) * 100.0).round() / 100.0); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 127 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 128 | Ranking::ContentLength => { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 129 | entry["content_length"] = json!((record.length * MM_PER_INCH).round()); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 130 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 131 | Ranking::SelfReported => {} |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 132 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 133 | entry |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 134 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 135 | |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 136 | /// Live runs are ranked alongside the recorded ones, and a live run outranks its own stale record. |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 137 | pub fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 138 | let (ranking, page) = match ranking(state, ctx).and_then(|r| Ok((r, page(ctx)?))) { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 139 | Ok(parsed) => parsed, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 140 | Err(problem) => return problem.into_response(), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 141 | }; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 142 | let seeded = ctx.query("seeded") == Some("include"); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 143 | let mut entries: Vec<Record> = state |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 144 | .runs |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 145 | .each(Record::of) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 146 | .into_iter() |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 147 | .filter(|record| seeded || !record.seeded) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 148 | .collect(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 149 | let wanted = page * PAGE; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 150 | match state |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 151 | .records |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 152 | .top(ranking, seeded, 0, wanted + entries.len()) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 153 | { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 154 | Ok(recorded) => entries.extend( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 155 | recorded |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 156 | .into_iter() |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 157 | .filter(|record| !state.runs.contains(&record.id)), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 158 | ), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 159 | Err(error) => return records_unavailable(&error), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 160 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 161 | entries.sort_by(|a, b| score(ranking, b).total_cmp(&score(ranking, a))); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 162 | let full = entries.len() > wanted; |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 163 | let listed: Vec<Value> = entries |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 164 | .iter() |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 165 | .skip(wanted - PAGE) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 166 | .take(PAGE) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 167 | .map(|record| entry(ranking, record)) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 168 | .collect(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 169 | let mut query = Vec::new(); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 170 | if let Some(sort) = ctx.query("sort") { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 171 | query.push(format!("sort={sort}")); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 172 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 173 | if seeded { |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 174 | query.push("seeded=include".to_owned()); |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 175 | } |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 176 | paged( |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 177 | json!({ "entries": listed }), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 178 | "/leaderboard", |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 179 | &query.join("&"), |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 180 | page, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 181 | full, |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 182 | ) |
8b8e83329a1d | atagen | 2026-10-12 12:01 UTC | 183 | } |