atagen

jelq

public

an http based roguelike rpg about jelqing

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