atagen

jelq

public

an http based roguelike rpg about jelqing

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