an http based roguelike rpg about jelqing
| 1 | //! Plumbing shared by every resource under `/runs/{id}`. |
| 2 | |
| 3 | use axum::{ |
| 4 | http::{HeaderName, HeaderValue, StatusCode, header}, |
| 5 | response::{IntoResponse, Response}, |
| 6 | }; |
| 7 | use hyper_util::rt::TokioIo; |
| 8 | use tokio::io::AsyncWriteExt; |
| 9 | |
| 10 | use super::{ |
| 11 | AppState, ctx::Ctx, links, middleware::Compress, registry::Lock, resource::Resource, shame, |
| 12 | }; |
| 13 | use crate::{ |
| 14 | game::{ |
| 15 | Cause, Gauges, Run, Shown, |
| 16 | balance::{FRICTION_MAX, SUSPICION_LIMIT}, |
| 17 | }, |
| 18 | headers::{ |
| 19 | X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GAINS_ARRIVED, X_GIRTH, X_LENGTH, |
| 20 | X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION, |
| 21 | }, |
| 22 | locale::Locale, |
| 23 | problem::{PROBLEM_BASE, Problem}, |
| 24 | records::Record, |
| 25 | secret::Secret, |
| 26 | }; |
| 27 | |
| 28 | /// Which household hazards stand between a request and the run. |
| 29 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 30 | pub enum Scope { |
| 31 | Home, |
| 32 | Pockets, |
| 33 | } |
| 34 | |
| 35 | pub struct RunResource { |
| 36 | pub scope: Scope, |
| 37 | pub resource: Resource<Run>, |
| 38 | } |
| 39 | |
| 40 | /// What the server writes over the connection after a `101`. |
| 41 | #[derive(Clone)] |
| 42 | pub struct Greeting(pub String); |
| 43 | |
| 44 | pub fn records_unavailable(error: &rusqlite::Error) -> Response { |
| 45 | Problem::new( |
| 46 | StatusCode::SERVICE_UNAVAILABLE, |
| 47 | "records-unavailable", |
| 48 | "The records are unavailable. Nothing has been forgotten, it is only out of reach.", |
| 49 | ) |
| 50 | .with("error", error.to_string()) |
| 51 | .into_response() |
| 52 | } |
| 53 | |
| 54 | pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response { |
| 55 | let id = ctx.param("id").to_owned(); |
| 56 | let Some(slot) = state.runs.get(&id) else { |
| 57 | return match state.records.find(&id) { |
| 58 | Ok(Some(Record { |
| 59 | cause: Some(cause), |
| 60 | turn, |
| 61 | .. |
| 62 | })) => gone(cause.slug(), turn), |
| 63 | Ok(_) => Problem::new( |
| 64 | StatusCode::NOT_FOUND, |
| 65 | "no-such-run", |
| 66 | "There is no such run. There may never have been.", |
| 67 | ) |
| 68 | .into_response(), |
| 69 | Err(error) => records_unavailable(&error), |
| 70 | }; |
| 71 | }; |
| 72 | let (response, record) = match Lock::take(&slot) { |
| 73 | Lock::Poisoned(mut run) => { |
| 74 | run.lose(); |
| 75 | let response = gone(Cause::Lost.slug(), run.turn); |
| 76 | (response, Some(Record::of(&run))) |
| 77 | } |
| 78 | Lock::Held(mut run) => { |
| 79 | if let Some(ending) = &run.ended { |
| 80 | return gone(ending.cause.slug(), ending.turn); |
| 81 | } |
| 82 | run.seen(ctx.now()); |
| 83 | let mut response = gate(&mut run, &ctx, target.scope) |
| 84 | .unwrap_or_else(|| target.resource.dispatch(&mut run, &ctx)); |
| 85 | if let Some(ending) = &run.ended { |
| 86 | response |
| 87 | .headers_mut() |
| 88 | .insert(X_RUN_OVER, HeaderValue::from_static(ending.cause.slug())); |
| 89 | } |
| 90 | let response = finish(&mut run, &ctx, &state.config.secret, response); |
| 91 | let record = run.status().finished().then(|| Record::of(&run)); |
| 92 | (response, record) |
| 93 | } |
| 94 | }; |
| 95 | if let Some(record) = record { |
| 96 | if let Err(error) = state.records.save(&record) { |
| 97 | return records_unavailable(&error); |
| 98 | } |
| 99 | if record.cause.is_some() { |
| 100 | state.runs.remove(&id); |
| 101 | } |
| 102 | } |
| 103 | greet(response, &mut ctx) |
| 104 | } |
| 105 | |
| 106 | fn gone(cause: &str, turn: u32) -> Response { |
| 107 | Problem::new(StatusCode::GONE, "run-ended", "This run has ended.") |
| 108 | .with("cause", format!("{PROBLEM_BASE}{cause}")) |
| 109 | .with("turn", turn) |
| 110 | .with("graveyard", "/graveyard") |
| 111 | .header(X_RUN_OVER, cause) |
| 112 | .into_response() |
| 113 | } |
| 114 | |
| 115 | fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> { |
| 116 | if scope == Scope::Pockets { |
| 117 | return None; |
| 118 | } |
| 119 | if run.needs_portal() { |
| 120 | let portal = run.path("/portal"); |
| 121 | return Some( |
| 122 | Problem::new( |
| 123 | StatusCode::NETWORK_AUTHENTICATION_REQUIRED, |
| 124 | "captive-portal", |
| 125 | "The network requires authentication. The hotel would like you to agree to some terms.", |
| 126 | ) |
| 127 | .with("portal", portal.as_str()) |
| 128 | .header( |
| 129 | header::LINK, |
| 130 | format!("<{portal}>; rel=\"{}portal\"", links::REL_BASE), |
| 131 | ) |
| 132 | .into_response(), |
| 133 | ); |
| 134 | } |
| 135 | let authorization = ctx |
| 136 | .header(header::AUTHORIZATION) |
| 137 | .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text)); |
| 138 | run.answer_door(authorization.as_deref()) |
| 139 | .err() |
| 140 | .map(IntoResponse::into_response) |
| 141 | } |
| 142 | |
| 143 | fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> { |
| 144 | let mut stats = vec![ |
| 145 | match gauges.length { |
| 146 | Shown::Measured(length) => (X_LENGTH, format!("{length:.2}")), |
| 147 | Shown::SelfReported(length) => (X_SELF_REPORTED_LENGTH, format!("{length:.2}")), |
| 148 | }, |
| 149 | (X_GIRTH, format!("{:.2}", gauges.girth)), |
| 150 | ( |
| 151 | X_FORESKIN, |
| 152 | gauges.foreskin.map_or("gone".into(), |f| format!("{f:.2}")), |
| 153 | ), |
| 154 | (X_FRICTION, format!("{}/{FRICTION_MAX}", gauges.friction)), |
| 155 | (X_EQ, gauges.arousal.name().into()), |
| 156 | ( |
| 157 | X_STAMINA, |
| 158 | format!("{}/{}", gauges.stamina, gauges.max_stamina), |
| 159 | ), |
| 160 | ( |
| 161 | X_SUSPICION, |
| 162 | format!("{}/{SUSPICION_LIMIT}", gauges.suspicion), |
| 163 | ), |
| 164 | (X_SHAME, shame.to_string()), |
| 165 | (X_CASH, gauges.cash.to_string()), |
| 166 | (X_FLOOR, gauges.floor.to_string()), |
| 167 | ]; |
| 168 | if let Some(observer) = gauges.observed { |
| 169 | stats.push((X_OBSERVED_BY, observer.name().into())); |
| 170 | } |
| 171 | if let Some(challenge) = &gauges.mum_challenge { |
| 172 | stats.push((header::VIA, "1.1 mum".into())); |
| 173 | stats.push((header::VARY, "Mum".into())); |
| 174 | stats.push((header::WWW_AUTHENTICATE, challenge.clone())); |
| 175 | } |
| 176 | if let Some(arrived) = &gauges.arrived { |
| 177 | let mut gains = format!("length={:.3}, girth={:.3}", arrived.length, arrived.girth); |
| 178 | if let Some(foreskin) = arrived.foreskin { |
| 179 | gains.push_str(&format!(", foreskin={foreskin:.3}")); |
| 180 | } |
| 181 | stats.push((X_GAINS_ARRIVED, gains)); |
| 182 | } |
| 183 | // RFC 9111 obsoleted Warning, which is why nobody heeds it. |
| 184 | for warning in &gauges.warnings { |
| 185 | stats.push((header::WARNING, format!("199 - \"{warning}\""))); |
| 186 | } |
| 187 | stats |
| 188 | } |
| 189 | |
| 190 | pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response { |
| 191 | let status = response.status(); |
| 192 | if status == StatusCode::NOT_MODIFIED { |
| 193 | return response; |
| 194 | } |
| 195 | let (shame, shame_cookie) = shame::settle(run, ctx, secret); |
| 196 | let gauges = run.gauges(); |
| 197 | let link = links::header(run); |
| 198 | let headers = response.headers_mut(); |
| 199 | for (name, value) in stats(&gauges, shame) { |
| 200 | if let Ok(value) = HeaderValue::from_str(&value) { |
| 201 | headers.append(name, value); |
| 202 | } |
| 203 | } |
| 204 | if let Ok(link) = HeaderValue::from_str(&link) { |
| 205 | headers.append(header::LINK, link); |
| 206 | } |
| 207 | headers.append(header::SET_COOKIE, shame_cookie); |
| 208 | if gauges.ruler { |
| 209 | headers.append(header::SET_COOKIE, shame::ruler_cookie(secret)); |
| 210 | } |
| 211 | let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT; |
| 212 | if gauges.ring && !bodiless { |
| 213 | response.extensions_mut().insert(Compress); |
| 214 | } |
| 215 | response |
| 216 | } |
| 217 | |
| 218 | fn greet(response: Response, ctx: &mut Ctx) -> Response { |
| 219 | if response.status() != StatusCode::SWITCHING_PROTOCOLS { |
| 220 | return response; |
| 221 | } |
| 222 | let (Some(Greeting(text)), Some(upgrade)) = ( |
| 223 | response.extensions().get::<Greeting>().cloned(), |
| 224 | ctx.take_upgrade(), |
| 225 | ) else { |
| 226 | return response; |
| 227 | }; |
| 228 | tokio::spawn(async move { |
| 229 | if let Ok(upgraded) = upgrade.await { |
| 230 | let mut io = TokioIo::new(upgraded); |
| 231 | let _ = io.write_all(text.as_bytes()).await; |
| 232 | let _ = io.shutdown().await; |
| 233 | } |
| 234 | }); |
| 235 | response |
| 236 | } |