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