atagen

jelq

public

an http based roguelike rpg about jelqing

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