atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
4347977b819220280d64f9e914967717329b830a
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http/runs.rsRust563 lines16.5 KB
1
//! The resources under `/runs/{id}`.
2
3
use std::{convert::Infallible, fmt::Write as _};
4
5
use axum::{
6
    Json,
7
    body::{Body, Bytes},
8
    http::{StatusCode, header},
9
    response::{IntoResponse, Response},
10
};
11
use serde_json::{Value, json};
12
13
use super::{
14
    ctx::Ctx,
15
    resource::{Resource, Verb},
16
    response::respond,
17
    run::{Greeting, RunResource, Scope},
18
};
19
use crate::{
20
    game::{
21
        Cause, Floor, Grip, Item, LUBRICANTS, Lube, PatchOp, Run, Technique,
22
        balance::{EQ_BAND, MM_PER_INCH},
23
        shaft,
24
    },
25
    headers::ACCEPT_PATCH,
26
    problem::Problem,
27
};
28
29
const fn home(verbs: &'static [Verb<Run>]) -> RunResource {
30
    RunResource {
31
        scope: Scope::Home,
32
        resource: Resource::new(verbs),
33
    }
34
}
35
36
const fn pockets(verbs: &'static [Verb<Run>]) -> RunResource {
37
    RunResource {
38
        scope: Scope::Pockets,
39
        resource: Resource::new(verbs),
40
    }
41
}
42
43
fn not_in_clinic(run: &Run) -> bool {
44
    run.floor != Floor::Clinic
45
}
46
47
fn never(_: &Run) -> bool {
48
    false
49
}
50
51
fn unreachable_handler(_: &mut Run, _: &Ctx) -> Response {
52
    StatusCode::METHOD_NOT_ALLOWED.into_response()
53
}
54
55
pub static RUN: RunResource = RunResource {
56
    scope: Scope::Home,
57
    resource: Resource {
58
        verbs: &[
59
            Verb::new("GET", get_run),
60
            Verb::new("JELQ", jelq)
61
                .only_when(not_in_clinic, "JELQ is not performed in the clinic."),
62
            Verb::new("HOLD", hold_run).only_when(
63
                Run::can_hold,
64
                "HOLD is only available above 70 EQ or at 2 stamina or less.",
65
            ),
66
            Verb::new("PUT", put_run),
67
            Verb::new("PATCH", patch_run),
68
            Verb::new("DELETE", delete_run),
69
            Verb::new("TRACE", trace_run),
70
        ],
71
        accept_patch: true,
72
    },
73
};
74
pub static LENGTH: RunResource = home(&[Verb::new("GET", get_length)]);
75
pub static FORESKIN: RunResource = home(&[
76
    Verb::new("GET", get_foreskin),
77
    Verb::new("DELETE", delete_foreskin),
78
]);
79
pub static INVENTORY: RunResource = pockets(&[Verb::new("GET", get_inventory)]);
80
pub static ITEM: RunResource =
81
    pockets(&[Verb::new("GET", get_item), Verb::new("DELETE", delete_item)]);
82
pub static TECHNIQUES: RunResource = home(&[Verb::new("GET", get_techniques)]);
83
pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);
84
pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);
85
pub static FLOOR: RunResource =
86
    home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);
