atagen

jelq

public

an http based roguelike rpg about jelqing

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