atagen

jelq

public

an http based roguelike rpg about jelqing

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