atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
e807266c323bcc1fd00f59aa160b3b94d8168beb
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/meta.rsRust238 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.refractory = config.refractory;
109
    run.last_seen = ctx.now;
110
    let response = (
111
        StatusCode::CREATED,
112
        [
113
            (header::LOCATION, run.path("")),
114
            (header::ETAG, run.etag()),
115
            (X_SEED, seed.to_string()),
116
        ],
117
        Json(run.view()),
118
    )
119
        .into_response();
120
    let response = run::finish(&mut run, ctx, &config.secret, response);
121
    runs.insert(id, run);
122
    response
123
}
124
125
fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
126
    match ctx.param("year").parse::<u32>() {
127
        Ok(2009) => (
128
            [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],
129
            Json(forum_thread()),
130
        )
131
            .into_response(),
132
        Ok(year) if year > 2009 => Problem::new(
133
            StatusCode::NOT_FOUND,
134
            "no-thread",
135
            "Nobody has posted since 2009.",
136
        )
137
        .with("thread", FORUM_THREAD)
138
        .into_response(),
139
        _ => Problem::new(
140
            StatusCode::NOT_FOUND,
141
            "no-thread",
142
            "The forum did not exist yet. The problem did.",
143
        )
144
        .with("thread", FORUM_THREAD)
145
        .into_response(),
146
    }
147
}
148
149
fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
150
    let runs = state.runs();
151
    let dead: Vec<Value> = runs
152
        .values()
153
        .filter_map(|run| {
154
            let ending = run.ended.as_ref()?;
155
            Some(json!({
156
                "public_id": run.public_id,
157
                "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),
158
                "detail": ending.cause.detail(),
159
                "turn": ending.turn,
160
            }))
161
        })
162
        .collect();
163
    Json(json!({ "runs": dead })).into_response()
164
}
165
166
fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
167
    let truthful = match ctx.query("sort") {
168
        None | Some("self-reported-length") => false,
169
        Some("content-length") => match ctx.cookie("ruler") {
170
            Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => true,
171
            Some(_) => {
172
                return Problem::new(
173
                    StatusCode::BAD_REQUEST,
174
                    "exaggeration",
175
                    "The server has measured. Lying is not a technique.",
176
                )
177
                .with("cookie", "ruler")
178
                .into_response();
179
            }
180
            None => {
181
                return Problem::new(
182
                    StatusCode::FORBIDDEN,
183
                    "ruler-required",
184
                    "Sorting by the truth requires a Ruler.",
185
                )
186
                .into_response();
187
            }
188
        },
189
        Some(_) => {
190
            return Problem::new(
191
                StatusCode::BAD_REQUEST,
192
                "unknown-sort",
193
                "The leaderboard sorts by self-reported-length or content-length.",
194
            )
195
            .into_response();
196
        }
197
    };
198
    let include_seeded = ctx.query("seeded") == Some("include");
199
    let runs = state.runs();
200
    let mut entries: Vec<&Run> = runs
201
        .values()
202
        .filter(|run| include_seeded || !run.seeded)
203
        .collect();
204
    let key = |run: &Run| {
205
        if truthful {
206
            run.current_length()
207
        } else {
208
            run.self_reported()
209
        }
210
    };
211
    entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
212
    let entries: Vec<Value> = entries
213
        .into_iter()
214
        .map(|run| {
215
            let mut entry = json!({
216
                "public_id": run.public_id,
217
                "status": run.status(),
218
                "floor": run.floor.number(),
219
                "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
220
                "ranked": !run.seeded,
221
                "viral": run.filmed_at.is_some(),
222
            });
223
            if truthful {
224
                entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());
225
            }
226
            entry
227
        })
228
        .collect();
229
    Json(json!({ "entries": entries })).into_response()
230
}
231
232
pub async fn not_found() -> Problem {
233
    Problem::new(
234
        StatusCode::NOT_FOUND,
235
        "not-found",
236
        "The resource does not exist. This is not about you.",
237
    )
238
}