an http based roguelike rpg about jelqing
| 1 | //! Plumbing shared by every resource under `/runs/{id}`. |
| 2 | |
| 3 | use std::time::Instant; |
| 4 | |
| 5 | use axum::{ |
| 6 | http::{HeaderName, HeaderValue, StatusCode, header}, |
| 7 | response::{IntoResponse, Response}, |
| 8 | }; |
| 9 | use hyper_util::rt::TokioIo; |
| 10 | use tokio::io::AsyncWriteExt; |
| 11 | |
| 12 | use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs}; |
| 13 | use crate::{ |
| 14 | game::{ |
| 15 | Elder, FORUM_THREAD, Floor, Run, SHOP, Story, |
| 16 | balance::{FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT}, |
| 17 | }, |
| 18 | headers::{ |
| 19 | X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY, |
| 20 | X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION, |
| 21 | }, |
| 22 | locale::Locale, |
| 23 | problem::{PROBLEM_BASE, Problem}, |
| 24 | secret::Secret, |
| 25 | }; |
| 26 | |
| 27 | pub const REL_BASE: &str = "https://jelq.dev/rels/"; |
| 28 | const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict"; |
| 29 | /// Which household hazards stand between a request and the run. |
| 30 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 31 | pub enum Scope { |
| 32 | Home, |
| 33 | Pockets, |
| 34 | } |
| 35 | |
| 36 | pub struct RunResource { |
| 37 | pub scope: Scope, |
| 38 | pub resource: Resource<Run>, |
| 39 | } |
| 40 | |
| 41 | /// What the server writes over the connection after a `101`. |
| 42 | #[derive(Clone)] |
| 43 | pub struct Greeting(pub String); |
| 44 | |
| 45 | pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response { |
| 46 | let mut runs = state.runs(); |
| 47 | let Some(run) = runs.get_mut(ctx.param("id")) else { |
| 48 | return Problem::new( |
| 49 | StatusCode::NOT_FOUND, |
| 50 | "no-such-run", |
| 51 | "There is no such run. There may never have been.", |
| 52 | ) |
| 53 | .into_response(); |
| 54 | }; |
| 55 | if let Some(ending) = &run.ended { |
| 56 | return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.") |
| 57 | .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug())) |
| 58 | .with("turn", ending.turn) |
| 59 | .with("graveyard", "/graveyard") |
| 60 | .into_response(); |
| 61 | } |
| 62 | run.last_seen = ctx.now; |
| 63 | run.recover(ctx.now); |
| 64 | run.observed = None; |
| 65 | let response = |
| 66 | gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx)); |
| 67 | let response = finish(run, &ctx, &state.config.secret, response); |
| 68 | drop(runs); |
| 69 | greet(response, &mut ctx) |
| 70 | } |
| 71 | |
| 72 | fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> { |
| 73 | if scope == Scope::Pockets { |
| 74 | return None; |
| 75 | } |
| 76 | if run.floor == Floor::Hotel && !run.here.wifi_accepted { |
| 77 | let portal = run.path("/portal"); |
| 78 | return Some( |
| 79 | Problem::new( |
| 80 | StatusCode::NETWORK_AUTHENTICATION_REQUIRED, |
| 81 | "captive-portal", |
| 82 | "The network requires authentication. The hotel would like you to agree to some terms.", |
| 83 | ) |
| 84 | .with("portal", portal.as_str()) |
| 85 | .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\"")) |
| 86 | .into_response(), |
| 87 | ); |
| 88 | } |
| 89 | let authorization = ctx |
| 90 | .header(header::AUTHORIZATION) |
| 91 | .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text)); |
| 92 | run.answer_door(authorization.as_deref()) |
| 93 | .err() |
| 94 | .map(IntoResponse::into_response) |
| 95 | } |
| 96 | |
| 97 | /// Every resource the run can reach right now, then every technique a JELQ could use. The `allow` |
| 98 | /// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses. |
| 99 | fn links(run: &Run, now: Instant) -> String { |
| 100 | let thread_reachable = run.thread_reachable(); |
| 101 | let linked: [(&str, &RunResource, bool); 15] = [ |
| 102 | ("", &runs::RUN, true), |
| 103 | ("floor", &runs::FLOOR, true), |
| 104 | ("length", &runs::LENGTH, true), |
| 105 | ("foreskin", &runs::FORESKIN, run.foreskin.is_some()), |
| 106 | ("inventory", &runs::INVENTORY, true), |
| 107 | ("techniques", &runs::TECHNIQUES, true), |
| 108 | ("drawer", &runs::DRAWER, run.drawer_worth_searching()), |
| 109 | ("kitchen", &runs::KITCHEN, { |
| 110 | run.floor.has_kitchen() && run.kitchen_tellings < KITCHEN_TELLINGS |
| 111 | }), |
| 112 | ("re-read", &runs::REREAD, thread_reachable), |
| 113 | ("grip", &runs::GRIP, true), |
| 114 | ("shop", &runs::SHOP, run.floor.shop_open()), |
| 115 | ( |
| 116 | "portal", |
| 117 | &runs::PORTAL, |
| 118 | run.floor == Floor::Hotel && !run.here.wifi_accepted, |
| 119 | ), |
| 120 | ( |
| 121 | "elder", |
| 122 | &runs::ELDER, |
| 123 | run.floor == Floor::Forum && run.elder != Elder::Beaten, |
| 124 | ), |
| 125 | ("footage", &runs::FOOTAGE, run.filmed_at.is_some()), |
| 126 | ("diagnosis", &runs::DIAGNOSIS, run.floor == Floor::Clinic), |
| 127 | ]; |
| 128 | linked |
| 129 | .into_iter() |
| 130 | .filter(|(_, _, relevant)| *relevant) |
| 131 | .map(|(rel, target, _)| { |
| 132 | let (path, name) = if rel.is_empty() { |
| 133 | (run.path(""), "run") |
| 134 | } else { |
| 135 | (run.path(&format!("/{rel}")), rel) |
| 136 | }; |
| 137 | let allow = target.resource.allow(run); |
| 138 | format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"") |
| 139 | }) |
| 140 | .chain(thread_reachable.then(|| { |
| 141 | format!( |
| 142 | "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"", |
| 143 | meta::THREAD.allow_any() |
| 144 | ) |
| 145 | }),) |
| 146 | .chain((run.story == Story::Pending).then(|| { |
| 147 | let choice = |slug: &str, title: &str| { |
| 148 | format!( |
| 149 | "<{}>; rel=\"{REL_BASE}branch\"; title=\"{title}\"; allow=\"POST\"", |
| 150 | run.path(&format!("/branches/{slug}")) |
| 151 | ) |
| 152 | }; |
| 153 | [choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ") |
| 154 | })) |
| 155 | .chain(run.techniques_now(now).into_iter().map(|technique| { |
| 156 | let name = technique.name(); |
| 157 | let lubricant = technique |
| 158 | .lubricant() |
| 159 | .map_or(String::new(), |rule| format!("; lubricant=\"{rule}\"")); |
| 160 | format!( |
| 161 | "<{}?technique={name}>; rel=\"{REL_BASE}technique\"; title=\"{name}\"; allow=\"JELQ\"{lubricant}", |
| 162 | run.path("") |
| 163 | ) |
| 164 | })) |
| 165 | .chain(run.here.shopping.then(|| { |
| 166 | SHOP.iter() |
| 167 | .filter_map(|item| { |
| 168 | let price = item.price()?; |
| 169 | Some(format!( |
| 170 | "<{}>; rel=\"{REL_BASE}shop-item\"; title=\"{} for {price}\"; allow=\"POST\"", |
| 171 | run.path(&format!("/shop/{}", item.slug())), |
| 172 | item.slug() |
| 173 | )) |
| 174 | }) |
| 175 | .collect::<Vec<_>>() |
| 176 | .join(", ") |
| 177 | })) |
| 178 | .collect::<Vec<_>>() |
| 179 | .join(", ") |
| 180 | } |
| 181 | |
| 182 | /// Shame lives in a signed cookie so it follows the player across runs. Each response adds this |
| 183 | /// run's new shame to whatever total the client presents, so two runs at once both count. |
| 184 | fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 { |
| 185 | let presented = ctx |
| 186 | .cookie("shame") |
| 187 | .and_then(|signed| secret.verify("shame", signed)) |
| 188 | .and_then(|total| total.parse::<u32>().ok()); |
| 189 | let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported); |
| 190 | run.shame_reported = run.shame; |
| 191 | run.shame_total = total; |
| 192 | total |
| 193 | } |
| 194 | |
| 195 | pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response { |
| 196 | let status = response.status(); |
| 197 | if status == StatusCode::NOT_MODIFIED { |
| 198 | return response; |
| 199 | } |
| 200 | let shame = settle_shame(run, ctx, secret); |
| 201 | let mut stats: Vec<(HeaderName, String)> = vec![ |
| 202 | if run.items.ruler { |
| 203 | (X_LENGTH, format!("{:.2}", run.current_length())) |
| 204 | } else { |
| 205 | ( |
| 206 | X_SELF_REPORTED_LENGTH, |
| 207 | format!("{:.2}", run.self_reported()), |
| 208 | ) |
| 209 | }, |
| 210 | (X_GIRTH, format!("{:.2}", run.girth)), |
| 211 | ( |
| 212 | X_FORESKIN, |
| 213 | run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")), |
| 214 | ), |
| 215 | (X_FRICTION, format!("{}/{}", run.friction, FRICTION_MAX)), |
| 216 | (X_EQ, run.eq.to_string()), |
| 217 | (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())), |
| 218 | (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)), |
| 219 | (X_SHAME, shame.to_string()), |
| 220 | (X_CASH, run.cash.to_string()), |
| 221 | (X_FLOOR, run.floor.number().to_string()), |
| 222 | (header::LINK, links(run, ctx.now)), |
| 223 | ]; |
| 224 | if let Some(observer) = run.observed { |
| 225 | stats.push((X_OBSERVED_BY, observer.name().into())); |
| 226 | } |
| 227 | if run.mum.is_some() { |
| 228 | stats.push((header::VIA, "1.1 mum".into())); |
| 229 | stats.push((header::VARY, "Mum".into())); |
| 230 | } |
| 231 | // RFC 9111 obsoleted Warning, which is why nobody heeds it. |
| 232 | if run.conditions.pulled_muscle { |
| 233 | stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into())); |
| 234 | } |
| 235 | if run.conditions.friction_burn { |
| 236 | stats.push((header::WARNING, "199 - \"friction burn\"".into())); |
| 237 | } |
| 238 | for notice in run.notices.drain(..) { |
| 239 | stats.push((header::WARNING, format!("199 - \"{notice}\""))); |
| 240 | } |
| 241 | if run.eq > 70 && run.ended.is_none() { |
| 242 | stats.push((header::WARNING, "199 - \"it is turning purple\"".into())); |
| 243 | } |
| 244 | stats.push(( |
| 245 | header::SET_COOKIE, |
| 246 | format!( |
| 247 | "shame={}; {COOKIE_ATTRIBUTES}", |
| 248 | secret.sign("shame", &shame.to_string()) |
| 249 | ), |
| 250 | )); |
| 251 | if run.items.ruler { |
| 252 | stats.push(( |
| 253 | header::SET_COOKIE, |
| 254 | format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")), |
| 255 | )); |
| 256 | } |
| 257 | let headers = response.headers_mut(); |
| 258 | for (name, value) in stats { |
| 259 | if let Ok(value) = HeaderValue::from_str(&value) { |
| 260 | headers.append(name, value); |
| 261 | } |
| 262 | } |
| 263 | let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT; |
| 264 | if run.items.ring && !bodiless { |
| 265 | response.extensions_mut().insert(Compress); |
| 266 | } |
| 267 | response |
| 268 | } |
| 269 | |
| 270 | fn greet(response: Response, ctx: &mut Ctx) -> Response { |
| 271 | if response.status() != StatusCode::SWITCHING_PROTOCOLS { |
| 272 | return response; |
| 273 | } |
| 274 | let (Some(Greeting(text)), Some(upgrade)) = ( |
| 275 | response.extensions().get::<Greeting>().cloned(), |
| 276 | ctx.upgrade.take(), |
| 277 | ) else { |
| 278 | return response; |
| 279 | }; |
| 280 | tokio::spawn(async move { |
| 281 | if let Ok(upgraded) = upgrade.await { |
| 282 | let mut io = TokioIo::new(upgraded); |
| 283 | let _ = io.write_all(text.as_bytes()).await; |
| 284 | let _ = io.shutdown().await; |
| 285 | } |
| 286 | }); |
| 287 | response |
| 288 | } |