87
pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);
88
pub static BUY: RunResource = home(&[Verb::new("POST", buy)]);
89
pub static PORTAL: RunResource = pockets(&[
90
    Verb::new("GET", get_portal),
91
    Verb::new("POST", accept_portal),
92
]);
93
pub static ELDER: RunResource = home(&[Verb::new("GET", get_elder)]);
94
pub static GRIP: RunResource = home(&[Verb::new("GET", get_grip)]);
95
pub static DIAGNOSIS: RunResource = pockets(&[Verb::new("GET", get_diagnosis)]);
96
pub static FOOTAGE: RunResource = pockets(&[
97
    Verb::new("GET", get_footage),
98
    Verb::new("DELETE", unreachable_handler).only_when(
99
        never,
100
        "The footage has been cached by intermediaries. It cannot be deleted.",
101
    ),
102
]);
103
104
fn get_run(run: &mut Run, ctx: &Ctx) -> Response {
105
    if ctx
106
        .header(header::ACCEPT)
107
        .is_some_and(|accept| accept.contains("reassurance"))
108
    {
109
        return Problem::new(
110
            StatusCode::NOT_ACCEPTABLE,
111
            "no-reassurance",
112
            "The server does not produce reassurance.",
113
        )
114
        .into_response();
115
    }
116
    (
117
        StatusCode::OK,
118
        [(header::ETAG, run.etag())],
119
        Json(run.view()),
120
    )
121
        .into_response()
122
}
123
124
fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {
125
    let required = || {
126
        Problem::new(
127
            StatusCode::BAD_REQUEST,
128
            "technique-required",
129
            "No technique was specified.",
130
        )
131
        .with("techniques", run.path("/techniques"))
132
    };
133
    let names = ctx
134
        .query("technique")
135
        .filter(|n| !n.trim().is_empty())
136
        .ok_or_else(required)?;
137
    names
138
        .split(',')
139
        .map(str::trim)
140
        .filter(|n| !n.is_empty())
141
        .map(|name| {
142
            Technique::parse(name).ok_or_else(|| {
143
                Problem::new(
144
                    StatusCode::BAD_REQUEST,
145
                    "unknown-technique",
146
                    format!("{name} is not a technique."),
147
                )
148
                .with("techniques", run.path("/techniques"))
149
            })
150
        })
151
        .collect()
152
}
153
154
fn parse_intensity(ctx: &Ctx) -> Result<i32, Problem> {
155
    match ctx.query("intensity").map(str::parse::<i32>) {
156
        None => Ok(5),
157
        Some(Ok(i)) if (1..=10).contains(&i) => Ok(i),
158
        Some(_) => Err(Problem::new(
159
            StatusCode::BAD_REQUEST,
160
            "intensity-out-of-range",
161
            "Intensity is measured from 1 to 10.",
162
        )),
163
    }
164
}
165
166
fn jelq(run: &mut Run, ctx: &Ctx) -> Response {
167
    let parsed = parse_techniques(run, ctx).and_then(|techniques| {
168
        let intensity = parse_intensity(ctx)?;
169
        let lube = Lube::from_content_type(ctx.header(header::CONTENT_TYPE))?;
170
        Ok((techniques, intensity, lube))
171
    });
172
    match parsed {
173
        Ok((techniques, intensity, lube)) => {
174
            respond(run.jelq(&techniques, intensity, lube, ctx.now))
175
        }
176
        Err(problem) => problem.into_response(),
177
    }
178
}
179
180
fn put_run(run: &mut Run, _: &Ctx) -> Response {
181
    run.put().into_response()
182
}
183
184
fn hold_run(run: &mut Run, ctx: &Ctx) -> Response {
185
    respond(run.hold(ctx.now))
186
}
187
188
fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {
189
    let is_patch = ctx
190
        .header(header::CONTENT_TYPE)
191
        .is_some_and(|ct| ct.starts_with("application/json-patch+json"));
192
    if !is_patch {
193
        return Problem::new(
194
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
195
            "patch-format",
196
            "Patches are accepted as application/json-patch+json.",
197
        )
198
        .header(ACCEPT_PATCH, "application/json-patch+json")
199
        .into_response();
200
    }
201
    match serde_json::from_slice::<Vec<PatchOp>>(&ctx.body) {
202
        Ok(ops) => respond(run.patch(&ops)),
203
        Err(_) => Problem::new(
204
            StatusCode::BAD_REQUEST,
205
            "malformed-patch",
206
            "The patch could not be parsed.",
207
        )
208
        .into_response(),
209
    }
210
}
211
212
fn delete_run(run: &mut Run, _: &Ctx) -> Response {
213
    run.end(Cause::Abandoned);
214
    StatusCode::NO_CONTENT.into_response()
215
}
216
217
fn trace_run(run: &mut Run, ctx: &Ctx) -> Response {
218
    run.shame += 1;
219
    let mut message = format!("{} {} {:?}\r\n", ctx.method, ctx.uri, ctx.version);
220
    for (name, value) in &ctx.headers {
221
        let _ = write!(
222
            message,
223
            "{name}: {}\r\n",
224
            value.to_str().unwrap_or("<binary>")
225
        );
226
    }
227
    message.push_str("\r\n");
228
    (
229
        StatusCode::OK,
230
        [(header::CONTENT_TYPE, "message/http")],
231
        message,
232
    )
233
        .into_response()
234
}
235
236
fn millimetres(inches: f64) -> usize {
237
    (inches * MM_PER_INCH).round() as usize
238
}
239
240
enum InchRange {
241
    Satisfiable(f64, f64),
242
    Unsatisfiable,
243
}
244
245
/// `None` means the header is not a valid range, which RFC 9110 §14.2 says to ignore rather than refuse.
246
fn parse_inch_range(range: &str, total: f64) -> Option<InchRange> {
247
    let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
248
    let number = |text: &str| {
249
        text.parse::<f64>()
250
            .ok()
251
            .filter(|n| n.is_finite() && *n >= 0.0)
252
    };
253
    let (start, end) = match (start.trim(), end.trim()) {
254
        ("", "") => return None,
255
        ("", suffix) => ((total - number(suffix)?).max(0.0), total),
256
        (start, "") => (number(start)?, total),
257
        (start, end) => {
258
            let (start, end) = (number(start)?, number(end)?);
259
            if start > end {
260
                return None;
261
            }
262
            (start, end)
263
        }
264
    };
265
    Some(if start < total {
266
        InchRange::Satisfiable(start, end.min(total))
267
    } else {
268
        InchRange::Unsatisfiable
269
    })
270
}
271
272
fn unsatisfied_range(inches: f64) -> Response {
273
    Problem::new(
274
        StatusCode::RANGE_NOT_SATISFIABLE,
275
        "range-not-satisfiable",
276
        format!("The requested range is not available. {inches:.2} inches are."),
277
    )
278
    .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))
