an http based roguelike rpg about jelqing
| 1 | mod catalog; |
| 2 | mod encounters; |
| 3 | mod jelq; |
| 4 | |
| 5 | pub use catalog::{LUBRICANTS, Lube, SHOP, ShopItem, Technique}; |
| 6 | pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread}; |
| 7 | |
| 8 | use std::{ |
| 9 | sync::LazyLock, |
| 10 | time::{Duration, Instant, SystemTime}, |
| 11 | }; |
| 12 | |
| 13 | use axum::{ |
| 14 | Json, |
| 15 | http::{HeaderName, HeaderValue, StatusCode, header}, |
| 16 | response::{IntoResponse, Response}, |
| 17 | }; |
| 18 | use rand::{Rng, SeedableRng, rngs::StdRng}; |
| 19 | use serde_json::{Value, json}; |
| 20 | |
| 21 | use crate::problem::Problem; |
| 22 | |
| 23 | pub const MAX_STAMINA: i32 = 10; |
| 24 | pub const SUSPICION_LIMIT: u8 = 5; |
| 25 | pub const BRANCH_TURN: u32 = 10; |
| 26 | pub const FLOOR_TURNS: u32 = 6; |
| 27 | pub const STARTING_CASH: u32 = 20; |
| 28 | pub const FLOOR_ALLOWANCE: u32 = 10; |
| 29 | pub static REFRACTORY: LazyLock<Duration> = LazyLock::new(|| { |
| 30 | let seconds = std::env::var("JELQD_REFRACTORY_SECS") |
| 31 | .ok() |
| 32 | .and_then(|s| s.parse().ok()) |
| 33 | .unwrap_or(30); |
| 34 | Duration::from_secs(seconds) |
| 35 | }); |
| 36 | pub const SELF_REPORT_INFLATION: f64 = 1.5; |
| 37 | pub const MM_PER_INCH: f64 = 25.4; |
| 38 | pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE"; |
| 39 | pub const CLINIC_ALLOW: &str = "GET, HEAD, PUT, PATCH, DELETE, OPTIONS, TRACE"; |
| 40 | |
| 41 | pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing"); |
| 42 | pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis"); |
| 43 | |
| 44 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 45 | pub enum Floor { |
| 46 | Bedroom, |
| 47 | Bathroom, |
| 48 | LockerRoom, |
| 49 | Grandmas, |
| 50 | Hotel, |
| 51 | Forum, |
| 52 | Clinic, |
| 53 | } |
| 54 | |
| 55 | impl Floor { |
| 56 | const ALL: [Self; 7] = [ |
| 57 | Self::Bedroom, |
| 58 | Self::Bathroom, |
| 59 | Self::LockerRoom, |
| 60 | Self::Grandmas, |
| 61 | Self::Hotel, |
| 62 | Self::Forum, |
| 63 | Self::Clinic, |
| 64 | ]; |
| 65 | |
| 66 | pub fn number(self) -> usize { |
| 67 | Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + 1 |
| 68 | } |
| 69 | |
| 70 | fn next(self) -> Option<Self> { |
| 71 | Self::ALL.get(self.number()).copied() |
| 72 | } |
| 73 | |
| 74 | pub fn name(self) -> &'static str { |
| 75 | match self { |
| 76 | Self::Bedroom => "the bedroom", |
| 77 | Self::Bathroom => "the shared bathroom", |
| 78 | Self::LockerRoom => "the gym locker room", |
| 79 | Self::Grandmas => "grandma's house for Thanksgiving", |
| 80 | Self::Hotel => "a business trip hotel", |
| 81 | Self::Forum => "the forum", |
| 82 | Self::Clinic => "the urologist's office", |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | pub fn note(self) -> &'static str { |
| 87 | match self { |
| 88 | Self::Bedroom => "The door does not lock. The cat is present.", |
| 89 | Self::Bathroom => "The water is hot. The roommate showers at eight.", |
| 90 | Self::LockerRoom => { |
| 91 | "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers." |
| 92 | } |
| 93 | Self::Grandmas => { |
| 94 | "The walls are thin. Mom is here, and she has been reading about Digest authentication." |
| 95 | } |
| 96 | Self::Hotel => { |
| 97 | "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby." |
| 98 | } |
| 99 | Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.", |
| 100 | Self::Clinic => "The urologist will see you now. He is bored.", |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | fn observer(self) -> Option<&'static str> { |
| 105 | match self { |
| 106 | Self::Bedroom => Some("cat"), |
| 107 | Self::LockerRoom => Some("greg"), |
| 108 | Self::Grandmas => Some("grandma"), |
| 109 | _ => None, |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | pub fn shop_open(self) -> bool { |
| 114 | matches!(self, Self::LockerRoom | Self::Hotel) |
| 115 | } |
| 116 | |
| 117 | pub fn has_mom(self) -> bool { |
| 118 | matches!(self, Self::Bedroom | Self::Grandmas) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 123 | pub enum Grip { |
| 124 | Overhand, |
| 125 | Underhand, |
| 126 | } |
| 127 | |
| 128 | impl Grip { |
| 129 | pub fn name(self) -> &'static str { |
| 130 | match self { |
| 131 | Self::Overhand => "overhand", |
| 132 | Self::Underhand => "underhand", |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | pub fn parse(protocol: &str) -> Option<Self> { |
| 137 | match protocol.to_ascii_lowercase().as_str() { |
| 138 | "overhand" => Some(Self::Overhand), |
| 139 | "underhand" => Some(Self::Underhand), |
| 140 | _ => None, |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | #[derive(Default)] |
| 146 | pub struct Items { |
| 147 | pub ruler: bool, |
| 148 | pub ice_packs: u32, |
| 149 | pub door_locks: u32, |
| 150 | pub dnd_sign: bool, |
| 151 | pub pump_pro: bool, |
| 152 | pub extender: bool, |
| 153 | pub ring: bool, |
| 154 | pub supplements: u32, |
| 155 | pub cage: bool, |
| 156 | pub spreadsheet: bool, |
| 157 | pub teapot_spout: bool, |
| 158 | pub blinds: bool, |
| 159 | } |
| 160 | |
| 161 | enum Payload { |
| 162 | Empty, |
| 163 | Json(Value), |
| 164 | Raw(&'static str, Vec<u8>), |
| 165 | } |
| 166 | |
| 167 | pub struct Outcome { |
| 168 | status: StatusCode, |
| 169 | payload: Payload, |
| 170 | headers: Vec<(HeaderName, String)>, |
| 171 | } |
| 172 | |
| 173 | impl Outcome { |
| 174 | pub fn empty(status: StatusCode) -> Self { |
| 175 | Self { |
| 176 | status, |
| 177 | payload: Payload::Empty, |
| 178 | headers: Vec::new(), |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | pub fn json(status: StatusCode, body: Value) -> Self { |
| 183 | Self { |
| 184 | payload: Payload::Json(body), |
| 185 | ..Self::empty(status) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self { |
| 190 | Self { |
| 191 | payload: Payload::Raw(content_type, body.into()), |
| 192 | ..Self::empty(status) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self { |
| 197 | self.headers.push((name, value.into())); |
| 198 | self |
| 199 | } |
| 200 | |
| 201 | pub fn status(&self) -> StatusCode { |
| 202 | self.status |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | impl IntoResponse for Outcome { |
| 207 | fn into_response(self) -> Response { |
| 208 | let mut response = match self.payload { |
| 209 | // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is. |
| 210 | Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => { |
| 211 | let full = body.to_string().into_bytes(); |
| 212 | let half = full.len() / 2; |
| 213 | let range = format!("bytes 0-{}/{}", half - 1, full.len()); |
| 214 | ( |
| 215 | self.status, |
| 216 | [ |
| 217 | (header::CONTENT_TYPE, "application/json".to_owned()), |
| 218 | (header::CONTENT_RANGE, range), |
| 219 | ], |
| 220 | full[..half].to_vec(), |
| 221 | ) |
| 222 | .into_response() |
| 223 | } |
| 224 | Payload::Json(body) => (self.status, Json(body)).into_response(), |
| 225 | Payload::Raw(content_type, body) => { |
| 226 | (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response() |
| 227 | } |
| 228 | Payload::Empty => self.status.into_response(), |
| 229 | }; |
| 230 | for (name, value) in self.headers { |
| 231 | let value = HeaderValue::from_str(&value).expect("header values are visible ascii"); |
| 232 | response.headers_mut().append(name, value); |
| 233 | } |
| 234 | response |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | pub struct Pending { |
| 239 | due: u32, |
| 240 | length: f64, |
| 241 | girth: f64, |
| 242 | } |
| 243 | |
| 244 | pub struct Ending { |
| 245 | pub cause: &'static str, |
| 246 | pub detail: String, |
| 247 | pub turn: u32, |
| 248 | } |
| 249 | |
| 250 | pub struct Run { |
| 251 | pub id: String, |
| 252 | pub origin_etag: String, |
| 253 | pub version: u64, |
| 254 | pub turn: u32, |
| 255 | pub floor: Floor, |
| 256 | pub floor_turns: u32, |
| 257 | pub length: f64, |
| 258 | pub temp_length: f64, |
| 259 | pub girth: f64, |
| 260 | pub foreskin: Option<f64>, |
| 261 | pub eq: i32, |
| 262 | pub stamina: i32, |
| 263 | pub suspicion: u8, |
| 264 | pub shame_base: u32, |
| 265 | pub shame: u32, |
| 266 | pub cash: u32, |
| 267 | pub grip: Grip, |
| 268 | pub items: Items, |
| 269 | pub warmed_up: bool, |
| 270 | pub teapot: bool, |
| 271 | pub peyronie: bool, |
| 272 | pub put_applied: bool, |
| 273 | pub forum_read: bool, |
| 274 | pub branch_pending: bool, |
| 275 | pub mom_present: bool, |
| 276 | pub mom_errand: &'static str, |
| 277 | pub mom_strikes: u8, |
| 278 | pub elder_strikes: u8, |
| 279 | pub seeded: bool, |
| 280 | pub towel_stolen: bool, |
| 281 | pub pulled_muscle: bool, |
| 282 | pub priapism_turns: u8, |
| 283 | pub filmed_at: Option<SystemTime>, |
| 284 | pub observed: Option<&'static str>, |
| 285 | pub measured: bool, |
| 286 | pub cured: bool, |
| 287 | pub roommate_told: bool, |
| 288 | pub wifi_accepted: bool, |
| 289 | pub verified: bool, |
| 290 | pub elder_beaten: bool, |
| 291 | pub edges: u8, |
| 292 | pub drawer_searches: u8, |
| 293 | pub intake_attempts: usize, |
| 294 | pub nonce: String, |
| 295 | pub heard: Vec<String>, |
| 296 | pub refractory_until: Option<Instant>, |
| 297 | pub pending: Vec<Pending>, |
| 298 | pub ended: Option<Ending>, |
| 299 | rng: StdRng, |
| 300 | } |
| 301 | |
| 302 | pub fn round2(x: f64) -> f64 { |
| 303 | (x * 100.0).round() / 100.0 |
| 304 | } |
| 305 | |
| 306 | fn round3(x: f64) -> f64 { |
| 307 | (x * 1000.0).round() / 1000.0 |
| 308 | } |
| 309 | |
| 310 | fn honest(claimed: f64, actual: f64) -> bool { |
| 311 | (claimed - actual).abs() < 0.06 |
| 312 | } |
| 313 | |
| 314 | fn not_extended() -> Problem { |
| 315 | Problem::new( |
| 316 | StatusCode::NOT_EXTENDED, |
| 317 | "not-extended", |
| 318 | "The requested extension was not applied. The server has recorded the attempt.", |
| 319 | ) |
| 320 | } |
| 321 | |
| 322 | pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> { |
| 323 | let (given, rest) = authorization.trim().split_once(' ')?; |
| 324 | given |
| 325 | .eq_ignore_ascii_case(scheme) |
| 326 | .then(|| rest.trim().trim_matches('"')) |
| 327 | } |
| 328 | |
| 329 | pub fn etag_matches(condition: &str, etag: &str) -> bool { |
| 330 | condition |
| 331 | .split(',') |
| 332 | .map(str::trim) |
| 333 | .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag) |
| 334 | } |
| 335 | |
| 336 | pub fn shaft(mm: usize) -> String { |
| 337 | format!("8{}D", "=".repeat(mm.saturating_sub(2))) |
| 338 | } |
| 339 | |
| 340 | impl Run { |
| 341 | pub fn new(id: String, seed: u64, shame_base: u32) -> Self { |
| 342 | let mut rng = StdRng::seed_from_u64(seed); |
| 343 | let length = round2(rng.random_range(4.5..6.5)); |
| 344 | let girth = round2(rng.random_range(4.0..5.0)); |
| 345 | let foreskin = round2(rng.random_range(0.4..2.4)); |
| 346 | let mut run = Self { |
| 347 | id, |
| 348 | origin_etag: String::new(), |
| 349 | version: 0, |
| 350 | turn: 0, |
| 351 | floor: Floor::Bedroom, |
| 352 | floor_turns: 0, |
| 353 | length, |
| 354 | temp_length: 0.0, |
| 355 | girth, |
| 356 | foreskin: Some(foreskin), |
| 357 | eq: 20, |
| 358 | stamina: MAX_STAMINA, |
| 359 | suspicion: 0, |
| 360 | shame_base, |
| 361 | shame: 0, |
| 362 | cash: STARTING_CASH, |
| 363 | grip: Grip::Overhand, |
| 364 | items: Items { |
| 365 | ice_packs: 1, |
| 366 | ..Items::default() |
| 367 | }, |
| 368 | warmed_up: false, |
| 369 | teapot: false, |
| 370 | peyronie: false, |
| 371 | put_applied: false, |
| 372 | forum_read: false, |
| 373 | branch_pending: false, |
| 374 | mom_present: false, |
| 375 | mom_errand: "She heard a noise.", |
| 376 | mom_strikes: 0, |
| 377 | elder_strikes: 0, |
| 378 | seeded: false, |
| 379 | towel_stolen: false, |
| 380 | pulled_muscle: false, |
| 381 | priapism_turns: 0, |
| 382 | filmed_at: None, |
| 383 | observed: None, |
| 384 | measured: false, |
| 385 | cured: false, |
| 386 | roommate_told: false, |
| 387 | wifi_accepted: false, |
| 388 | verified: false, |
| 389 | elder_beaten: false, |
| 390 | edges: 0, |
| 391 | drawer_searches: 0, |
| 392 | intake_attempts: 0, |
| 393 | nonce: String::new(), |
| 394 | heard: Vec::new(), |
| 395 | refractory_until: None, |
| 396 | pending: Vec::new(), |
| 397 | ended: None, |
| 398 | rng, |
| 399 | }; |
| 400 | run.origin_etag = run.etag(); |
| 401 | run.nonce = run.fresh_nonce(); |
| 402 | run |
| 403 | } |
| 404 | |
| 405 | pub fn etag(&self) -> String { |
| 406 | format!("\"{}-{}\"", self.id, self.version) |
| 407 | } |
| 408 | |
| 409 | pub fn touch(&mut self) { |
| 410 | self.version += 1; |
| 411 | } |
| 412 | |
| 413 | fn fresh_nonce(&mut self) -> String { |
| 414 | format!("{:016x}", self.rng.random::<u64>()) |
| 415 | } |
| 416 | |
| 417 | pub fn current_length(&self) -> f64 { |
| 418 | self.length + self.temp_length |
| 419 | } |
| 420 | |
| 421 | pub fn self_reported(&self) -> f64 { |
| 422 | self.current_length() + SELF_REPORT_INFLATION |
| 423 | } |
| 424 | |
| 425 | pub fn max_stamina(&self) -> i32 { |
| 426 | if self.pulled_muscle { 7 } else { MAX_STAMINA } |
| 427 | } |
| 428 | |
| 429 | pub fn footage(&self) -> Option<(SystemTime, u64)> { |
| 430 | let filmed = self.filmed_at?; |
| 431 | let age = filmed.elapsed().map_or(0, |d| d.as_secs()); |
| 432 | Some((filmed, age)) |
| 433 | } |
| 434 | |
| 435 | pub fn total_shame(&self) -> u32 { |
| 436 | self.shame_base + self.shame |
| 437 | } |
| 438 | |
| 439 | pub fn status(&self) -> &'static str { |
| 440 | match (&self.ended, self.cured) { |
| 441 | (Some(_), _) => "gone", |
| 442 | (None, true) => "normal", |
| 443 | (None, false) => "alive", |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | pub fn view(&self) -> Value { |
| 448 | let conditions: Vec<&str> = [ |
| 449 | (self.teapot, "short and stout"), |
| 450 | (self.peyronie, "curved"), |
| 451 | (self.items.cage, "caged"), |
| 452 | (self.priapism_turns > 0, "priapism"), |
| 453 | (self.pulled_muscle, "pulled muscle"), |
| 454 | (self.filmed_at.is_some(), "viral"), |
| 455 | ] |
| 456 | .into_iter() |
| 457 | .filter_map(|(active, name)| active.then_some(name)) |
| 458 | .collect(); |
| 459 | let mut view = json!({ |
| 460 | "id": self.id, |
| 461 | "status": self.status(), |
| 462 | "turn": self.turn, |
| 463 | "floor": { "number": self.floor.number(), "name": self.floor.name() }, |
| 464 | "self_reported_length": round2(self.self_reported()), |
| 465 | "girth": round2(self.girth), |
| 466 | "foreskin": self.foreskin.map(round2), |
| 467 | "eq": self.eq, |
| 468 | "stamina": self.stamina, |
| 469 | "suspicion": self.suspicion, |
| 470 | "shame": self.total_shame(), |
| 471 | "cash": self.cash, |
| 472 | "grip": self.grip.name(), |
| 473 | "warmed_up": self.warmed_up, |
| 474 | "ranked": !self.seeded, |
| 475 | "conditions": conditions, |
| 476 | }); |
| 477 | if self.items.ruler { |
| 478 | view["length"] = json!(round2(self.current_length())); |
| 479 | } |
| 480 | view |
| 481 | } |
| 482 | |
| 483 | pub fn floor_view(&self) -> Value { |
| 484 | let id = &self.id; |
| 485 | let turns_required = match self.floor { |
| 486 | Floor::Forum | Floor::Clinic => Value::Null, |
| 487 | _ => json!(FLOOR_TURNS), |
| 488 | }; |
| 489 | let mut view = json!({ |
| 490 | "number": self.floor.number(), |
| 491 | "name": self.floor.name(), |
| 492 | "note": self.floor.note(), |
| 493 | "turns": self.floor_turns, |
| 494 | "turns_required": turns_required, |
| 495 | "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" }, |
| 496 | }); |
| 497 | match self.floor { |
| 498 | Floor::LockerRoom => { |
| 499 | view["others"] = json!([ |
| 500 | { "name": "Greg", "self_reported_length": 9.5 }, |
| 501 | { "name": "Dmitri", "self_reported_length": 8.75 }, |
| 502 | { "name": "a man in a towel", "self_reported_length": 11.0 }, |
| 503 | ]); |
| 504 | } |
| 505 | Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")), |
| 506 | Floor::Forum => { |
| 507 | view["thread"] = json!(FORUM_THREAD); |
| 508 | view["elder"] = json!(format!("/runs/{id}/elder")); |
| 509 | } |
| 510 | Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")), |
| 511 | _ => {} |
| 512 | } |
| 513 | if self.floor.shop_open() { |
| 514 | view["shop"] = json!(format!("/runs/{id}/shop")); |
| 515 | } |
| 516 | view |
| 517 | } |
| 518 | |
| 519 | pub fn inventory(&self) -> Vec<(&'static str, &'static str)> { |
| 520 | let i = &self.items; |
| 521 | let owned = [ |
| 522 | ( |
| 523 | true, |
| 524 | "warm-towel", |
| 525 | "Reusable. Satisfies the warm-up precondition. Use technique=warmup.", |
| 526 | ), |
| 527 | ( |
| 528 | i.ice_packs > 0, |
| 529 | "ice-pack", |
| 530 | "Lowers EQ by 40. Use technique=ice.", |
| 531 | ), |
| 532 | ( |
| 533 | i.ruler, |
| 534 | "ruler", |
| 535 | "Measures honestly. The length resource now carries a Content-Length.", |
| 536 | ), |
| 537 | ( |
| 538 | i.door_locks > 0, |
| 539 | "door-lock", |
| 540 | "Keeps the roommate out once.", |
| 541 | ), |
| 542 | ( |
| 543 | i.dnd_sign, |
| 544 | "do-not-disturb-sign", |
| 545 | "Works on housekeeping. Mom does not read signs.", |
| 546 | ), |
| 547 | ( |
| 548 | i.pump_pro, |
| 549 | "pump-pro", |
| 550 | "Use technique=pump. Gains are temporary.", |
| 551 | ), |
| 552 | ( |
| 553 | i.extender, |
| 554 | "extender", |
| 555 | "Passive gains each turn. Takes up room. Can be removed with DELETE.", |
| 556 | ), |
| 557 | ( |
| 558 | i.ring, |
| 559 | "cock-ring", |
| 560 | "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.", |
| 561 | ), |
| 562 | (i.supplements > 0, "supplement", "Use technique=supplement."), |
| 563 | (i.cage, "chastity-cage", "Locked."), |
| 564 | ( |
| 565 | i.spreadsheet, |
| 566 | "spreadsheet", |
| 567 | "Charts your progress. Every line is flat.", |
| 568 | ), |
| 569 | ( |
| 570 | i.teapot_spout, |
| 571 | "teapot-spout", |
| 572 | "Does nothing. Cannot be dropped.", |
| 573 | ), |
| 574 | ( |
| 575 | i.blinds, |
| 576 | "window-blinds", |
| 577 | "The neighbour has a good camera. The blinds are better.", |
| 578 | ), |
| 579 | ]; |
| 580 | owned |
| 581 | .into_iter() |
| 582 | .filter_map(|(have, slug, description)| have.then_some((slug, description))) |
| 583 | .collect() |
| 584 | } |
| 585 | |
| 586 | pub fn end(&mut self, cause: &'static str, detail: &str) { |
| 587 | self.ended = Some(Ending { |
| 588 | cause, |
| 589 | detail: detail.into(), |
| 590 | turn: self.turn, |
| 591 | }); |
| 592 | } |
| 593 | |
| 594 | fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> { |
| 595 | let Some(until) = self.refractory_until else { |
| 596 | return Ok(()); |
| 597 | }; |
| 598 | if now < until { |
| 599 | let seconds = (until - now).as_secs() + 1; |
| 600 | return Err(Problem::new( |
| 601 | StatusCode::TOO_MANY_REQUESTS, |
| 602 | "overtrained", |
| 603 | "Stamina is exhausted. The refractory period is in effect.", |
| 604 | ) |
| 605 | .header(header::RETRY_AFTER, seconds.to_string())); |
| 606 | } |
| 607 | self.refractory_until = None; |
| 608 | self.stamina = self.max_stamina(); |
| 609 | self.eq = 20; |
| 610 | self.warmed_up = false; |
| 611 | self.touch(); |
| 612 | Ok(()) |
| 613 | } |
| 614 | |
| 615 | fn realize(&mut self) { |
| 616 | let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending) |
| 617 | .into_iter() |
| 618 | .partition(|p| p.due <= self.turn); |
| 619 | self.pending = waiting; |
| 620 | for p in due { |
| 621 | self.length += p.length; |
| 622 | self.girth += p.girth; |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | fn gain(&mut self, length: f64, girth: f64, foreskin: f64) { |
| 627 | self.length += length; |
| 628 | self.girth += girth; |
| 629 | if let Some(f) = &mut self.foreskin { |
| 630 | *f += foreskin; |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | fn rupture(&mut self) -> Problem { |
| 635 | let detail = if self.items.ice_packs > 0 { |
| 636 | "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used." |
| 637 | } else { |
| 638 | "A vessel has exceeded its rated pressure. The run has ended." |
| 639 | }; |
| 640 | self.end("vascular-event", detail); |
| 641 | Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail) |
| 642 | .with("eq_at_failure", self.eq) |
| 643 | .with("turn", self.turn) |
| 644 | } |
| 645 | |
| 646 | fn next_floor(&mut self, paid: bool) { |
| 647 | let Some(next) = self.floor.next() else { |
| 648 | return; |
| 649 | }; |
| 650 | self.floor = next; |
| 651 | self.floor_turns = 0; |
| 652 | self.temp_length = 0.0; |
| 653 | self.teapot = false; |
| 654 | self.warmed_up = false; |
| 655 | self.eq = 20; |
| 656 | self.suspicion = 0; |
| 657 | self.mom_present = false; |
| 658 | self.roommate_told = false; |
| 659 | self.drawer_searches = 0; |
| 660 | self.towel_stolen = false; |
| 661 | self.edges = 0; |
| 662 | if paid { |
| 663 | self.cash += FLOOR_ALLOWANCE; |
| 664 | } |
| 665 | self.nonce = self.fresh_nonce(); |
| 666 | self.touch(); |
| 667 | } |
| 668 | |
| 669 | pub fn advance(&mut self) -> Result<Outcome, Problem> { |
| 670 | match self.floor { |
| 671 | Floor::Clinic => { |
| 672 | return Err(Problem::new( |
| 673 | StatusCode::CONFLICT, |
| 674 | "no-further-floor", |
| 675 | "There is no further floor. There is only the diagnosis.", |
| 676 | )); |
| 677 | } |
| 678 | Floor::Forum if !self.elder_beaten => { |
| 679 | return Err(Problem::new( |
| 680 | StatusCode::CONFLICT, |
| 681 | "elder-unaddressed", |
| 682 | "The Elder has not been addressed.", |
| 683 | ) |
| 684 | .with("elder", format!("/runs/{}/elder", self.id))); |
| 685 | } |
| 686 | Floor::Forum => {} |
| 687 | _ if self.floor_turns < FLOOR_TURNS => { |
| 688 | return Err(Problem::new( |
| 689 | StatusCode::TOO_EARLY, |
| 690 | "floor-not-over", |
| 691 | format!( |
| 692 | "The floor is not over. {} turns remain.", |
| 693 | FLOOR_TURNS - self.floor_turns |
| 694 | ), |
| 695 | )); |
| 696 | } |
| 697 | _ => {} |
| 698 | } |
| 699 | if self.rng.random_ratio(1, 200) { |
| 700 | let detail = |
| 701 | "The request conflicts with the current state of the zipper. The run has ended."; |
| 702 | self.end("zipper", detail); |
| 703 | return Err(Problem::new(StatusCode::CONFLICT, "zipper", detail)); |
| 704 | } |
| 705 | let reverted = self.temp_length; |
| 706 | self.next_floor(true); |
| 707 | let mut body = json!({ |
| 708 | "detail": format!("You have arrived at {}.", self.floor.name()), |
| 709 | "floor": self.floor.number(), |
| 710 | "note": self.floor.note(), |
| 711 | }); |
| 712 | if reverted > 0.0 { |
| 713 | body["reverted"] = json!(round2(reverted)); |
| 714 | } |
| 715 | Ok(Outcome::json(StatusCode::SEE_OTHER, body) |
| 716 | .header(header::LOCATION, format!("/runs/{}/floor", self.id))) |
| 717 | } |
| 718 | } |