atagen

jelq

public

an http based roguelike rpg about jelqing

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