279
    .into_response()
280
}
281
282
fn partial_inches(body: &str, offset: usize, start: f64, end: f64, total: f64) -> Response {
283
    let from = (millimetres(start) + offset).min(body.len() - 1);
284
    let to = (millimetres(end) + offset).clamp(from + 1, body.len());
285
    (
286
        StatusCode::PARTIAL_CONTENT,
287
        [
288
            (
289
                header::CONTENT_TYPE,
290
                "text/plain; charset=us-ascii".to_owned(),
291
            ),
292
            (header::ACCEPT_RANGES, "inches".to_owned()),
293
            (
294
                header::CONTENT_RANGE,
295
                format!("inches {start:.2}-{end:.2}/{total:.2}"),
296
            ),
297
        ],
298
        body[from..to].to_owned(),
299
    )
300
        .into_response()
301
}
302
303
fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
304
    let inches = if run.items.ruler {
305
        run.current_length()
306
    } else {
307
        run.self_reported()
308
    };
309
    let body = shaft(millimetres(inches));
310
    let content_type = (
311
        header::CONTENT_TYPE,
312
        "text/plain; charset=us-ascii".to_owned(),
313
    );
314
    let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
315
316
    if let Some(range) = ctx
317
        .header(header::RANGE)
318
        .and_then(|range| parse_inch_range(range, inches))
319
    {
320
        match range {
321
            InchRange::Satisfiable(start, end) => {
322
                return partial_inches(&body, 0, start, end, inches);
323
            }
324
            InchRange::Unsatisfiable => return unsatisfied_range(inches),
325
        }
326
    }
327
328
    if run.items.ruler {
329
        return (StatusCode::OK, [content_type, accept_ranges], body).into_response();
330
    }
331
    let chunks: Vec<Result<Bytes, Infallible>> = body
332
        .into_bytes()
333
        .chunks(16)
