atagen

jelq

public

an http based roguelike rpg about jelqing

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