an http based roguelike rpg about jelqing
| 1 | use std::{ |
| 2 | collections::HashMap, |
| 3 | convert::Infallible, |
| 4 | fmt::Write as _, |
| 5 | sync::{Arc, Mutex}, |
| 6 | time::Instant, |
| 7 | }; |
| 8 | |
| 9 | use axum::{ |
| 10 | Json, Router, |
| 11 | body::{Body, Bytes}, |
| 12 | extract::{Path, Query, Request, State}, |
| 13 | http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header}, |
| 14 | middleware::{self, Next}, |
| 15 | response::{IntoResponse, Response}, |
| 16 | routing::any, |
| 17 | }; |
| 18 | use rand::Rng; |
| 19 | use serde_json::{Value, json}; |
| 20 | |
| 21 | use crate::{ |
| 22 | game::{ |
| 23 | EXCUSES, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, PatchOp, Run, SUSPICION_LIMIT, |
| 24 | Technique, shaft, |
| 25 | }, |
| 26 | problem::{PROBLEM_BASE, Problem}, |
| 27 | }; |
| 28 | |
| 29 | const REL_BASE: &str = "https://jelq.dev/rels/"; |
| 30 | const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict"; |
| 31 | const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method."; |
| 32 | |
| 33 | #[derive(Default)] |
| 34 | pub struct AppState { |
| 35 | runs: Mutex<HashMap<String, Run>>, |
| 36 | } |
| 37 | |
| 38 | impl AppState { |
| 39 | pub fn insert(&self, run: Run) { |
| 40 | self.runs.lock().unwrap().insert(run.id.clone(), run); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | type Shared = Arc<AppState>; |
| 45 | |
| 46 | pub fn app() -> Router { |
| 47 | router(Arc::default()) |
| 48 | } |
| 49 | |
| 50 | pub fn router(state: Shared) -> Router { |
| 51 | Router::new() |
| 52 | .route("/runs", any(runs)) |
| 53 | .route("/runs/{id}", any(run)) |
| 54 | .route("/runs/{id}/length", any(length)) |
| 55 | .route("/runs/{id}/foreskin", any(foreskin)) |
| 56 | .route("/runs/{id}/inventory", any(inventory)) |
| 57 | .route("/runs/{id}/inventory/{item}", any(item)) |
| 58 | .route("/runs/{id}/techniques", any(techniques)) |
| 59 | .route("/runs/{id}/drawer", any(drawer)) |
| 60 | .route("/runs/{id}/branches/{choice}", any(branch)) |
| 61 | .route("/runs/{id}/diagnosis", any(diagnosis)) |
| 62 | .route("/graveyard", any(graveyard)) |
| 63 | .route("/leaderboard", any(leaderboard)) |
| 64 | .fallback(not_found) |
| 65 | .layer(middleware::from_fn(chrome)) |
| 66 | .with_state(state) |
| 67 | } |
| 68 | |
| 69 | async fn chrome(request: Request, next: Next) -> Response { |
| 70 | let mut response = next.run(request).await; |
| 71 | let headers = response.headers_mut(); |
| 72 | headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9")); |
| 73 | headers.insert("x-powered-by", HeaderValue::from_static("blood")); |
| 74 | headers.insert( |
| 75 | header::CACHE_CONTROL, |
| 76 | HeaderValue::from_static("no-store, private"), |
| 77 | ); |
| 78 | response |
| 79 | } |
| 80 | |
| 81 | fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> { |
| 82 | headers.get(name).and_then(|v| v.to_str().ok()) |
| 83 | } |
| 84 | |
| 85 | fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { |
| 86 | headers |
| 87 | .get_all(header::COOKIE) |
| 88 | .iter() |
| 89 | .filter_map(|v| v.to_str().ok()) |
| 90 | .flat_map(|v| v.split(';')) |
| 91 | .filter_map(|pair| pair.trim().split_once('=')) |
| 92 | .find(|(key, _)| *key == name) |
| 93 | .map(|(_, value)| value) |
| 94 | } |
| 95 | |
| 96 | fn unsupported(method: &Method, allow: &'static str) -> Response { |
| 97 | if *method == Method::CONNECT { |
| 98 | return StatusCode::NOT_IMPLEMENTED.into_response(); |
| 99 | } |
| 100 | let known = matches!( |
| 101 | method.as_str(), |
| 102 | "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ" |
| 103 | ); |
| 104 | if !known { |
| 105 | return Problem::new( |
| 106 | StatusCode::NOT_IMPLEMENTED, |
| 107 | "not-implemented", |
| 108 | format!("The server does not implement {method}."), |
| 109 | ) |
| 110 | .into_response(); |
| 111 | } |
| 112 | Problem::new( |
| 113 | StatusCode::METHOD_NOT_ALLOWED, |
| 114 | "method-not-allowed", |
| 115 | format!("{method} is not supported here."), |
| 116 | ) |
| 117 | .header(header::ALLOW, allow) |
| 118 | .into_response() |
| 119 | } |
| 120 | |
| 121 | fn options(allow: &'static str, patchable: bool) -> Response { |
| 122 | let mut response = ( |
| 123 | StatusCode::OK, |
| 124 | [(header::ALLOW, allow)], |
| 125 | Json(json!({ "detail": THERAPY })), |
| 126 | ) |
| 127 | .into_response(); |
| 128 | if patchable { |
| 129 | response.headers_mut().insert( |
| 130 | "accept-patch", |
| 131 | HeaderValue::from_static("application/json-patch+json"), |
| 132 | ); |
| 133 | } |
| 134 | response |
| 135 | } |
| 136 | |
| 137 | fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response { |
| 138 | let mut message = format!("{method} {uri} {version:?}\r\n"); |
| 139 | for (name, value) in headers { |
| 140 | let _ = write!( |
| 141 | message, |
| 142 | "{name}: {}\r\n", |
| 143 | value.to_str().unwrap_or("<binary>") |
| 144 | ); |
| 145 | } |
| 146 | message.push_str("\r\n"); |
| 147 | ( |
| 148 | StatusCode::OK, |
| 149 | [(header::CONTENT_TYPE, "message/http")], |
| 150 | message, |
| 151 | ) |
| 152 | .into_response() |
| 153 | } |
| 154 | |
| 155 | fn links(run: &Run) -> String { |
| 156 | let id = &run.id; |
| 157 | let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")]; |
| 158 | for rel in [ |
| 159 | "length", |
| 160 | "foreskin", |
| 161 | "inventory", |
| 162 | "techniques", |
| 163 | "drawer", |
| 164 | "diagnosis", |
| 165 | ] { |
| 166 | links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\"")); |
| 167 | } |
| 168 | links.join(", ") |
| 169 | } |
| 170 | |
| 171 | fn finish(run: &Run, mut response: Response) -> Response { |
| 172 | if response.status() == StatusCode::NOT_MODIFIED { |
| 173 | return response; |
| 174 | } |
| 175 | let mut stats = vec![ |
| 176 | ( |
| 177 | "x-self-reported-length", |
| 178 | format!("{:.2}", run.self_reported()), |
| 179 | ), |
| 180 | ("x-girth", format!("{:.2}", run.girth)), |
| 181 | ( |
| 182 | "x-foreskin", |
| 183 | run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")), |
| 184 | ), |
| 185 | ("x-eq", run.eq.to_string()), |
| 186 | ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)), |
| 187 | ( |
| 188 | "x-suspicion", |
| 189 | format!("{}/{SUSPICION_LIMIT}", run.suspicion), |
| 190 | ), |
| 191 | ("x-shame", run.total_shame().to_string()), |
| 192 | ("link", links(run)), |
| 193 | ]; |
| 194 | if run.observed { |
| 195 | stats.push(("x-observed-by", "cat".into())); |
| 196 | } |
| 197 | if run.mom_present { |
| 198 | stats.push(("via", "1.1 mom".into())); |
| 199 | stats.push(("vary", "Mom".into())); |
| 200 | } |
| 201 | // RFC 9111 obsoleted Warning, which is why nobody heeds it. |
| 202 | if run.eq > 70 && run.ended.is_none() { |
| 203 | stats.push(("warning", "199 - \"it is turning purple\"".into())); |
| 204 | } |
| 205 | stats.push(( |
| 206 | "set-cookie", |
| 207 | format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()), |
| 208 | )); |
| 209 | if run.ruler { |
| 210 | stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}"))); |
| 211 | } |
| 212 | let headers = response.headers_mut(); |
| 213 | for (name, value) in stats { |
| 214 | headers.append(name, HeaderValue::from_str(&value).expect("visible ascii")); |
| 215 | } |
| 216 | response |
| 217 | } |
| 218 | |
| 219 | fn gone(run: &Run) -> Response { |
| 220 | let ending = run.ended.as_ref().expect("only called on ended runs"); |
| 221 | Problem::new(StatusCode::GONE, "run-ended", "This run has ended.") |
| 222 | .with("cause", format!("{PROBLEM_BASE}{}", ending.cause)) |
| 223 | .with("turn", ending.turn) |
| 224 | .with("graveyard", "/graveyard") |
| 225 | .into_response() |
| 226 | } |
| 227 | |
| 228 | fn with_run( |
| 229 | state: &AppState, |
| 230 | id: &str, |
| 231 | headers: &HeaderMap, |
| 232 | mom_applies: bool, |
| 233 | handle: impl FnOnce(&mut Run) -> Response, |
| 234 | ) -> Response { |
| 235 | let mut runs = state.runs.lock().unwrap(); |
| 236 | let Some(run) = runs.get_mut(id) else { |
| 237 | return Problem::new( |
| 238 | StatusCode::NOT_FOUND, |
| 239 | "no-such-run", |
| 240 | format!("There is no run {id}. There may never have been."), |
| 241 | ) |
| 242 | .into_response(); |
| 243 | }; |
| 244 | if run.ended.is_some() { |
| 245 | return gone(run); |
| 246 | } |
| 247 | run.observed = false; |
| 248 | if mom_applies |
| 249 | && run.mom_present |
| 250 | && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION)) |
| 251 | { |
| 252 | return finish(run, problem.into_response()); |
| 253 | } |
| 254 | let response = handle(run); |
| 255 | finish(run, response) |
| 256 | } |
| 257 | |
| 258 | fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response { |
| 259 | match result { |
| 260 | Ok(ok) => ok.into_response(), |
| 261 | Err(err) => err.into_response(), |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | async fn runs( |
| 266 | State(state): State<Shared>, |
| 267 | method: Method, |
| 268 | Query(query): Query<HashMap<String, String>>, |
| 269 | headers: HeaderMap, |
| 270 | ) -> Response { |
| 271 | if method != Method::POST { |
| 272 | return unsupported(&method, "POST"); |
| 273 | } |
| 274 | let seed = match query.get("seed").map(|s| s.parse::<u64>()) { |
| 275 | None => rand::rng().random(), |
| 276 | Some(Ok(seed)) => seed, |
| 277 | Some(Err(_)) => { |
| 278 | return Problem::new( |
| 279 | StatusCode::BAD_REQUEST, |
| 280 | "bad-seed", |
| 281 | "The seed must be an unsigned 64-bit integer.", |
| 282 | ) |
| 283 | .into_response(); |
| 284 | } |
| 285 | }; |
| 286 | let shame = cookie(&headers, "shame") |
| 287 | .and_then(|s| s.parse().ok()) |
| 288 | .unwrap_or(0); |
| 289 | let id = format!("{:08x}", rand::rng().random::<u32>()); |
| 290 | let run = Run::new(id.clone(), seed, shame); |
| 291 | let response = ( |
| 292 | StatusCode::CREATED, |
| 293 | [ |
| 294 | (header::LOCATION, format!("/runs/{id}")), |
| 295 | (header::ETAG, run.etag()), |
| 296 | (HeaderName::from_static("x-seed"), seed.to_string()), |
| 297 | ], |
| 298 | Json(run.view()), |
| 299 | ) |
| 300 | .into_response(); |
| 301 | let response = finish(&run, response); |
| 302 | state.insert(run); |
| 303 | response |
| 304 | } |
| 305 | |
| 306 | #[allow(clippy::too_many_arguments)] |
| 307 | async fn run( |
| 308 | State(state): State<Shared>, |
| 309 | Path(id): Path<String>, |
| 310 | method: Method, |
| 311 | uri: Uri, |
| 312 | version: Version, |
| 313 | Query(query): Query<HashMap<String, String>>, |
| 314 | headers: HeaderMap, |
| 315 | body: Bytes, |
| 316 | ) -> Response { |
| 317 | const ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE"; |
| 318 | let now = Instant::now(); |
| 319 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 320 | "GET" | "HEAD" => { |
| 321 | let accept = header_str(&headers, header::ACCEPT).unwrap_or_default(); |
| 322 | if accept.contains("reassurance") { |
| 323 | return Problem::new( |
| 324 | StatusCode::NOT_ACCEPTABLE, |
| 325 | "no-reassurance", |
| 326 | "The server does not produce reassurance.", |
| 327 | ) |
| 328 | .into_response(); |
| 329 | } |
| 330 | ( |
| 331 | StatusCode::OK, |
| 332 | [(header::ETAG, run.etag())], |
| 333 | Json(run.view()), |
| 334 | ) |
| 335 | .into_response() |
| 336 | } |
| 337 | "JELQ" => jelq(run, &query, &headers, now), |
| 338 | "PUT" => run.put().into_response(), |
| 339 | "PATCH" => patch(run, &headers, &body), |
| 340 | "DELETE" => { |
| 341 | run.end("abandoned", "The run was abandoned."); |
| 342 | StatusCode::NO_CONTENT.into_response() |
| 343 | } |
| 344 | "OPTIONS" => options(ALLOW, true), |
| 345 | "TRACE" => { |
| 346 | run.shame += 1; |
| 347 | trace(&method, &uri, version, &headers) |
| 348 | } |
| 349 | _ => unsupported(&method, ALLOW), |
| 350 | }) |
| 351 | } |
| 352 | |
| 353 | fn jelq( |
| 354 | run: &mut Run, |
| 355 | query: &HashMap<String, String>, |
| 356 | headers: &HeaderMap, |
| 357 | now: Instant, |
| 358 | ) -> Response { |
| 359 | let technique = match query.get("technique") { |
| 360 | None => { |
| 361 | return Problem::new( |
| 362 | StatusCode::BAD_REQUEST, |
| 363 | "technique-required", |
| 364 | "No technique was specified.", |
| 365 | ) |
| 366 | .with("techniques", format!("/runs/{}/techniques", run.id)) |
| 367 | .into_response(); |
| 368 | } |
| 369 | Some(name) => match Technique::parse(name) { |
| 370 | Some(t) => t, |
| 371 | None => { |
| 372 | return Problem::new( |
| 373 | StatusCode::BAD_REQUEST, |
| 374 | "unknown-technique", |
| 375 | format!("{name} is not a technique."), |
| 376 | ) |
| 377 | .with("techniques", format!("/runs/{}/techniques", run.id)) |
| 378 | .into_response(); |
| 379 | } |
| 380 | }, |
| 381 | }; |
| 382 | let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) { |
| 383 | None => 5, |
| 384 | Some(Ok(i)) if (1..=10).contains(&i) => i, |
| 385 | Some(_) => { |
| 386 | return Problem::new( |
| 387 | StatusCode::BAD_REQUEST, |
| 388 | "intensity-out-of-range", |
| 389 | "Intensity is measured from 1 to 10.", |
| 390 | ) |
| 391 | .into_response(); |
| 392 | } |
| 393 | }; |
| 394 | let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) { |
| 395 | Ok(lube) => lube, |
| 396 | Err(problem) => return problem.into_response(), |
| 397 | }; |
| 398 | respond(run.jelq(technique, intensity, lube, now)) |
| 399 | } |
| 400 | |
| 401 | fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response { |
| 402 | let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default(); |
| 403 | if !content_type.starts_with("application/json-patch+json") { |
| 404 | return Problem::new( |
| 405 | StatusCode::UNSUPPORTED_MEDIA_TYPE, |
| 406 | "patch-format", |
| 407 | "Patches are accepted as application/json-patch+json.", |
| 408 | ) |
| 409 | .header( |
| 410 | HeaderName::from_static("accept-patch"), |
| 411 | "application/json-patch+json", |
| 412 | ) |
| 413 | .into_response(); |
| 414 | } |
| 415 | match serde_json::from_slice::<Vec<PatchOp>>(body) { |
| 416 | Ok(ops) => respond(run.patch(&ops)), |
| 417 | Err(_) => Problem::new( |
| 418 | StatusCode::BAD_REQUEST, |
| 419 | "malformed-patch", |
| 420 | "The patch could not be parsed.", |
| 421 | ) |
| 422 | .into_response(), |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | async fn length( |
| 427 | State(state): State<Shared>, |
| 428 | Path(id): Path<String>, |
| 429 | method: Method, |
| 430 | headers: HeaderMap, |
| 431 | ) -> Response { |
| 432 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 433 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 434 | "GET" | "HEAD" => { |
| 435 | let content_type = [(header::CONTENT_TYPE, "text/plain; charset=us-ascii")]; |
| 436 | if run.ruler { |
| 437 | let mm = (run.length * MM_PER_INCH).round() as usize; |
| 438 | return (StatusCode::OK, content_type, shaft(mm)).into_response(); |
| 439 | } |
| 440 | let mm = (run.self_reported() * MM_PER_INCH).round() as usize; |
| 441 | let chunks: Vec<Result<Bytes, Infallible>> = shaft(mm) |
| 442 | .into_bytes() |
| 443 | .chunks(16) |
| 444 | .map(|c| Ok(Bytes::copy_from_slice(c))) |
| 445 | .collect(); |
| 446 | let body = Body::from_stream(futures_util::stream::iter(chunks)); |
| 447 | (StatusCode::OK, content_type, body).into_response() |
| 448 | } |
| 449 | "OPTIONS" => options(ALLOW, false), |
| 450 | _ => unsupported(&method, ALLOW), |
| 451 | }) |
| 452 | } |
| 453 | |
| 454 | async fn foreskin( |
| 455 | State(state): State<Shared>, |
| 456 | Path(id): Path<String>, |
| 457 | method: Method, |
| 458 | headers: HeaderMap, |
| 459 | ) -> Response { |
| 460 | const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS"; |
| 461 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 462 | "GET" | "HEAD" => match run.foreskin { |
| 463 | Some(f) => ( |
| 464 | StatusCode::OK, |
| 465 | [(header::ETAG, run.etag())], |
| 466 | Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })), |
| 467 | ) |
| 468 | .into_response(), |
| 469 | None => Problem::new( |
| 470 | StatusCode::GONE, |
| 471 | "foreskin-gone", |
| 472 | "The foreskin is gone. It will not be returning.", |
| 473 | ) |
| 474 | .into_response(), |
| 475 | }, |
| 476 | "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))), |
| 477 | "OPTIONS" => options(ALLOW, false), |
| 478 | _ => unsupported(&method, ALLOW), |
| 479 | }) |
| 480 | } |
| 481 | |
| 482 | fn items(run: &Run) -> Vec<(&'static str, &'static str)> { |
| 483 | let mut items = vec![( |
| 484 | "warm-towel", |
| 485 | "Reusable. Satisfies the warm-up precondition. Use technique=warmup.", |
| 486 | )]; |
| 487 | if run.ice_packs > 0 { |
| 488 | items.push(( |
| 489 | "ice-pack", |
| 490 | "Lowers EQ by 40. Use technique=ice. There is one.", |
| 491 | )); |
| 492 | } |
| 493 | if run.ruler { |
| 494 | items.push(( |
| 495 | "ruler", |
| 496 | "Measures honestly. The length resource now carries a Content-Length.", |
| 497 | )); |
| 498 | } |
| 499 | if run.teapot { |
| 500 | items.push(("teapot-spout", "Does nothing. Cannot be dropped.")); |
| 501 | } |
| 502 | items |
| 503 | } |
| 504 | |
| 505 | async fn inventory( |
| 506 | State(state): State<Shared>, |
| 507 | Path(id): Path<String>, |
| 508 | method: Method, |
| 509 | headers: HeaderMap, |
| 510 | ) -> Response { |
| 511 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 512 | with_run(&state, &id, &headers, false, |run| match method.as_str() { |
| 513 | "GET" | "HEAD" => { |
| 514 | let listed: Vec<Value> = items(run) |
| 515 | .into_iter() |
| 516 | .map(|(name, _)| json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) })) |
| 517 | .collect(); |
| 518 | Json(json!({ "items": listed, "excuses": EXCUSES })).into_response() |
| 519 | } |
| 520 | "OPTIONS" => options(ALLOW, false), |
| 521 | _ => unsupported(&method, ALLOW), |
| 522 | }) |
| 523 | } |
| 524 | |
| 525 | async fn item( |
| 526 | State(state): State<Shared>, |
| 527 | Path((id, name)): Path<(String, String)>, |
| 528 | method: Method, |
| 529 | headers: HeaderMap, |
| 530 | ) -> Response { |
| 531 | const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS"; |
| 532 | with_run(&state, &id, &headers, false, |run| { |
| 533 | let Some((name, description)) = items(run).into_iter().find(|(n, _)| *n == name) else { |
| 534 | return Problem::new( |
| 535 | StatusCode::NOT_FOUND, |
| 536 | "no-such-item", |
| 537 | "You do not have that.", |
| 538 | ) |
| 539 | .into_response(); |
| 540 | }; |
| 541 | match method.as_str() { |
| 542 | "GET" | "HEAD" => { |
| 543 | Json(json!({ "name": name, "description": description })).into_response() |
| 544 | } |
| 545 | "DELETE" => Problem::new( |
| 546 | StatusCode::FORBIDDEN, |
| 547 | "cannot-be-dropped", |
| 548 | "Items cannot be dropped.", |
| 549 | ) |
| 550 | .into_response(), |
| 551 | "OPTIONS" => options(ALLOW, false), |
| 552 | _ => unsupported(&method, ALLOW), |
| 553 | } |
| 554 | }) |
| 555 | } |
| 556 | |
| 557 | async fn techniques( |
| 558 | State(state): State<Shared>, |
| 559 | Path(id): Path<String>, |
| 560 | method: Method, |
| 561 | headers: HeaderMap, |
| 562 | ) -> Response { |
| 563 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 564 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 565 | "GET" | "HEAD" => { |
| 566 | let listed: Vec<Value> = Technique::ALL |
| 567 | .into_iter() |
| 568 | .map(|t| t.describe(run.forum_read)) |
| 569 | .collect(); |
| 570 | Json(json!({ |
| 571 | "usage": format!("JELQ /runs/{}?technique=<name>&intensity=<1-10>", run.id), |
| 572 | "lubricant": "Declared with Content-Type.", |
| 573 | "lubricants": LUBRICANTS, |
| 574 | "eq_band": [50, 70], |
| 575 | "techniques": listed, |
| 576 | })) |
| 577 | .into_response() |
| 578 | } |
| 579 | "OPTIONS" => options(ALLOW, false), |
| 580 | _ => unsupported(&method, ALLOW), |
| 581 | }) |
| 582 | } |
| 583 | |
| 584 | async fn drawer( |
| 585 | State(state): State<Shared>, |
| 586 | Path(id): Path<String>, |
| 587 | method: Method, |
| 588 | headers: HeaderMap, |
| 589 | ) -> Response { |
| 590 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 591 | "POST" => run.search_drawer().into_response(), |
| 592 | "OPTIONS" => options("POST, OPTIONS", false), |
| 593 | _ => unsupported(&method, "POST, OPTIONS"), |
| 594 | }) |
| 595 | } |
| 596 | |
| 597 | async fn branch( |
| 598 | State(state): State<Shared>, |
| 599 | Path((id, choice)): Path<(String, String)>, |
| 600 | method: Method, |
| 601 | headers: HeaderMap, |
| 602 | ) -> Response { |
| 603 | with_run(&state, &id, &headers, true, |run| match method.as_str() { |
| 604 | "POST" => respond(run.choose(&choice)), |
| 605 | "OPTIONS" => options("POST, OPTIONS", false), |
| 606 | _ => unsupported(&method, "POST, OPTIONS"), |
| 607 | }) |
| 608 | } |
| 609 | |
| 610 | async fn diagnosis( |
| 611 | State(state): State<Shared>, |
| 612 | Path(id): Path<String>, |
| 613 | method: Method, |
| 614 | headers: HeaderMap, |
| 615 | ) -> Response { |
| 616 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 617 | with_run(&state, &id, &headers, false, |run| match method.as_str() { |
| 618 | "GET" | "HEAD" => respond(run.diagnose( |
| 619 | header_str(&headers, header::AUTHORIZATION), |
| 620 | header_str(&headers, header::IF_NONE_MATCH), |
| 621 | )), |
| 622 | "OPTIONS" => options(ALLOW, false), |
| 623 | _ => unsupported(&method, ALLOW), |
| 624 | }) |
| 625 | } |
| 626 | |
| 627 | async fn graveyard(State(state): State<Shared>, method: Method) -> Response { |
| 628 | if method != Method::GET && method != Method::HEAD { |
| 629 | return unsupported(&method, "GET, HEAD"); |
| 630 | } |
| 631 | let runs = state.runs.lock().unwrap(); |
| 632 | let dead: Vec<Value> = runs |
| 633 | .values() |
| 634 | .filter_map(|run| { |
| 635 | let ending = run.ended.as_ref()?; |
| 636 | Some(json!({ |
| 637 | "id": run.id, |
| 638 | "cause": format!("{PROBLEM_BASE}{}", ending.cause), |
| 639 | "detail": ending.detail, |
| 640 | "turn": ending.turn, |
| 641 | })) |
| 642 | }) |
| 643 | .collect(); |
| 644 | Json(json!({ "runs": dead })).into_response() |
| 645 | } |
| 646 | |
| 647 | async fn leaderboard( |
| 648 | State(state): State<Shared>, |
| 649 | method: Method, |
| 650 | Query(query): Query<HashMap<String, String>>, |
| 651 | headers: HeaderMap, |
| 652 | ) -> Response { |
| 653 | if method != Method::GET && method != Method::HEAD { |
| 654 | return unsupported(&method, "GET, HEAD"); |
| 655 | } |
| 656 | let truthful = match query.get("sort").map(String::as_str) { |
| 657 | None | Some("self-reported-length") => false, |
| 658 | Some("content-length") if cookie(&headers, "ruler") == Some("1") => true, |
| 659 | Some("content-length") => { |
| 660 | return Problem::new( |
| 661 | StatusCode::FORBIDDEN, |
| 662 | "ruler-required", |
| 663 | "Sorting by the truth requires a Ruler.", |
| 664 | ) |
| 665 | .into_response(); |
| 666 | } |
| 667 | Some(_) => { |
| 668 | return Problem::new( |
| 669 | StatusCode::BAD_REQUEST, |
| 670 | "unknown-sort", |
| 671 | "The leaderboard sorts by self-reported-length or content-length.", |
| 672 | ) |
| 673 | .into_response(); |
| 674 | } |
| 675 | }; |
| 676 | let runs = state.runs.lock().unwrap(); |
| 677 | let mut entries: Vec<&Run> = runs.values().collect(); |
| 678 | entries.sort_by(|a, b| { |
| 679 | let key = |r: &Run| { |
| 680 | if truthful { |
| 681 | r.length |
| 682 | } else { |
| 683 | r.self_reported() |
| 684 | } |
| 685 | }; |
| 686 | key(b).total_cmp(&key(a)) |
| 687 | }); |
| 688 | let entries: Vec<Value> = entries |
| 689 | .into_iter() |
| 690 | .map(|run| { |
| 691 | let mut entry = json!({ |
| 692 | "id": run.id, |
| 693 | "status": run.status(), |
| 694 | "self_reported_length": (run.self_reported() * 100.0).round() / 100.0, |
| 695 | }); |
| 696 | if truthful { |
| 697 | entry["content_length"] = json!((run.length * MM_PER_INCH).round() as u64); |
| 698 | } |
| 699 | entry |
| 700 | }) |
| 701 | .collect(); |
| 702 | Json(json!({ "entries": entries })).into_response() |
| 703 | } |
| 704 | |
| 705 | async fn not_found() -> Problem { |
| 706 | Problem::new( |
| 707 | StatusCode::NOT_FOUND, |
| 708 | "not-found", |
| 709 | "The resource does not exist. This is not about you.", |
| 710 | ) |
| 711 | } |