334
        .map(|c| Ok(Bytes::copy_from_slice(c)))
335
        .collect();
336
    let body = Body::from_stream(futures_util::stream::iter(chunks));
337
    (StatusCode::OK, [content_type, accept_ranges], body).into_response()
338
}
339
340
fn get_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
341
    let Some(inches) = run.foreskin else {
342
        return Problem::new(
343
            StatusCode::GONE,
344
            "foreskin-gone",
345
            "The foreskin is gone. It will not be returning.",
346
        )
347
        .into_response();
348
    };
349
    let body = format!("8({})D", "~".repeat(millimetres(inches)));
350
    if let Some(range) = ctx
351
        .header(header::RANGE)
352
        .and_then(|range| parse_inch_range(range, inches))
353
    {
354
        match range {
355
            InchRange::Satisfiable(start, end) => {
356
                return partial_inches(&body, 2, start, end, inches);
357
            }
358
            InchRange::Unsatisfiable => return unsatisfied_range(inches),
359
        }
360
    }
361
    (
362
        StatusCode::OK,
363
        [
364
            (header::ETAG, run.etag()),
365
            (
366
                header::CONTENT_TYPE,
367
                "text/plain; charset=us-ascii".to_owned(),
368
            ),
369
            (header::ACCEPT_RANGES, "inches".to_owned()),
370
        ],
371
        body,
372
    )
373
        .into_response()
374
}
375
376
fn delete_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
377
    respond(run.delete_foreskin(ctx.header(header::IF_MATCH)))
378
}
379
380
fn get_inventory(run: &mut Run, _: &Ctx) -> Response {
381
    let items: Vec<Value> = run
382
        .items
383
        .owned()
384
        .into_iter()
385
        .map(|item| {
386
            json!({
387
                "name": item.slug(),
388
                "href": run.path(&format!("/inventory/{}", item.slug())),
389
            })
390
        })
391
        .collect();
392
    Json(json!({
393
        "items": items,
394
        "ice_packs": run.items.ice_packs,
395
        "supplements": run.items.supplements,
396
        "excuses": run.excuses(),
397
    }))
398
    .into_response()
399
}
400
401
fn owned_item(run: &Run, ctx: &Ctx) -> Result<Item, Problem> {
402
    Item::parse(ctx.param("item"))
403
        .filter(|&item| run.items.has(item))
404
        .ok_or_else(|| {
405
            Problem::new(
406
                StatusCode::NOT_FOUND,
407
                "no-such-item",
408
                "You do not have that.",
409
            )
410
        })
411
}
412
413
fn get_item(run: &mut Run, ctx: &Ctx) -> Response {
414
    match owned_item(run, ctx) {
415
        Ok(item) => {
416
            Json(json!({ "name": item.slug(), "description": item.description() })).into_response()
417
        }
418
        Err(problem) => problem.into_response(),
419
    }
420
}
421
422
fn delete_item(run: &mut Run, ctx: &Ctx) -> Response {
423
    match owned_item(run, ctx) {
424
        Ok(item) => respond(run.drop_item(item)),
425
        Err(problem) => problem.into_response(),
426
    }
427
}
428
429
fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
430
    let listed: Vec<Value> = Technique::ALL
431
        .into_iter()
432
        .map(|t| t.describe(run.forum_read()))
433
        .collect();
434
    Json(json!({
435
        "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
436
        "lubricant": "Declared with Content-Type.",
437
        "lubricants": LUBRICANTS,
438
        "eq_band": [EQ_BAND.start(), EQ_BAND.end()],
439
        "techniques": listed,
440
    }))
441
    .into_response()
