atagen

jelq

public

an http based roguelike rpg about jelqing

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