atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
a6caa2f9978665374ee51870e78151e4324328d0
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/meta.rsRust237 lines7.4 KB
1
//! Resources that do not belong to a single run.
2
3
use std::time::Instant;
4
5
use axum::{
6
    Json,
7
    http::{StatusCode, header},
8
    response::{IntoResponse, Response},
9
};
10
use rand::Rng;
11
use serde_json::{Value, json};
12
13
use super::{
14
    Shared,
15
    ctx::Ctx,
16
    resource::{Resource, Verb},
17
    run,
18
};
19
use crate::{
20
    game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread},
21
    headers::X_SEED,
22
    problem::{PROBLEM_BASE, Problem},
23
};
24
25
pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);
26
pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);
27
pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);
28
pub static GRAVEYARD: Resource<Shared> = Resource::new(&[Verb::new("GET", graveyard)]);
29
pub static LEADERBOARD: Resource<Shared> = Resource::new(&[Verb::new("GET", leaderboard)]);
30
31
fn index(_: &mut Shared, ctx: &Ctx) -> Response {
32
    let wants_html = ctx
33
        .header(header::ACCEPT)
34
        .is_some_and(|accept| accept.contains("text/html"));
35
    if wants_html {
36
        return (
37
            StatusCode::SEE_OTHER,
38
            [(header::LOCATION, "/ui/"), (header::VARY, "Accept")],
39
        )
40
            .into_response();
41
    }
42
    (
43
        [(header::VARY, "Accept")],
44
        Json(json!({
45
            "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",
46
            "start": { "href": "/runs", "method": "POST" },
47
            "leaderboard": "/leaderboard",
48
            "graveyard": "/graveyard",
49
            "forum": FORUM_THREAD,
50
            "ui": "/ui/",
51
        })),
52
    )
53
        .into_response()
54
}
55
56
fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
57
    let seeded = ctx.query("seed").is_some();
58
    let seed = match ctx.query("seed").map(str::parse::<u64>) {
59
        None => rand::rng().random(),
60
        Some(Ok(seed)) => seed,
61
        Some(Err(_)) => {
62
            return Problem::new(
63
                StatusCode::BAD_REQUEST,
64
                "bad-seed",
65
                "The seed must be an unsigned 64-bit integer.",
66
            )
67
            .into_response();
68
        }
69
    };
70
    let config = &state.config;
71
    let mut runs = state.runs();
72
    runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);
73
    if runs.len() >= config.max_runs {
74
        let mut buried: Vec<(Instant, String)> = runs
75
            .iter()
76
            .filter(|(_, run)| run.ended.is_some())
77
            .map(|(id, run)| (run.last_seen, id.clone()))
78
            .collect();
79
        buried.sort_unstable();
80
        let excess = runs.len() + 1 - config.max_runs;
81
        for (_, id) in buried.into_iter().take(excess) {
82
            runs.remove(&id);
83
        }
84
    }
85
    if runs.len() >= config.max_runs {
86
        return Problem::new(
87
            StatusCode::SERVICE_UNAVAILABLE,
88
            "server-full",
89
            "The server is full. Everyone is in there.",
90
        )
91
        .header(header::RETRY_AFTER, "600")
92
        .into_response();
93
    }
94
    let id = loop {
95
        let id = format!("{:032x}", rand::rng().random::<u128>());
96
        if !runs.contains_key(&id) {
97
            break id;
98
        }
99
    };
100
    let public_id = format!("{:08x}", rand::rng().random::<u32>());
101
    let mut run = Run::with_oracle(
102
        id.clone(),
103
        public_id,
104
        config.secret.mix_seed(seed),
105
        &config.oracle,
106
    );
107
    run.seeded = seeded;
108
    run.last_seen = ctx.now;
109
    let response = (
110
        StatusCode::CREATED,
111
        [
112
            (header::LOCATION, run.path("")),
113
            (header::ETAG, run.etag()),
114
            (X_SEED, seed.to_string()),
115
        ],
116
        Json(run.view()),
117
    )
118
        .into_response();
119
    let response = run::finish(&mut run, ctx, &config.secret, response);
120
    runs.insert(id, run);
121
    response
122
}
123
124
fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
125
    match ctx.param("year").parse::<u32>() {
126
        Ok(2009) => (
127
            [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],
128
            Json(forum_thread()),
129
        )
130
            .into_response(),
131
        Ok(year) if year > 2009 => Problem::new(
132
            StatusCode::NOT_FOUND,
133
            "no-thread",
134
            "Nobody has posted since 2009.",
135
        )
136
        .with("thread", FORUM_THREAD)
137
        .into_response(),
138
        _ => Problem::new(
139
            StatusCode::NOT_FOUND,
140
            "no-thread",
141
            "The forum did not exist yet. The problem did.",
142
        )
143
        .with("thread", FORUM_THREAD)
144
        .into_response(),
145
    }
146
}
147
148
fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
149
    let runs = state.runs();
150
    let dead: Vec<Value> = runs
151
        .values()
152
        .filter_map(|run| {
153
            let ending = run.ended.as_ref()?;
154
            Some(json!({
155
                "public_id": run.public_id,
156
                "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),
157
                "detail": ending.cause.detail(),
158
                "turn": ending.turn,
159
            }))
160
        })
161
        .collect();
162
    Json(json!({ "runs": dead })).into_response()
163
}
164
165
fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
166
    let truthful = match ctx.query("sort") {
167
        None | Some("self-reported-length") => false,
168
        Some("content-length") => match ctx.cookie("ruler") {
169
            Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => true,
170
            Some(_) => {
171
                return Problem::new(
172
                    StatusCode::BAD_REQUEST,
173
                    "exaggeration",
174
                    "The server has measured. Lying is not a technique.",
175
                )
176
                .with("cookie", "ruler")
177
                .into_response();
178
            }
179
            None => {
180
                return Problem::new(
181
                    StatusCode::FORBIDDEN,
182
                    "ruler-required",
183
                    "Sorting by the truth requires a Ruler.",
184
                )
185
                .into_response();
186
            }
187
        },
188
        Some(_) => {
189
            return Problem::new(
190
                StatusCode::BAD_REQUEST,
191
                "unknown-sort",
192
                "The leaderboard sorts by self-reported-length or content-length.",
193
            )
194
            .into_response();
195
        }
196
    };
197
    let include_seeded = ctx.query("seeded") == Some("include");
198
    let runs = state.runs();
199
    let mut entries: Vec<&Run> = runs
200
        .values()
201
        .filter(|run| include_seeded || !run.seeded)
202
        .collect();
203
    let key = |run: &Run| {
204
        if truthful {
205
            run.current_length()
206
        } else {
207
            run.self_reported()
208
        }
209
    };
210
    entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
211
    let entries: Vec<Value> = entries
212
        .into_iter()
213
        .map(|run| {
214
            let mut entry = json!({
215
                "public_id": run.public_id,
216
                "status": run.status(),
217
                "floor": run.floor.number(),
218
                "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
219
                "ranked": !run.seeded,
220
                "viral": run.filmed_at.is_some(),
221
            });
222
            if truthful {
223
                entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());
224
            }
225
            entry
226
        })
227
        .collect();
228
    Json(json!({ "entries": entries })).into_response()
229
}
230
231
pub async fn not_found() -> Problem {
232
    Problem::new(
233
        StatusCode::NOT_FOUND,
234
        "not-found",
235
        "The resource does not exist. This is not about you.",
236
    )
237
}