442
}
443
444
fn search_drawer(run: &mut Run, _: &Ctx) -> Response {
445
    respond(run.search_drawer())
446
}
447
448
fn choose_branch(run: &mut Run, ctx: &Ctx) -> Response {
449
    respond(run.choose(ctx.param("choice")))
450
}
451
452
fn get_floor(run: &mut Run, _: &Ctx) -> Response {
453
    Json(run.floor_view()).into_response()
454
}
455
456
fn leave_floor(run: &mut Run, _: &Ctx) -> Response {
457
    respond(run.advance())
458
}
459
460
fn get_shop(run: &mut Run, _: &Ctx) -> Response {
461
    let view = run.shop_view();
462
    if view.is_ok() {
463
        run.here.shopping = true;
464
    }
465
    respond(view.map(Json))
466
}
467
468
fn buy(run: &mut Run, ctx: &Ctx) -> Response {
469
    respond(run.buy(ctx.param("item")))
470
}
471
472
fn get_portal(run: &mut Run, _: &Ctx) -> Response {
473
    Json(json!({
474
        "terms": "By connecting you agree that the hotel is not responsible for anything that happens in this room, and that it does not want to know.",
475
        "accept": { "href": run.path("/portal"), "method": "POST" },
476
    }))
477
    .into_response()
478
}
479
480
fn accept_portal(run: &mut Run, _: &Ctx) -> Response {
481
    respond(run.accept_terms())
482
}
483
484
fn get_elder(run: &mut Run, ctx: &Ctx) -> Response {
485
    respond(run.elder(
486
        ctx.header(header::PROXY_AUTHORIZATION),
487
        ctx.header(header::IF_MODIFIED_SINCE),
488
    ))
489
}
490
491
fn requested_grip(ctx: &Ctx) -> Option<Grip> {
492
    let connection = ctx.header(header::CONNECTION)?.to_ascii_lowercase();
493
    if !connection.split(',').any(|token| token.trim() == "upgrade") {
494
        return None;
495
    }
496
    ctx.header(header::UPGRADE)?
497
        .split(',')
498
        .find_map(|protocol| Grip::parse(protocol.trim()))
499
}
500
501
fn get_grip(run: &mut Run, ctx: &Ctx) -> Response {
502
    if let Some(grip) = requested_grip(ctx) {
503
        run.set_grip(grip);
504
        let mut response = (
505
            StatusCode::SWITCHING_PROTOCOLS,
506
            [
507
                (header::UPGRADE, grip.name()),
508
                (header::CONNECTION, "upgrade"),
509
            ],
510
        )
511
            .into_response();
512
        response.extensions_mut().insert(Greeting(format!(
513
            "You are now jelqing {}.\r\n",
514
            grip.name()
515
        )));
516
        return response;
517
    }
518
    let detail = match ctx.header(header::UPGRADE) {
519
        Some(protocol) => {
520
            format!("The server does not know the {protocol} grip. Grips are protocols.")
521
        }
522
        None => "Grips are protocols. Change grip by upgrading the connection.".to_owned(),
523
    };
524
    (
525
        StatusCode::OK,
526
        [(header::UPGRADE, "overhand, underhand")],
527
        Json(json!({ "grip": run.grip.name(), "detail": detail })),
528
    )
529
        .into_response()
530
}
531
532
fn get_diagnosis(run: &mut Run, ctx: &Ctx) -> Response {
533
    respond(run.diagnose(
534
        ctx.header(header::AUTHORIZATION),
535
        ctx.header(header::IF_NONE_MATCH),
536
    ))
537
}
538
539
fn get_footage(run: &mut Run, _: &Ctx) -> Response {
540
    let Some((filmed, age)) = run.footage() else {
541
        return Problem::new(
542
            StatusCode::NOT_FOUND,
543
            "no-footage",
544
            "There is no footage. Yet.",
545
        )
546
        .into_response();
547
    };
548
    (
549
        [
550
            (
551
                header::CACHE_CONTROL,
552
                "public, max-age=31536000, immutable".to_owned(),
553
            ),
554
            (header::AGE, age.to_string()),
555
            (header::LAST_MODIFIED, httpdate::fmt_http_date(filmed)),
556
        ],
557
        Json(json!({
558
            "detail": "A recording. It is grainy. It is unmistakably you. It has been shared.",
559
            "views": 1 + age * 37,
560
        })),
561
    )
562
        .into_response()
563
}