an http based roguelike rpg about jelqing
Commit dfa38cf4cd4b
tarball11 files changed+3042-1221
@@ -3,6 +3,12 @@
33
version = 444
55
[[package]]6
+name = "adler2"7
+version = "2.0.1"8
+source = "registry+https://github.com/rust-lang/crates.io-index"9
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"10
+11
+[[package]]612
name = "atomic-waker"713
version = "1.1.2"814
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -73,6 +79,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
7379
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"7480
7581
[[package]]82
+name = "crc32fast"83
+version = "1.5.2"84
+source = "registry+https://github.com/rust-lang/crates.io-index"85
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"86
+dependencies = [87
+ "cfg-if",88
+]89
+90
+[[package]]91
+name = "flate2"92
+version = "1.1.10"93
+source = "registry+https://github.com/rust-lang/crates.io-index"94
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"95
+dependencies = [96
+ "crc32fast",97
+ "miniz_oxide",98
+ "zlib-rs",99
+]100
+101
+[[package]]76102
name = "form_urlencoded"77103
version = "1.2.2"78104
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -219,7 +245,11 @@ name = "jelqd"
219245
version = "0.6.9"220246
dependencies = [221247
"axum",248
+ "flate2",222249
"futures-util",250
+ "httpdate",251
+ "hyper",252
+ "hyper-util",223253
"rand",224254
"serde",225255
"serde_json",@@ -258,6 +288,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
258288
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"259289
260290
[[package]]291
+name = "miniz_oxide"292
+version = "0.9.1"293
+source = "registry+https://github.com/rust-lang/crates.io-index"294
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"295
+dependencies = [296
+ "adler2",297
+ "simd-adler32",298
+]299
+300
+[[package]]261301
name = "mio"262302
version = "1.2.4"263303
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -421,6 +461,12 @@ dependencies = [
421461
]422462
423463
[[package]]464
+name = "simd-adler32"465
+version = "0.3.10"466
+source = "registry+https://github.com/rust-lang/crates.io-index"467
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"468
+469
+[[package]]424470
name = "slab"425471
version = "0.4.12"426472
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -476,6 +522,7 @@ version = "1.53.2"
476522
source = "registry+https://github.com/rust-lang/crates.io-index"477523
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"478524
dependencies = [525
+ "bytes",479526
"libc",480527
"mio",481528
"pin-project-lite",@@ -606,6 +653,12 @@ dependencies = [
606653
]607654
608655
[[package]]656
+name = "zlib-rs"657
+version = "0.6.8"658
+source = "registry+https://github.com/rust-lang/crates.io-index"659
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"660
+661
+[[package]]609662
name = "zmij"610663
version = "1.0.23"611664
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -7,11 +7,15 @@ license = "MIT"
77
88
[dependencies]99
axum = "0.8"10
+flate2 = "1"1011
futures-util = "0.3"12
+httpdate = "1"13
+hyper = { version = "1", features = ["http1", "server"] }14
+hyper-util = { version = "0.1", features = ["tokio"] }1115
rand = "0.9"1216
serde = { version = "1", features = ["derive"] }1317
serde_json = "1"14
-tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }18
+tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }1519
1620
[dev-dependencies]1721
tower = { version = "0.5", features = ["util"] }@@ -55,7 +55,11 @@
5555
5656
devShells.default = craneLib.devShell {5757
checks = self.checks.${system};58
- packages = [ pkgs.rust-analyzer ];58
+ packages = [59
+ pkgs.rust-analyzer60
+ pkgs.nushell61
+ pkgs.curl62
+ ];5963
};6064
6165
formatter = pkgs.nixfmt;@@ -1,1028 +0,0 @@
1
-use std::time::{Duration, Instant};2
-3
-use axum::{4
- Json,5
- body::Body,6
- http::{HeaderName, HeaderValue, StatusCode, header},7
- response::{IntoResponse, Response},8
-};9
-use rand::{Rng, SeedableRng, rngs::StdRng};10
-use serde::Deserialize;11
-use serde_json::{Value, json};12
-13
-use crate::problem::Problem;14
-15
-pub const MAX_STAMINA: i32 = 10;16
-pub const SUSPICION_LIMIT: u8 = 5;17
-pub const BRANCH_TURN: u32 = 10;18
-pub const APPOINTMENT_TURN: u32 = 20;19
-pub const REFRACTORY: Duration = Duration::from_secs(30);20
-pub const SELF_REPORT_INFLATION: f64 = 1.5;21
-pub const MM_PER_INCH: f64 = 25.4;22
-pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];23
-pub const LUBRICANTS: [&str; 5] = [24
- "application/baby-oil",25
- "application/coconut-oil",26
- "application/lotion",27
- "application/vaseline",28
- "text/plain; charset=spit",29
-];30
-pub const INTAKE: [&str; 3] = [31
- "The clinic does not accept excuses.",32
- "The clinic accepts one token. You have not been carrying it.",33
- "Be honest.",34
-];35
-36
-pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");37
-pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");38
-39
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]40
-pub enum Lube {41
- BabyOil,42
- CoconutOil,43
- Lotion,44
- Vaseline,45
- Spit,46
-}47
-48
-impl Lube {49
- pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {50
- let Some(raw) = content_type else {51
- return Ok(None);52
- };53
- let lowered = raw.to_ascii_lowercase();54
- let mut parts = lowered.split(';').map(str::trim);55
- let essence = parts.next().unwrap_or_default();56
- let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");57
- match essence {58
- "application/baby-oil" => Ok(Some(Self::BabyOil)),59
- "application/coconut-oil" => Ok(Some(Self::CoconutOil)),60
- "application/lotion" => Ok(Some(Self::Lotion)),61
- "application/vaseline" => Ok(Some(Self::Vaseline)),62
- "text/plain" if spit => Ok(Some(Self::Spit)),63
- e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(64
- StatusCode::UNSUPPORTED_MEDIA_TYPE,65
- "no",66
- "No.",67
- )),68
- _ => Err(Problem::new(69
- StatusCode::UNSUPPORTED_MEDIA_TYPE,70
- "unsupported-lubricant",71
- format!("{essence} is not a lubricant."),72
- )73
- .with("lubricants", json!(LUBRICANTS))),74
- }75
- }76
-77
- fn modifier(self) -> i32 {78
- match self {79
- Self::BabyOil => 1,80
- Self::CoconutOil => 3,81
- Self::Lotion => 0,82
- Self::Vaseline => 2,83
- Self::Spit => -2,84
- }85
- }86
-}87
-88
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]89
-pub enum Technique {90
- Warmup,91
- Ice,92
- Wet,93
- Dry,94
- Stretch,95
- Uli,96
-}97
-98
-#[derive(Clone, Copy)]99
-enum LubeRule {100
- Required,101
- Forbidden,102
- Optional,103
-}104
-105
-struct Profile {106
- dc: i32,107
- length: f64,108
- girth: f64,109
- foreskin: Option<(f64, f64)>,110
- foreskin_growth: f64,111
- lube: LubeRule,112
-}113
-114
-impl Technique {115
- pub const ALL: [Self; 6] = [116
- Self::Warmup,117
- Self::Ice,118
- Self::Wet,119
- Self::Dry,120
- Self::Stretch,121
- Self::Uli,122
- ];123
-124
- pub fn name(self) -> &'static str {125
- match self {126
- Self::Warmup => "warmup",127
- Self::Ice => "ice",128
- Self::Wet => "wet",129
- Self::Dry => "dry",130
- Self::Stretch => "stretch",131
- Self::Uli => "uli",132
- }133
- }134
-135
- pub fn parse(name: &str) -> Option<Self> {136
- Self::ALL.into_iter().find(|t| t.name() == name)137
- }138
-139
- fn profile(self) -> Option<Profile> {140
- let profile = match self {141
- Self::Warmup | Self::Ice => return None,142
- Self::Wet => Profile {143
- dc: 10,144
- length: 0.010,145
- girth: 0.005,146
- foreskin: None,147
- foreskin_growth: 0.0,148
- lube: LubeRule::Required,149
- },150
- Self::Dry => Profile {151
- dc: 12,152
- length: 0.008,153
- girth: 0.003,154
- foreskin: Some((0.8, 2.0)),155
- foreskin_growth: 0.01,156
- lube: LubeRule::Forbidden,157
- },158
- Self::Stretch => Profile {159
- dc: 11,160
- length: 0.015,161
- girth: 0.0,162
- foreskin: Some((0.0, 1.8)),163
- foreskin_growth: 0.03,164
- lube: LubeRule::Optional,165
- },166
- Self::Uli => Profile {167
- dc: 15,168
- length: 0.002,169
- girth: 0.020,170
- foreskin: None,171
- foreskin_growth: 0.0,172
- lube: LubeRule::Optional,173
- },174
- };175
- Some(profile)176
- }177
-178
- pub fn describe(self, forum_read: bool) -> Value {179
- let Some(p) = self.profile() else {180
- let effect = match self {181
- Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",182
- _ => "EQ -40. Consumes an ice pack.",183
- };184
- return json!({ "name": self.name(), "effect": effect });185
- };186
- let lubricant = match p.lube {187
- LubeRule::Required => "required",188
- LubeRule::Forbidden => "forbidden",189
- LubeRule::Optional => "optional",190
- };191
- let foreskin = p192
- .foreskin193
- .map(|(min, max)| json!({ "min": min, "max": max }));194
- json!({195
- "name": self.name(),196
- "dc": p.dc,197
- "lubricant": lubricant,198
- "foreskin": foreskin,199
- "locked": self == Self::Uli && !forum_read,200
- })201
- }202
-}203
-204
-pub struct Outcome {205
- status: StatusCode,206
- body: Option<Value>,207
- headers: Vec<(HeaderName, String)>,208
-}209
-210
-impl Outcome {211
- pub fn new(status: StatusCode, body: Option<Value>) -> Self {212
- Self {213
- status,214
- body,215
- headers: Vec::new(),216
- }217
- }218
-219
- pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {220
- self.headers.push((name, value.into()));221
- self222
- }223
-}224
-225
-impl IntoResponse for Outcome {226
- fn into_response(self) -> Response {227
- let mut response = match self.body {228
- // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.229
- Some(body) if self.status == StatusCode::PARTIAL_CONTENT => {230
- let full = body.to_string().into_bytes();231
- let half = full.len() / 2;232
- let range = format!("bytes 0-{}/{}", half - 1, full.len());233
- (234
- self.status,235
- [236
- (header::CONTENT_TYPE, "application/json".to_owned()),237
- (header::CONTENT_RANGE, range),238
- ],239
- Body::from(full[..half].to_vec()),240
- )241
- .into_response()242
- }243
- Some(body) => (self.status, Json(body)).into_response(),244
- None => self.status.into_response(),245
- };246
- for (name, value) in self.headers {247
- let value = HeaderValue::from_str(&value).expect("header values are visible ascii");248
- response.headers_mut().append(name, value);249
- }250
- response251
- }252
-}253
-254
-#[derive(Deserialize)]255
-pub struct PatchOp {256
- pub op: String,257
- pub path: String,258
- #[serde(default)]259
- pub value: Value,260
-}261
-262
-pub struct Pending {263
- due: u32,264
- length: f64,265
- girth: f64,266
-}267
-268
-pub struct Ending {269
- pub cause: &'static str,270
- pub detail: String,271
- pub turn: u32,272
-}273
-274
-pub struct Run {275
- pub id: String,276
- pub origin_etag: String,277
- pub version: u64,278
- pub turn: u32,279
- pub length: f64,280
- pub girth: f64,281
- pub foreskin: Option<f64>,282
- pub eq: i32,283
- pub stamina: i32,284
- pub suspicion: u8,285
- pub shame_base: u32,286
- pub shame: u32,287
- pub ice_packs: u32,288
- pub warmed_up: bool,289
- pub teapot: bool,290
- pub ruler: bool,291
- pub put_applied: bool,292
- pub forum_read: bool,293
- pub branch_pending: bool,294
- pub mom_present: bool,295
- pub observed: bool,296
- pub measured: bool,297
- pub cured: bool,298
- pub intake_attempts: usize,299
- pub refractory_until: Option<Instant>,300
- pub pending: Vec<Pending>,301
- pub ended: Option<Ending>,302
- rng: StdRng,303
-}304
-305
-fn round2(x: f64) -> f64 {306
- (x * 100.0).round() / 100.0307
-}308
-309
-fn round3(x: f64) -> f64 {310
- (x * 1000.0).round() / 1000.0311
-}312
-313
-fn tenths(x: f64) -> i64 {314
- (x * 10.0).round() as i64315
-}316
-317
-fn not_extended() -> Problem {318
- Problem::new(319
- StatusCode::NOT_EXTENDED,320
- "not-extended",321
- "The requested extension was not applied. The server has recorded the attempt.",322
- )323
-}324
-325
-pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {326
- let (given, rest) = authorization.trim().split_once(' ')?;327
- given328
- .eq_ignore_ascii_case(scheme)329
- .then(|| rest.trim().trim_matches('"'))330
-}331
-332
-impl Run {333
- pub fn new(id: String, seed: u64, shame_base: u32) -> Self {334
- let mut rng = StdRng::seed_from_u64(seed);335
- let length = round2(rng.random_range(4.5..6.5));336
- let girth = round2(rng.random_range(4.0..5.0));337
- let foreskin = round2(rng.random_range(0.4..2.4));338
- let mut run = Self {339
- id,340
- origin_etag: String::new(),341
- version: 0,342
- turn: 0,343
- length,344
- girth,345
- foreskin: Some(foreskin),346
- eq: 20,347
- stamina: MAX_STAMINA,348
- suspicion: 0,349
- shame_base,350
- shame: 0,351
- ice_packs: 1,352
- warmed_up: false,353
- teapot: false,354
- ruler: false,355
- put_applied: false,356
- forum_read: false,357
- branch_pending: false,358
- mom_present: false,359
- observed: false,360
- measured: false,361
- cured: false,362
- intake_attempts: 0,363
- refractory_until: None,364
- pending: Vec::new(),365
- ended: None,366
- rng,367
- };368
- run.origin_etag = run.etag();369
- run370
- }371
-372
- pub fn etag(&self) -> String {373
- format!("\"{}-{}\"", self.id, self.version)374
- }375
-376
- pub fn touch(&mut self) {377
- self.version += 1;378
- }379
-380
- pub fn self_reported(&self) -> f64 {381
- self.length + SELF_REPORT_INFLATION382
- }383
-384
- pub fn total_shame(&self) -> u32 {385
- self.shame_base + self.shame386
- }387
-388
- pub fn status(&self) -> &'static str {389
- match (&self.ended, self.cured) {390
- (Some(_), _) => "gone",391
- (None, true) => "normal",392
- (None, false) => "alive",393
- }394
- }395
-396
- pub fn view(&self) -> Value {397
- let mut view = json!({398
- "id": self.id,399
- "status": self.status(),400
- "turn": self.turn,401
- "floor": 1,402
- "self_reported_length": round2(self.self_reported()),403
- "girth": round2(self.girth),404
- "foreskin": self.foreskin.map(round2),405
- "eq": self.eq,406
- "stamina": self.stamina,407
- "suspicion": self.suspicion,408
- "shame": self.total_shame(),409
- "warmed_up": self.warmed_up,410
- });411
- if self.ruler {412
- view["length"] = json!(round2(self.length));413
- }414
- if self.teapot {415
- view["condition"] = json!("short and stout");416
- }417
- view418
- }419
-420
- pub fn end(&mut self, cause: &'static str, detail: &str) {421
- self.ended = Some(Ending {422
- cause,423
- detail: detail.into(),424
- turn: self.turn,425
- });426
- }427
-428
- pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {429
- let challenge =430
- |p: Problem| p.header(header::WWW_AUTHENTICATE, "Excuse realm=\"household\"");431
- let Some(authorization) = authorization else {432
- return Err(challenge(433
- Problem::new(434
- StatusCode::UNAUTHORIZED,435
- "someone-at-the-door",436
- "Someone is at the door.",437
- )438
- .with("inventory", format!("/runs/{}/inventory", self.id)),439
- ));440
- };441
- let excuse = parse_credentials(authorization, "excuse");442
- if excuse.is_some_and(|e| EXCUSES.iter().any(|known| known.eq_ignore_ascii_case(e))) {443
- self.mom_present = false;444
- self.suspicion = 0;445
- self.touch();446
- return Ok(());447
- }448
- self.shame += 3;449
- self.touch();450
- Err(challenge(Problem::new(451
- StatusCode::UNAUTHORIZED,452
- "excuse-rejected",453
- "Mom has not heard that one before. She does not believe it.",454
- )))455
- }456
-457
- fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {458
- let Some(until) = self.refractory_until else {459
- return Ok(());460
- };461
- if now < until {462
- let seconds = (until - now).as_secs() + 1;463
- return Err(Problem::new(464
- StatusCode::TOO_MANY_REQUESTS,465
- "overtrained",466
- "Stamina is exhausted. The refractory period is in effect.",467
- )468
- .header(header::RETRY_AFTER, seconds.to_string()));469
- }470
- self.refractory_until = None;471
- self.stamina = MAX_STAMINA;472
- self.eq = 20;473
- self.warmed_up = false;474
- self.touch();475
- Ok(())476
- }477
-478
- fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {479
- let Some(f) = self.foreskin else {480
- if min <= 0.0 {481
- return Ok(());482
- }483
- return Err(Problem::new(484
- StatusCode::GONE,485
- "foreskin-gone",486
- "This technique requires a foreskin. The foreskin is gone.",487
- )488
- .header(489
- header::CONTENT_LOCATION,490
- format!("/runs/{}/foreskin", self.id),491
- ));492
- };493
- if f < min {494
- return Err(Problem::new(495
- StatusCode::LENGTH_REQUIRED,496
- "foreskin-insufficient",497
- format!(498
- "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."499
- ),500
- )501
- .with("required", min)502
- .with("available", round2(f)));503
- }504
- if f > max {505
- return Err(Problem::new(506
- StatusCode::PAYLOAD_TOO_LARGE,507
- "foreskin-excessive",508
- format!(509
- "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."510
- ),511
- )512
- .with("maximum", max)513
- .with("present", round2(f)));514
- }515
- Ok(())516
- }517
-518
- fn realize(&mut self) {519
- let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)520
- .into_iter()521
- .partition(|p| p.due <= self.turn);522
- self.pending = waiting;523
- for p in due {524
- self.length += p.length;525
- self.girth += p.girth;526
- }527
- }528
-529
- fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {530
- self.length += length;531
- self.girth += girth;532
- if let Some(f) = &mut self.foreskin {533
- *f += foreskin;534
- }535
- }536
-537
- fn rupture(&mut self) -> Problem {538
- let detail = if self.ice_packs > 0 {539
- "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."540
- } else {541
- "A vessel has exceeded its rated pressure. The run has ended."542
- };543
- self.end("vascular-event", detail);544
- Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)545
- .with("eq_at_failure", self.eq)546
- .with("turn", self.turn)547
- }548
-549
- pub fn jelq(550
- &mut self,551
- technique: Technique,552
- intensity: i32,553
- lube: Option<Lube>,554
- now: Instant,555
- ) -> Result<Outcome, Problem> {556
- self.check_refractory(now)?;557
- if self.branch_pending {558
- return Ok(self.branch_offer());559
- }560
- let Some(profile) = technique.profile() else {561
- return self.utility(technique);562
- };563
- if technique == Technique::Uli && !self.forum_read {564
- return Err(Problem::new(565
- StatusCode::FORBIDDEN,566
- "technique-locked",567
- "This technique is discussed on the forum. You have not been to the forum.",568
- ));569
- }570
- if !self.warmed_up {571
- return Err(Problem::new(572
- StatusCode::PRECONDITION_FAILED,573
- "cold-start",574
- "Warm-up was skipped. The server has noted this.",575
- )576
- .with("technique", Technique::Warmup.name()));577
- }578
- match (profile.lube, lube) {579
- (LubeRule::Required, None) => {580
- return Err(Problem::new(581
- StatusCode::UNSUPPORTED_MEDIA_TYPE,582
- "lubricant-required",583
- format!(584
- "The {} technique requires a lubricant. None was declared.",585
- technique.name()586
- ),587
- )588
- .with("lubricants", json!(LUBRICANTS)));589
- }590
- (LubeRule::Forbidden, Some(_)) => {591
- return Err(Problem::new(592
- StatusCode::UNSUPPORTED_MEDIA_TYPE,593
- "lubricant-declared",594
- "The dry technique was requested. A lubricant was declared.",595
- ));596
- }597
- _ => {}598
- }599
- if let Some((min, max)) = profile.foreskin {600
- self.check_foreskin(min, max)?;601
- }602
-603
- self.touch();604
- self.turn += 1;605
- self.realize();606
- self.stamina -= 1 + i32::from(lube == Some(Lube::Lotion));607
- if self.stamina <= 0 {608
- self.stamina = 0;609
- self.refractory_until = Some(now + REFRACTORY);610
- }611
- self.eq = (self.eq + intensity * 2 - 6).clamp(0, 100);612
- let suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));613
- self.suspicion = (self.suspicion + suspicion as u8).min(SUSPICION_LIMIT);614
- if self.suspicion == SUSPICION_LIMIT {615
- self.mom_present = true;616
- }617
- if self.rng.random_ratio(1, 6) {618
- self.observed = true;619
- self.shame += 1;620
- }621
- if self.turn == BRANCH_TURN {622
- self.branch_pending = true;623
- }624
-625
- if self.eq > 95 {626
- self.eq = 0;627
- self.warmed_up = false;628
- self.shame += 5;629
- return Err(Problem::new(630
- StatusCode::TOO_EARLY,631
- "premature",632
- "The floor has ended early. Everyone involved has agreed not to discuss it.",633
- ));634
- }635
- if self.eq < 30 {636
- return Err(Problem::new(637
- StatusCode::NOT_FOUND,638
- "cold",639
- "The resource exists. It is cold.",640
- )641
- .with("eq", self.eq));642
- }643
- self.roll(technique, &profile, intensity, lube)644
- }645
-646
- fn roll(647
- &mut self,648
- technique: Technique,649
- profile: &Profile,650
- intensity: i32,651
- lube: Option<Lube>,652
- ) -> Result<Outcome, Problem> {653
- let natural: i32 = self.rng.random_range(1..=20);654
- let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };655
- let modifier = lube.map_or(0, Lube::modifier) + band;656
- let dc = profile.dc + intensity / 3;657
- let total = natural + modifier;658
- let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");659
- let timed = |p: Problem| p.header(SERVER_TIMING, &timing);660
-661
- let scale = f64::from(intensity);662
- let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);663
- if self.teapot {664
- length *= 0.5;665
- girth *= 1.5;666
- }667
-668
- if natural == 1 {669
- let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);670
- if touches_foreskin && self.foreskin.is_some() {671
- self.foreskin = None;672
- self.shame += 25;673
- return Err(timed(674
- Problem::new(675
- StatusCode::GONE,676
- "foreskin-lost",677
- "The technique has removed the foreskin. It will not be returning.",678
- )679
- .header(680
- header::CONTENT_LOCATION,681
- format!("/runs/{}/foreskin", self.id),682
- ),683
- ));684
- }685
- if self.eq > 70 {686
- return Err(timed(self.rupture()));687
- }688
- if technique == Technique::Stretch && !self.teapot {689
- self.teapot = true;690
- self.shame += 5;691
- return Err(timed(Problem::new(692
- StatusCode::IM_A_TEAPOT,693
- "teapot",694
- "The subject is now short and stout. Length gains are halved for the rest of the run.",695
- )));696
- }697
- return Err(timed(not_extended()));698
- }699
- if self.eq > 90 && total < dc {700
- return Err(timed(self.rupture()));701
- }702
- if natural == 20 {703
- self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);704
- return Ok(Outcome::new(705
- StatusCode::CREATED,706
- Some(json!({707
- "detail": "A permanent gain has been created.",708
- "length": round3(length * 2.0),709
- "girth": round3(girth * 2.0),710
- })),711
- )712
- .header(header::LOCATION, format!("/runs/{}/length", self.id))713
- .header(SERVER_TIMING, timing));714
- }715
- if total < dc - 3 {716
- return Err(timed(not_extended()));717
- }718
- if total < dc {719
- return Ok(Outcome::new(StatusCode::NO_CONTENT, None).header(SERVER_TIMING, timing));720
- }721
-722
- let outcome = if self.eq < 50 {723
- self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);724
- Outcome::new(725
- StatusCode::PARTIAL_CONTENT,726
- Some(json!({727
- "detail": "Partial gains were applied. EQ was below the band.",728
- "length": round3(length / 2.0),729
- "girth": round3(girth / 2.0),730
- })),731
- )732
- } else if total >= dc + 5 {733
- self.gain(length, girth, profile.foreskin_growth);734
- Outcome::new(735
- StatusCode::OK,736
- Some(json!({737
- "detail": "Gains were applied.",738
- "length": round3(length),739
- "girth": round3(girth),740
- })),741
- )742
- } else {743
- let eta = self.rng.random_range(1..=4);744
- self.pending.push(Pending {745
- due: self.turn + eta,746
- length,747
- girth,748
- });749
- self.gain(0.0, 0.0, profile.foreskin_growth);750
- Outcome::new(751
- StatusCode::ACCEPTED,752
- Some(json!({753
- "detail": "Gains have been queued. They may arrive.",754
- "eta_turns": eta,755
- })),756
- )757
- };758
- Ok(outcome.header(SERVER_TIMING, timing))759
- }760
-761
- fn utility(&mut self, technique: Technique) -> Result<Outcome, Problem> {762
- if technique == Technique::Warmup {763
- self.touch();764
- self.warmed_up = true;765
- self.eq = (self.eq + 25).min(100);766
- return Ok(Outcome::new(767
- StatusCode::OK,768
- Some(json!({ "detail": "Warmed up. The towel has cooled." })),769
- ));770
- }771
- if self.ice_packs == 0 {772
- return Err(Problem::new(773
- StatusCode::FAILED_DEPENDENCY,774
- "no-ice",775
- "The ice pack has been used. There was one ice pack.",776
- ));777
- }778
- self.touch();779
- self.ice_packs -= 1;780
- self.eq = (self.eq - 40).max(0);781
- Ok(Outcome::new(782
- StatusCode::OK,783
- Some(json!({ "detail": "EQ has been reduced. The ice pack is gone." })),784
- ))785
- }786
-787
- fn branch_offer(&self) -> Outcome {788
- let forum = format!("/runs/{}/branches/forum", self.id);789
- let close = format!("/runs/{}/branches/close-tab", self.id);790
- Outcome::new(791
- StatusCode::MULTIPLE_CHOICES,792
- Some(json!({793
- "detail": "A browser tab is open. It has been open for some time.",794
- "choices": [795
- { "href": forum, "method": "POST", "title": "Read the forum" },796
- { "href": close, "method": "POST", "title": "Close the tab" },797
- ],798
- })),799
- )800
- .header(801
- header::LINK,802
- format!(803
- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""804
- ),805
- )806
- }807
-808
- pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {809
- if !self.branch_pending {810
- return Err(Problem::new(811
- StatusCode::CONFLICT,812
- "no-decision-pending",813
- "There is no decision pending.",814
- ));815
- }816
- let (location, detail) = match choice {817
- "forum" => {818
- self.forum_read = true;819
- self.shame += 3;820
- (821
- format!("/runs/{}/techniques", self.id),822
- "The thread is from 2009. A technique called uli is discussed at length.",823
- )824
- }825
- "close-tab" => {826
- self.suspicion = 0;827
- (828
- format!("/runs/{}", self.id),829
- "The tab is closed. Nobody saw it.",830
- )831
- }832
- _ => {833
- return Err(Problem::new(834
- StatusCode::NOT_FOUND,835
- "no-such-choice",836
- "That was not one of the choices.",837
- ));838
- }839
- };840
- self.branch_pending = false;841
- self.touch();842
- Ok(843
- Outcome::new(StatusCode::SEE_OTHER, Some(json!({ "detail": detail })))844
- .header(header::LOCATION, location),845
- )846
- }847
-848
- pub fn search_drawer(&mut self) -> Outcome {849
- if self.ruler {850
- return Outcome::new(StatusCode::NO_CONTENT, None);851
- }852
- self.ruler = true;853
- self.shame += 10;854
- self.touch();855
- Outcome::new(856
- StatusCode::FOUND,857
- Some(json!({858
- "detail": "You now know. This is the first honest measurement of the run.",859
- })),860
- )861
- .header(862
- header::LOCATION,863
- format!("/runs/{}/inventory/ruler", self.id),864
- )865
- }866
-867
- pub fn put(&mut self) -> Outcome {868
- if self.put_applied {869
- return Outcome::new(StatusCode::NO_CONTENT, None);870
- }871
- self.put_applied = true;872
- self.length += 0.01;873
- self.touch();874
- Outcome::new(StatusCode::OK, Some(json!({ "detail": "Applied." })))875
- }876
-877
- pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {878
- for op in ops {879
- if op.op != "test" {880
- return Err(Problem::new(881
- StatusCode::UNPROCESSABLE_ENTITY,882
- "wishful-thinking",883
- "The server accepts only the truth.",884
- )885
- .with("op", op.op.clone()));886
- }887
- let actual = match op.path.as_str() {888
- "/length" => self.length,889
- "/girth" => self.girth,890
- "/foreskin" => self.foreskin.unwrap_or(0.0),891
- _ => {892
- return Err(Problem::new(893
- StatusCode::UNPROCESSABLE_ENTITY,894
- "no-such-measurement",895
- "There is no such measurement.",896
- )897
- .with("path", op.path.clone()));898
- }899
- };900
- let Some(claimed) = op.value.as_f64() else {901
- return Err(Problem::new(902
- StatusCode::BAD_REQUEST,903
- "malformed-patch",904
- "A test requires a numeric value.",905
- ));906
- };907
- if op.path == "/length" && claimed >= self.length + 1.0 {908
- self.shame += 2;909
- self.touch();910
- return Err(Problem::new(911
- StatusCode::BAD_REQUEST,912
- "exaggeration",913
- "The server has measured. Lying is not a technique.",914
- ));915
- }916
- if tenths(claimed) != tenths(actual) {917
- self.shame += 1;918
- self.touch();919
- return Err(920
- Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")921
- .with("path", op.path.clone()),922
- );923
- }924
- }925
- Ok(Outcome::new(926
- StatusCode::OK,927
- Some(json!({ "detail": "Correct." })),928
- ))929
- }930
-931
- pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {932
- if self.foreskin.is_none() {933
- return Err(Problem::new(934
- StatusCode::GONE,935
- "foreskin-gone",936
- "The foreskin is gone. It will not be returning.",937
- ));938
- }939
- let Some(if_match) = if_match else {940
- return Err(Problem::new(941
- StatusCode::PRECONDITION_REQUIRED,942
- "confirmation-required",943
- "The server requires you to be sure. Send If-Match.",944
- ));945
- };946
- if !etag_matches(if_match, &self.etag()) {947
- return Err(Problem::new(948
- StatusCode::PRECONDITION_FAILED,949
- "stale-foreskin",950
- "The foreskin has changed since you last looked at it.",951
- ));952
- }953
- self.foreskin = None;954
- self.shame += 20;955
- self.touch();956
- Ok(Outcome::new(StatusCode::NO_CONTENT, None))957
- }958
-959
- pub fn diagnose(960
- &mut self,961
- authorization: Option<&str>,962
- if_none_match: Option<&str>,963
- ) -> Result<Outcome, Problem> {964
- let remaining = APPOINTMENT_TURN.saturating_sub(self.turn);965
- if remaining > 0 {966
- return Err(Problem::new(967
- StatusCode::TOO_EARLY,968
- "no-appointment",969
- format!("The clinic's next available appointment is in {remaining} turns."),970
- ));971
- }972
- let intake = Problem::new(973
- StatusCode::UNAUTHORIZED,974
- "intake",975
- "The clinic requires authorization.",976
- );977
- let Some(authorization) = authorization else {978
- return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));979
- };980
- let honest = parse_credentials(authorization, "bearer")981
- .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));982
- if !honest {983
- let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];984
- self.intake_attempts += 1;985
- return Err(intake.header(986
- header::WWW_AUTHENTICATE,987
- format!(988
- "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""989
- ),990
- ));991
- }992
-993
- let etag = self.origin_etag.clone();994
- if if_none_match.is_some_and(|inm| etag_matches(inm, &etag)) {995
- self.cured = true;996
- return Ok(Outcome::new(StatusCode::NOT_MODIFIED, None)997
- .header(header::ETAG, etag)998
- .header(X_DIAGNOSIS, "normal"));999
- }1000
- if !self.measured {1001
- self.measured = true;1002
- if !self.ruler {1003
- self.shame += 15;1004
- }1005
- }1006
- Ok(Outcome::new(1007
- StatusCode::OK,1008
- Some(json!({1009
- "diagnosis": "within normal range",1010
- "measured_length": round2(self.length),1011
- "you_said": round2(self.self_reported()),1012
- "prescription": "Please stop.",1013
- })),1014
- )1015
- .header(header::ETAG, etag))1016
- }1017
-}1018
-1019
-pub fn etag_matches(condition: &str, etag: &str) -> bool {1020
- condition1021
- .split(',')1022
- .map(str::trim)1023
- .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)1024
-}1025
-1026
-pub fn shaft(mm: usize) -> String {1027
- format!("8{}D", "=".repeat(mm.saturating_sub(2)))1028
-}@@ -0,0 +1,248 @@
1
+use axum::http::StatusCode;2
+use serde_json::{Value, json};3
+4
+use crate::problem::Problem;5
+6
+pub const LUBRICANTS: [&str; 5] = [7
+ "application/baby-oil",8
+ "application/coconut-oil",9
+ "application/lotion",10
+ "application/vaseline",11
+ "text/plain; charset=spit",12
+];13
+14
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]15
+pub enum Lube {16
+ BabyOil,17
+ CoconutOil,18
+ Lotion,19
+ Vaseline,20
+ Spit,21
+}22
+23
+impl Lube {24
+ pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {25
+ let Some(raw) = content_type else {26
+ return Ok(None);27
+ };28
+ let lowered = raw.to_ascii_lowercase();29
+ let mut parts = lowered.split(';').map(str::trim);30
+ let essence = parts.next().unwrap_or_default();31
+ let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");32
+ match essence {33
+ "application/baby-oil" => Ok(Some(Self::BabyOil)),34
+ "application/coconut-oil" => Ok(Some(Self::CoconutOil)),35
+ "application/lotion" => Ok(Some(Self::Lotion)),36
+ "application/vaseline" => Ok(Some(Self::Vaseline)),37
+ "text/plain" if spit => Ok(Some(Self::Spit)),38
+ e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(39
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,40
+ "no",41
+ "No.",42
+ )),43
+ _ => Err(Problem::new(44
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,45
+ "unsupported-lubricant",46
+ format!("{essence} is not a lubricant."),47
+ )48
+ .with("lubricants", json!(LUBRICANTS))),49
+ }50
+ }51
+52
+ pub(super) fn modifier(self) -> i32 {53
+ match self {54
+ Self::BabyOil => 1,55
+ Self::CoconutOil => 3,56
+ Self::Lotion => 0,57
+ Self::Vaseline => 2,58
+ Self::Spit => -2,59
+ }60
+ }61
+}62
+63
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]64
+pub enum Technique {65
+ Warmup,66
+ Ice,67
+ Edge,68
+ Pump,69
+ Supplement,70
+ Wet,71
+ Dry,72
+ Stretch,73
+ Uli,74
+}75
+76
+#[derive(Clone, Copy)]77
+pub(super) enum LubeRule {78
+ Required,79
+ Forbidden,80
+ Optional,81
+}82
+83
+pub(super) struct Profile {84
+ pub dc: i32,85
+ pub length: f64,86
+ pub girth: f64,87
+ pub foreskin: Option<(f64, f64)>,88
+ pub foreskin_growth: f64,89
+ pub lube: LubeRule,90
+}91
+92
+impl Technique {93
+ pub const ALL: [Self; 9] = [94
+ Self::Warmup,95
+ Self::Ice,96
+ Self::Edge,97
+ Self::Pump,98
+ Self::Supplement,99
+ Self::Wet,100
+ Self::Dry,101
+ Self::Stretch,102
+ Self::Uli,103
+ ];104
+105
+ pub fn name(self) -> &'static str {106
+ match self {107
+ Self::Warmup => "warmup",108
+ Self::Ice => "ice",109
+ Self::Edge => "edge",110
+ Self::Pump => "pump",111
+ Self::Supplement => "supplement",112
+ Self::Wet => "wet",113
+ Self::Dry => "dry",114
+ Self::Stretch => "stretch",115
+ Self::Uli => "uli",116
+ }117
+ }118
+119
+ pub fn parse(name: &str) -> Option<Self> {120
+ Self::ALL.into_iter().find(|t| t.name() == name)121
+ }122
+123
+ pub(super) fn profile(self) -> Option<Profile> {124
+ let profile = match self {125
+ Self::Warmup | Self::Ice | Self::Edge | Self::Pump | Self::Supplement => return None,126
+ Self::Wet => Profile {127
+ dc: 10,128
+ length: 0.010,129
+ girth: 0.005,130
+ foreskin: None,131
+ foreskin_growth: 0.0,132
+ lube: LubeRule::Required,133
+ },134
+ Self::Dry => Profile {135
+ dc: 12,136
+ length: 0.008,137
+ girth: 0.003,138
+ foreskin: Some((0.8, 2.0)),139
+ foreskin_growth: 0.01,140
+ lube: LubeRule::Forbidden,141
+ },142
+ Self::Stretch => Profile {143
+ dc: 11,144
+ length: 0.015,145
+ girth: 0.0,146
+ foreskin: Some((0.0, 1.8)),147
+ foreskin_growth: 0.03,148
+ lube: LubeRule::Optional,149
+ },150
+ Self::Uli => Profile {151
+ dc: 15,152
+ length: 0.002,153
+ girth: 0.020,154
+ foreskin: None,155
+ foreskin_growth: 0.0,156
+ lube: LubeRule::Optional,157
+ },158
+ };159
+ Some(profile)160
+ }161
+162
+ pub fn describe(self, forum_read: bool) -> Value {163
+ let Some(p) = self.profile() else {164
+ let effect = match self {165
+ Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",166
+ Self::Ice => "EQ -40. Consumes an ice pack.",167
+ Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",168
+ Self::Pump => "Large gains, all temporary. Requires a pump.",169
+ _ => "Consumes a supplement.",170
+ };171
+ return json!({ "name": self.name(), "effect": effect });172
+ };173
+ let lubricant = match p.lube {174
+ LubeRule::Required => "required",175
+ LubeRule::Forbidden => "forbidden",176
+ LubeRule::Optional => "optional",177
+ };178
+ let foreskin = p179
+ .foreskin180
+ .map(|(min, max)| json!({ "min": min, "max": max }));181
+ json!({182
+ "name": self.name(),183
+ "dc": p.dc,184
+ "lubricant": lubricant,185
+ "foreskin": foreskin,186
+ "locked": self == Self::Uli && !forum_read,187
+ })188
+ }189
+}190
+191
+pub struct ShopItem {192
+ pub slug: &'static str,193
+ pub price: u32,194
+ pub description: &'static str,195
+}196
+197
+pub const SHOP: [ShopItem; 10] = [198
+ ShopItem {199
+ slug: "pump",200
+ price: 10,201
+ description: "A pump.",202
+ },203
+ ShopItem {204
+ slug: "pump-pro",205
+ price: 15,206
+ description: "A pump, but professional.",207
+ },208
+ ShopItem {209
+ slug: "extender",210
+ price: 15,211
+ description: "Passive gains every turn. Takes up room.",212
+ },213
+ ShopItem {214
+ slug: "cock-ring",215
+ price: 8,216
+ description: "Doubles girth gains. Pump-Bro does not discuss the risks.",217
+ },218
+ ShopItem {219
+ slug: "supplement",220
+ price: 5,221
+ description: "Clinically formulated. Ships from overseas.",222
+ },223
+ ShopItem {224
+ slug: "door-lock",225
+ price: 5,226
+ description: "Keeps the roommate out once.",227
+ },228
+ ShopItem {229
+ slug: "do-not-disturb-sign",230
+ price: 5,231
+ description: "Housekeeping respects it.",232
+ },233
+ ShopItem {234
+ slug: "ruler",235
+ price: 5,236
+ description: "Pump-Bro does not recommend it.",237
+ },238
+ ShopItem {239
+ slug: "ice-pack",240
+ price: 3,241
+ description: "Cold.",242
+ },243
+ ShopItem {244
+ slug: "cage-key",245
+ price: 15,246
+ description: "Pump-Bro does not ask.",247
+ },248
+];@@ -0,0 +1,721 @@
1
+use std::collections::HashMap;2
+3
+use axum::http::{StatusCode, header};4
+use rand::Rng;5
+use serde::Deserialize;6
+use serde_json::{Value, json};7
+8
+use super::{9
+ Floor, Grip, Outcome, Run, SHOP, SUSPICION_LIMIT, X_DIAGNOSIS, parse_credentials, round2,10
+ tenths,11
+};12
+use crate::problem::Problem;13
+14
+pub const FORUM_THREAD: &str = "/forum/threads/2009";15
+pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";16
+17
+pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];18
+pub const THANKSGIVING_EXCUSES: [&str; 3] = ["on a call", "folding laundry", "praying"];19
+const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];20
+21
+const INTAKE: [&str; 3] = [22
+ "The clinic does not accept excuses.",23
+ "The clinic accepts one token. You have not been carrying it.",24
+ "Be honest.",25
+];26
+27
+const SPREADSHEET: &str = "\28
+date,length,girth,technique,notes,honest?29
+2007-01-03,7.50,5.50,wet,baseline,no30
+2007-06-11,7.50,5.50,wet,feeling fuller,no31
+2008-01-02,7.50,5.50,dry,new routine,no32
+2008-09-19,7.50,5.50,uli,PUT is idempotent. do not use PUT.,no33
+2009-06-14,7.50,5.50,wet,progress is slow but real,no34
+";35
+36
+pub fn forum_thread() -> Value {37
+ json!({38
+ "title": "The definitive jelqing FAQ (updated 2009)",39
+ "posts": [40
+ { "author": "TheElder", "body": "Warm up. Always warm up. A cold jelq is a 412 waiting to happen." },41
+ { "author": "TheElder", "body": "Keep EQ between 50 and 70. Above 70 you are gambling. Above 90 you are a cautionary tale." },42
+ { "author": "newbie_2009", "body": "I have been sending PUT for three weeks and nothing has changed." },43
+ { "author": "TheElder", "body": "PUT is idempotent. Read the spec." },44
+ { "author": "TheElder", "body": "Members verify with a Ruler. Proxy-Authorization, inches, no rounding up. We can tell." },45
+ { "author": "throwaway_77", "body": "my doctor said i was normal. i didnt believe him. i still dont know what he meant by bearer" },46
+ { "author": "TheElder", "body": "My numbers are in my spreadsheet. They have not changed since 2009. Nothing about me has changed since 2009." },47
+ ],48
+ })49
+}50
+51
+#[derive(Deserialize)]52
+pub struct PatchOp {53
+ pub op: String,54
+ pub path: String,55
+ #[serde(default)]56
+ pub value: Value,57
+}58
+59
+fn digest_params(authorization: &str) -> Option<HashMap<String, String>> {60
+ let (scheme, rest) = authorization.trim().split_once(' ')?;61
+ if !scheme.eq_ignore_ascii_case("digest") {62
+ return None;63
+ }64
+ Some(65
+ rest.split(',')66
+ .filter_map(|pair| {67
+ let (key, value) = pair.split_once('=')?;68
+ Some((69
+ key.trim().to_ascii_lowercase(),70
+ value.trim().trim_matches('"').to_owned(),71
+ ))72
+ })73
+ .collect(),74
+ )75
+}76
+77
+fn exaggeration() -> Problem {78
+ Problem::new(79
+ StatusCode::BAD_REQUEST,80
+ "exaggeration",81
+ "The server has measured. Lying is not a technique.",82
+ )83
+}84
+85
+impl Run {86
+ pub fn excuses(&self) -> Vec<&'static str> {87
+ if self.floor.number() < Floor::Grandmas.number() {88
+ return EXCUSES.to_vec();89
+ }90
+ EXCUSES91
+ .into_iter()92
+ .chain(THANKSGIVING_EXCUSES)93
+ .filter(|e| !self.heard.iter().any(|h| h == e))94
+ .collect()95
+ }96
+97
+ pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {98
+ let digest = self.floor == Floor::Grandmas;99
+ let challenge_value = if digest {100
+ format!("Digest realm=\"household\", nonce=\"{}\"", self.nonce)101
+ } else {102
+ "Excuse realm=\"household\"".to_owned()103
+ };104
+ let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);105
+106
+ let Some(authorization) = authorization else {107
+ return Err(challenge(108
+ Problem::new(109
+ StatusCode::UNAUTHORIZED,110
+ "someone-at-the-door",111
+ format!("Someone is at the door. {}", self.mom_errand),112
+ )113
+ .with("inventory", format!("/runs/{}/inventory", self.id)),114
+ ));115
+ };116
+117
+ let excuse = if digest {118
+ let Some(params) = digest_params(authorization) else {119
+ return Err(challenge(Problem::new(120
+ StatusCode::UNAUTHORIZED,121
+ "digest-required",122
+ "Mom has started using Digest authentication.",123
+ )));124
+ };125
+ if params.get("nonce") != Some(&self.nonce) {126
+ return Err(challenge(Problem::new(127
+ StatusCode::UNAUTHORIZED,128
+ "stale-nonce",129
+ "That was a different conversation.",130
+ )));131
+ }132
+ params.get("username").cloned().unwrap_or_default()133
+ } else {134
+ parse_credentials(authorization, "excuse")135
+ .unwrap_or_default()136
+ .to_owned()137
+ };138
+ let excuse = excuse.to_ascii_lowercase();139
+140
+ if TRUTHS.contains(&excuse.as_str()) {141
+ self.shame += 10;142
+ self.leave_door();143
+ return Ok(());144
+ }145
+ if digest && self.heard.contains(&excuse) {146
+ self.shame += 3;147
+ self.touch();148
+ return Err(challenge(Problem::new(149
+ StatusCode::UNAUTHORIZED,150
+ "excuse-replayed",151
+ "Mom has heard that one before. She would have preferred the truth.",152
+ )));153
+ }154
+ if self.excuses().contains(&excuse.as_str()) {155
+ self.heard.push(excuse);156
+ self.leave_door();157
+ return Ok(());158
+ }159
+ self.shame += 3;160
+ self.touch();161
+ let detail = if digest {162
+ "Mom has not heard that one before. She does not believe it. She would have preferred the truth."163
+ } else {164
+ "Mom has not heard that one before. She does not believe it."165
+ };166
+ Err(challenge(Problem::new(167
+ StatusCode::UNAUTHORIZED,168
+ "excuse-rejected",169
+ detail,170
+ )))171
+ }172
+173
+ pub(super) fn errand(&mut self, summoned: bool) -> &'static str {174
+ if summoned {175
+ return "She heard a noise.";176
+ }177
+ let errands: &[&'static str] = if self.floor == Floor::Grandmas {178
+ &[179
+ "Grandma would like to see you.",180
+ "Dinner is in ten minutes.",181
+ "She needs help with the folding table.",182
+ "An uncle has asked where you went.",183
+ ]184
+ } else {185
+ &[186
+ "She has brought laundry.",187
+ "She wants to know if you have eaten.",188
+ "She needs the wifi password.",189
+ "She would like to talk about your future.",190
+ ]191
+ };192
+ errands[self.rng.random_range(0..errands.len())]193
+ }194
+195
+ fn leave_door(&mut self) {196
+ self.mom_present = false;197
+ self.suspicion = 0;198
+ self.touch();199
+ }200
+201
+ pub(super) fn floor_enemy(&mut self) -> Result<(), Problem> {202
+ let wanders_in = matches!(self.floor, Floor::Bathroom | Floor::Hotel)203
+ && self.rng.random_ratio(u32::from(self.suspicion) + 1, 32);204
+ if self.suspicion < SUSPICION_LIMIT && !wanders_in {205
+ return Ok(());206
+ }207
+ match self.floor {208
+ Floor::Bathroom => {209
+ self.suspicion = 0;210
+ self.touch();211
+ if self.items.door_locks > 0 {212
+ self.items.door_locks -= 1;213
+ return Ok(());214
+ }215
+ self.roommate_told = true;216
+ Err(Problem::new(217
+ StatusCode::CONFLICT,218
+ "roommate",219
+ "The roommate needs the bathroom. He will mention this to people.",220
+ ))221
+ }222
+ Floor::LockerRoom => {223
+ self.suspicion = 0;224
+ self.shame += 5;225
+ self.touch();226
+ Err(Problem::new(227
+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,228
+ "gym-staff",229
+ "Gym staff have been notified. The server cannot serve this request for legal reasons.",230
+ ))231
+ }232
+ Floor::Hotel => {233
+ self.suspicion = 0;234
+ self.touch();235
+ if self.items.dnd_sign {236
+ return Ok(());237
+ }238
+ self.shame += 3;239
+ Err(Problem::new(240
+ StatusCode::UNAUTHORIZED,241
+ "housekeeping",242
+ "Housekeeping has entered. Housekeeping accepts no excuses.",243
+ )244
+ .header(header::WWW_AUTHENTICATE, "Excuse realm=\"housekeeping\""))245
+ }246
+ _ => Ok(()),247
+ }248
+ }249
+250
+ pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {251
+ self.drawer_searches += 1;252
+ self.touch();253
+ let first = self.drawer_searches == 1;254
+ let id = self.id.clone();255
+ let found = |slug: &str, detail: &str| {256
+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))257
+ .header(header::LOCATION, format!("/runs/{id}/inventory/{slug}"))258
+ };259
+ match self.floor {260
+ Floor::Bedroom if first && !self.items.ruler => {261
+ self.items.ruler = true;262
+ self.shame += 10;263
+ Ok(found(264
+ "ruler",265
+ "You now know. This is the first honest measurement of the run.",266
+ ))267
+ }268
+ Floor::Bathroom if first => {269
+ self.items.door_locks += 1;270
+ Ok(found("door-lock", "A door lock. It works once."))271
+ }272
+ Floor::LockerRoom => {273
+ self.shame += 3;274
+ Err(Problem::new(275
+ StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,276
+ "not-your-locker",277
+ "That locker belongs to Greg. The server cannot serve this request for legal reasons.",278
+ ))279
+ }280
+ Floor::Grandmas if first => {281
+ self.items.cage = true;282
+ self.shame += 5;283
+ Ok(found(284
+ "chastity-cage",285
+ "A small box. Inside was a cage. It is now on. The key is not in the box.",286
+ ))287
+ }288
+ Floor::Grandmas if self.items.cage => {289
+ self.items.cage = false;290
+ Ok(Outcome::json(291
+ StatusCode::OK,292
+ json!({ "detail": "The key was under where the box had been. The cage is off." }),293
+ ))294
+ }295
+ Floor::Hotel if first => {296
+ self.items.dnd_sign = true;297
+ self.items.ice_packs += 2;298
+ Ok(found(299
+ "do-not-disturb-sign",300
+ "A Do Not Disturb sign, and two ice packs from the machine down the hall.",301
+ ))302
+ }303
+ Floor::Forum if first => {304
+ self.items.spreadsheet = true;305
+ Ok(found(306
+ "spreadsheet",307
+ "Your own spreadsheet. Every line is flat.",308
+ ))309
+ }310
+ Floor::Clinic => Err(Problem::new(311
+ StatusCode::FORBIDDEN,312
+ "doctors-drawer",313
+ "That is the doctor's drawer.",314
+ )),315
+ _ => Ok(Outcome::empty(StatusCode::NO_CONTENT)),316
+ }317
+ }318
+319
+ pub fn shop_view(&self) -> Result<Value, Problem> {320
+ if !self.floor.shop_open() {321
+ return Err(pump_bro_absent());322
+ }323
+ let stock: Vec<Value> = SHOP324
+ .iter()325
+ .map(|item| {326
+ json!({327
+ "item": item.slug,328
+ "price": item.price,329
+ "description": item.description,330
+ "href": format!("/runs/{}/shop/{}", self.id, item.slug),331
+ "method": "POST",332
+ })333
+ })334
+ .collect();335
+ Ok(json!({ "merchant": "Pump-Bro", "cash": self.cash, "stock": stock }))336
+ }337
+338
+ pub fn buy(&mut self, slug: &str) -> Result<Outcome, Problem> {339
+ if !self.floor.shop_open() {340
+ return Err(pump_bro_absent());341
+ }342
+ let Some(item) = SHOP.iter().find(|i| i.slug == slug) else {343
+ return Err(Problem::new(344
+ StatusCode::NOT_FOUND,345
+ "not-for-sale",346
+ "Pump-Bro does not sell that. He knows a guy.",347
+ ));348
+ };349
+ if slug == "pump" {350
+ return Err(Problem::new(351
+ StatusCode::UPGRADE_REQUIRED,352
+ "upsell",353
+ "This pump requires an upgrade.",354
+ )355
+ .header(header::UPGRADE, "pump-pro/2.0")356
+ .with("upgrade", format!("/runs/{}/shop/pump-pro", self.id)));357
+ }358
+ if self.cash < item.price {359
+ return Err(Problem::new(360
+ StatusCode::PAYMENT_REQUIRED,361
+ "insufficient-funds",362
+ "Payment is required. RFC 9110 has reserved this status for future use since 1997. The future is here and you cannot afford it.",363
+ )364
+ .with("cash", self.cash)365
+ .with("price", item.price));366
+ }367
+ self.cash -= item.price;368
+ self.touch();369
+ let items = &mut self.items;370
+ match slug {371
+ "supplement" => {372
+ items.supplements += 1;373
+ return Err(Problem::new(374
+ StatusCode::GATEWAY_TIMEOUT,375
+ "still-shipping",376
+ "The supplement is still shipping. You have been charged. It may arrive.",377
+ )378
+ .with("cash", self.cash));379
+ }380
+ "pump-pro" => items.pump_pro = true,381
+ "extender" => items.extender = true,382
+ "cock-ring" => items.ring = true,383
+ "door-lock" => items.door_locks += 1,384
+ "do-not-disturb-sign" => items.dnd_sign = true,385
+ "ruler" => items.ruler = true,386
+ "ice-pack" => items.ice_packs += 1,387
+ _ => items.cage = false,388
+ }389
+ Ok(Outcome::json(390
+ StatusCode::CREATED,391
+ json!({ "detail": "Purchased. No refunds.", "cash": self.cash }),392
+ )393
+ .header(394
+ header::LOCATION,395
+ format!("/runs/{}/inventory/{slug}", self.id),396
+ ))397
+ }398
+399
+ pub fn drop_item(&mut self, slug: &str) -> Result<Outcome, Problem> {400
+ match slug {401
+ "extender" => self.items.extender = false,402
+ "cock-ring" => self.items.ring = false,403
+ "chastity-cage" => {404
+ return Err(Problem::new(405
+ StatusCode::LOCKED,406
+ "caged",407
+ "The cage is locked.",408
+ ));409
+ }410
+ _ => {411
+ return Err(Problem::new(412
+ StatusCode::FORBIDDEN,413
+ "cannot-be-dropped",414
+ "That cannot be dropped.",415
+ ));416
+ }417
+ }418
+ self.touch();419
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))420
+ }421
+422
+ pub(super) fn branch_offer(&self) -> Outcome {423
+ let forum = format!("/runs/{}/branches/forum", self.id);424
+ let close = format!("/runs/{}/branches/close-tab", self.id);425
+ Outcome::json(426
+ StatusCode::MULTIPLE_CHOICES,427
+ json!({428
+ "detail": "A browser tab is open. It has been open for some time.",429
+ "choices": [430
+ { "href": forum, "method": "POST", "title": "Read the forum" },431
+ { "href": close, "method": "POST", "title": "Close the tab" },432
+ ],433
+ }),434
+ )435
+ .header(436
+ header::LINK,437
+ format!(438
+ "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""439
+ ),440
+ )441
+ }442
+443
+ pub fn choose(&mut self, choice: &str) -> Result<Outcome, Problem> {444
+ if !self.branch_pending {445
+ return Err(Problem::new(446
+ StatusCode::CONFLICT,447
+ "no-decision-pending",448
+ "There is no decision pending.",449
+ ));450
+ }451
+ let (location, detail) = match choice {452
+ "forum" => {453
+ self.forum_read = true;454
+ self.shame += 3;455
+ (456
+ format!("/runs/{}/techniques", self.id),457
+ "The thread is from 2009. A technique called uli is discussed at length.",458
+ )459
+ }460
+ "close-tab" => {461
+ self.suspicion = 0;462
+ (463
+ format!("/runs/{}", self.id),464
+ "The tab is closed. Nobody saw it.",465
+ )466
+ }467
+ _ => {468
+ return Err(Problem::new(469
+ StatusCode::NOT_FOUND,470
+ "no-such-choice",471
+ "That was not one of the choices.",472
+ ));473
+ }474
+ };475
+ self.branch_pending = false;476
+ self.touch();477
+ Ok(478
+ Outcome::json(StatusCode::SEE_OTHER, json!({ "detail": detail }))479
+ .header(header::LOCATION, location),480
+ )481
+ }482
+483
+ pub fn put(&mut self) -> Outcome {484
+ if self.put_applied {485
+ return Outcome::empty(StatusCode::NO_CONTENT);486
+ }487
+ self.put_applied = true;488
+ self.length += 0.01;489
+ self.touch();490
+ Outcome::json(StatusCode::OK, json!({ "detail": "Applied." }))491
+ }492
+493
+ pub fn patch(&mut self, ops: &[PatchOp]) -> Result<Outcome, Problem> {494
+ for op in ops {495
+ if op.op != "test" {496
+ return Err(Problem::new(497
+ StatusCode::UNPROCESSABLE_ENTITY,498
+ "wishful-thinking",499
+ "The server accepts only the truth.",500
+ )501
+ .with("op", op.op.clone()));502
+ }503
+ let actual = match op.path.as_str() {504
+ "/length" => self.current_length(),505
+ "/girth" => self.girth,506
+ "/foreskin" => self.foreskin.unwrap_or(0.0),507
+ _ => {508
+ return Err(Problem::new(509
+ StatusCode::UNPROCESSABLE_ENTITY,510
+ "no-such-measurement",511
+ "There is no such measurement.",512
+ )513
+ .with("path", op.path.clone()));514
+ }515
+ };516
+ let Some(claimed) = op.value.as_f64() else {517
+ return Err(Problem::new(518
+ StatusCode::BAD_REQUEST,519
+ "malformed-patch",520
+ "A test requires a numeric value.",521
+ ));522
+ };523
+ if op.path == "/length" && claimed >= actual + 1.0 {524
+ self.shame += 2;525
+ self.touch();526
+ return Err(exaggeration());527
+ }528
+ if tenths(claimed) != tenths(actual) {529
+ self.shame += 1;530
+ self.touch();531
+ return Err(532
+ Problem::new(StatusCode::CONFLICT, "test-failed", "The test failed.")533
+ .with("path", op.path.clone()),534
+ );535
+ }536
+ }537
+ Ok(Outcome::json(538
+ StatusCode::OK,539
+ json!({ "detail": "Correct." }),540
+ ))541
+ }542
+543
+ pub fn delete_foreskin(&mut self, if_match: Option<&str>) -> Result<Outcome, Problem> {544
+ if self.foreskin.is_none() {545
+ return Err(Problem::new(546
+ StatusCode::GONE,547
+ "foreskin-gone",548
+ "The foreskin is gone. It will not be returning.",549
+ ));550
+ }551
+ let Some(if_match) = if_match else {552
+ return Err(Problem::new(553
+ StatusCode::PRECONDITION_REQUIRED,554
+ "confirmation-required",555
+ "The server requires you to be sure. Send If-Match.",556
+ ));557
+ };558
+ if !super::etag_matches(if_match, &self.etag()) {559
+ return Err(Problem::new(560
+ StatusCode::PRECONDITION_FAILED,561
+ "stale-foreskin",562
+ "The foreskin has changed since you last looked at it.",563
+ ));564
+ }565
+ self.foreskin = None;566
+ self.shame += 20;567
+ self.touch();568
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))569
+ }570
+571
+ pub fn set_grip(&mut self, grip: Grip) {572
+ self.grip = grip;573
+ self.touch();574
+ }575
+576
+ pub fn accept_terms(&mut self) -> Result<Outcome, Problem> {577
+ if self.floor != Floor::Hotel {578
+ return Err(Problem::new(579
+ StatusCode::NOT_FOUND,580
+ "no-portal",581
+ "There is no captive portal here. There is only the regular kind of captivity.",582
+ ));583
+ }584
+ self.wifi_accepted = true;585
+ self.touch();586
+ Ok(Outcome::json(587
+ StatusCode::SEE_OTHER,588
+ json!({ "detail": "Connected. The hotel does not want to know." }),589
+ )590
+ .header(header::LOCATION, format!("/runs/{}", self.id)))591
+ }592
+593
+ pub fn elder(594
+ &mut self,595
+ proxy_authorization: Option<&str>,596
+ if_modified_since: Option<&str>,597
+ ) -> Result<Outcome, Problem> {598
+ if self.floor != Floor::Forum {599
+ return Err(Problem::new(600
+ StatusCode::NOT_FOUND,601
+ "elder-absent",602
+ "The Elder only posts on the forum.",603
+ ));604
+ }605
+ if !self.verified {606
+ let verification = |detail: &str| {607
+ Problem::new(608
+ StatusCode::PROXY_AUTHENTICATION_REQUIRED,609
+ "verification-required",610
+ detail,611
+ )612
+ .header(header::PROXY_AUTHENTICATE, "Ruler realm=\"forum\"")613
+ };614
+ let Some(credentials) = proxy_authorization else {615
+ return Err(verification("The forum requires verification."));616
+ };617
+ let Some(claimed) =618
+ parse_credentials(credentials, "ruler").and_then(|v| v.parse::<f64>().ok())619
+ else {620
+ return Err(verification("The forum requires verification, in inches."));621
+ };622
+ let actual = self.current_length();623
+ if claimed >= actual + 1.0 {624
+ self.shame += 2;625
+ self.touch();626
+ return Err(exaggeration());627
+ }628
+ if tenths(claimed) != tenths(actual) {629
+ self.shame += 1;630
+ self.touch();631
+ return Err(verification("Verification failed. The forum can tell."));632
+ }633
+ self.verified = true;634
+ self.touch();635
+ }636
+637
+ let last_modified =638
+ httpdate::parse_http_date(ELDER_LAST_MODIFIED).expect("valid constant date");639
+ let current = if_modified_since640
+ .and_then(|d| httpdate::parse_http_date(d).ok())641
+ .is_some_and(|since| since >= last_modified);642
+ if current {643
+ self.elder_beaten = true;644
+ self.touch();645
+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)646
+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED));647
+ }648
+ Ok(649
+ Outcome::raw(StatusCode::OK, "text/csv; header=present", SPREADSHEET)650
+ .header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED),651
+ )652
+ }653
+654
+ pub fn diagnose(655
+ &mut self,656
+ authorization: Option<&str>,657
+ if_none_match: Option<&str>,658
+ ) -> Result<Outcome, Problem> {659
+ if self.floor != Floor::Clinic {660
+ let floors = Floor::Clinic.number() - self.floor.number();661
+ return Err(Problem::new(662
+ StatusCode::TOO_EARLY,663
+ "no-appointment",664
+ format!("The clinic's next available appointment is {floors} floors away."),665
+ ));666
+ }667
+ let intake = Problem::new(668
+ StatusCode::UNAUTHORIZED,669
+ "intake",670
+ "The clinic requires authorization.",671
+ );672
+ let Some(authorization) = authorization else {673
+ return Err(intake.header(header::WWW_AUTHENTICATE, "Bearer realm=\"clinic\""));674
+ };675
+ let honest = parse_credentials(authorization, "bearer")676
+ .is_some_and(|token| token.eq_ignore_ascii_case("honesty"));677
+ if !honest {678
+ let hint = INTAKE[self.intake_attempts.min(INTAKE.len() - 1)];679
+ self.intake_attempts += 1;680
+ return Err(intake.header(681
+ header::WWW_AUTHENTICATE,682
+ format!(683
+ "Bearer realm=\"clinic\", error=\"invalid_token\", error_description=\"{hint}\""684
+ ),685
+ ));686
+ }687
+688
+ self.peyronie = false;689
+ let etag = self.origin_etag.clone();690
+ if if_none_match.is_some_and(|inm| super::etag_matches(inm, &etag)) {691
+ self.cured = true;692
+ return Ok(Outcome::empty(StatusCode::NOT_MODIFIED)693
+ .header(header::ETAG, etag)694
+ .header(X_DIAGNOSIS, "normal"));695
+ }696
+ if !self.measured {697
+ self.measured = true;698
+ if !self.items.ruler {699
+ self.shame += 15;700
+ }701
+ }702
+ Ok(Outcome::json(703
+ StatusCode::OK,704
+ json!({705
+ "diagnosis": "within normal range",706
+ "measured_length": round2(self.length),707
+ "you_said": round2(self.self_reported()),708
+ "prescription": "Please stop.",709
+ }),710
+ )711
+ .header(header::ETAG, etag))712
+ }713
+}714
+715
+fn pump_bro_absent() -> Problem {716
+ Problem::new(717
+ StatusCode::NOT_FOUND,718
+ "pump-bro-absent",719
+ "Pump-Bro is not here.",720
+ )721
+}@@ -0,0 +1,603 @@
1
+use std::{2
+ fmt::Write as _,3
+ time::{Duration, Instant, SystemTime},4
+};5
+6
+use axum::http::{StatusCode, header};7
+use rand::Rng;8
+use serde_json::json;9
+10
+use super::{11
+ CLINIC_ALLOW, FORUM_THREAD, Floor, Grip, Lube, Outcome, REFRACTORY, Run, SERVER_TIMING,12
+ SUSPICION_LIMIT, Technique, catalog::LubeRule, catalog::Profile, not_extended, round3,13
+};14
+use crate::{game::LUBRICANTS, problem::Problem};15
+16
+const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);17
+18
+impl Run {19
+ pub fn jelq(20
+ &mut self,21
+ techniques: &[Technique],22
+ intensity: i32,23
+ lube: Option<Lube>,24
+ now: Instant,25
+ ) -> Result<Outcome, Problem> {26
+ self.check_refractory(now)?;27
+ if self.branch_pending {28
+ return Ok(self.branch_offer());29
+ }30
+ match self.floor {31
+ Floor::Forum => {32
+ return Ok(Outcome::json(33
+ StatusCode::SEE_OTHER,34
+ json!({ "detail": "Refer to the forum." }),35
+ )36
+ .header(header::LOCATION, FORUM_THREAD));37
+ }38
+ Floor::Clinic => {39
+ return Err(Problem::new(40
+ StatusCode::METHOD_NOT_ALLOWED,41
+ "not-in-the-clinic",42
+ "JELQ is not performed in the clinic.",43
+ )44
+ .header(header::ALLOW, CLINIC_ALLOW));45
+ }46
+ _ => {}47
+ }48
+ if self.items.cage {49
+ return Err(Problem::new(50
+ StatusCode::LOCKED,51
+ "caged",52
+ "The cage is locked. The key is not in the box.",53
+ ));54
+ }55
+ match *techniques {56
+ [technique] => self.single(technique, intensity, lube, now),57
+ [a, b] => self.combo(a, b, intensity, lube, now),58
+ _ => Err(Problem::new(59
+ StatusCode::BAD_REQUEST,60
+ "too-many-techniques",61
+ "Two techniques at most. The server has limits even if you do not.",62
+ )),63
+ }64
+ }65
+66
+ fn single(67
+ &mut self,68
+ technique: Technique,69
+ intensity: i32,70
+ lube: Option<Lube>,71
+ now: Instant,72
+ ) -> Result<Outcome, Problem> {73
+ match technique {74
+ Technique::Warmup => return self.warm_up(),75
+ Technique::Ice => return self.ice(),76
+ Technique::Supplement => return self.supplement(),77
+ Technique::Edge => {}78
+ _ => self.edges = 0,79
+ }80
+ self.prepare(technique, lube)?;81
+ self.check_storage()?;82
+ self.floor_enemy()?;83
+ match technique {84
+ Technique::Edge => return self.edge(now),85
+ Technique::Pump => return self.pump(now),86
+ _ => {}87
+ }88
+ let profile = technique.profile().expect("training technique");89
+ self.commit(intensity, lube, now, 1);90
+ self.hazards(intensity)?;91
+ let result = self.roll(technique, &profile, intensity, lube);92
+ Ok(self.gossip(result?))93
+ }94
+95
+ fn combo(96
+ &mut self,97
+ a: Technique,98
+ b: Technique,99
+ intensity: i32,100
+ lube: Option<Lube>,101
+ now: Instant,102
+ ) -> Result<Outcome, Problem> {103
+ let (Some(first), Some(second)) = (a.profile(), b.profile()) else {104
+ return Err(Problem::new(105
+ StatusCode::BAD_REQUEST,106
+ "combo-invalid",107
+ "Only training techniques can be combined.",108
+ ));109
+ };110
+ if a == b {111
+ return Err(Problem::new(112
+ StatusCode::BAD_REQUEST,113
+ "combo-invalid",114
+ "A technique cannot be combined with itself. The server has checked.",115
+ ));116
+ }117
+ self.edges = 0;118
+ self.prepare(a, lube)?;119
+ self.prepare(b, lube)?;120
+ self.check_storage()?;121
+ self.floor_enemy()?;122
+ self.commit(intensity, lube, now, 2);123
+ self.hazards(intensity)?;124
+125
+ let mut statuses = Vec::new();126
+ for (technique, profile) in [(a, first), (b, second)] {127
+ let result = self.roll(technique, &profile, intensity, lube);128
+ if self.ended.is_some() {129
+ return result;130
+ }131
+ let status = match &result {132
+ Ok(outcome) => outcome.status(),133
+ Err(problem) => problem.status(),134
+ };135
+ statuses.push((technique, status));136
+ }137
+138
+ let mut xml = String::from(139
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n",140
+ );141
+ for (technique, status) in statuses {142
+ let _ = write!(143
+ xml,144
+ " <D:response>\n <D:href>/runs/{}?technique={}</D:href>\n <D:status>HTTP/1.1 {} {}</D:status>\n </D:response>\n",145
+ self.id,146
+ technique.name(),147
+ status.as_u16(),148
+ status.canonical_reason().unwrap_or_default(),149
+ );150
+ }151
+ xml.push_str(152
+ " <D:responsedescription>Each technique has been reported separately.</D:responsedescription>\n</D:multistatus>\n",153
+ );154
+ Ok(Outcome::raw(155
+ StatusCode::MULTI_STATUS,156
+ "application/xml; charset=utf-8",157
+ xml,158
+ ))159
+ }160
+161
+ fn prepare(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {162
+ if technique == Technique::Uli && !self.forum_read {163
+ return Err(Problem::new(164
+ StatusCode::FORBIDDEN,165
+ "technique-locked",166
+ "This technique is discussed on the forum. You have not been to the forum.",167
+ ));168
+ }169
+ if !self.warmed_up {170
+ return Err(Problem::new(171
+ StatusCode::PRECONDITION_FAILED,172
+ "cold-start",173
+ "Warm-up was skipped. The server has noted this.",174
+ )175
+ .with("technique", Technique::Warmup.name()));176
+ }177
+ if technique == Technique::Pump && !self.items.pump_pro {178
+ return Err(Problem::new(179
+ StatusCode::FAILED_DEPENDENCY,180
+ "no-pump",181
+ "The pump technique depends on a pump.",182
+ ));183
+ }184
+ let Some(profile) = technique.profile() else {185
+ return Ok(());186
+ };187
+ match (profile.lube, lube) {188
+ (LubeRule::Required, None) => {189
+ return Err(Problem::new(190
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,191
+ "lubricant-required",192
+ format!(193
+ "The {} technique requires a lubricant. None was declared.",194
+ technique.name()195
+ ),196
+ )197
+ .with("lubricants", json!(LUBRICANTS)));198
+ }199
+ (LubeRule::Forbidden, Some(_)) => {200
+ return Err(Problem::new(201
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,202
+ "lubricant-declared",203
+ "The dry technique was requested. A lubricant was declared.",204
+ ));205
+ }206
+ _ => {}207
+ }208
+ if let Some((min, max)) = profile.foreskin {209
+ self.check_foreskin(min, max)?;210
+ }211
+ Ok(())212
+ }213
+214
+ fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {215
+ let Some(f) = self.foreskin else {216
+ if min <= 0.0 {217
+ return Ok(());218
+ }219
+ return Err(Problem::new(220
+ StatusCode::GONE,221
+ "foreskin-gone",222
+ "This technique requires a foreskin. The foreskin is gone.",223
+ )224
+ .header(225
+ header::CONTENT_LOCATION,226
+ format!("/runs/{}/foreskin", self.id),227
+ ));228
+ };229
+ if f < min {230
+ return Err(Problem::new(231
+ StatusCode::LENGTH_REQUIRED,232
+ "foreskin-insufficient",233
+ format!(234
+ "The selected technique requires {min:.1} units of foreskin. {f:.2} units are available. The server has noted the shortfall."235
+ ),236
+ )237
+ .with("required", min)238
+ .with("available", super::round2(f)));239
+ }240
+ if f > max {241
+ return Err(Problem::new(242
+ StatusCode::PAYLOAD_TOO_LARGE,243
+ "foreskin-excessive",244
+ format!(245
+ "The selected technique tolerates at most {max:.1} units of foreskin. {f:.2} units are present."246
+ ),247
+ )248
+ .with("maximum", max)249
+ .with("present", super::round2(f)));250
+ }251
+ Ok(())252
+ }253
+254
+ fn check_storage(&self) -> Result<(), Problem> {255
+ if self.items.extender && self.girth > 5.0 {256
+ return Err(Problem::new(257
+ StatusCode::INSUFFICIENT_STORAGE,258
+ "jeans-full",259
+ "The jeans are full. The extender is occupying the remaining storage.",260
+ )261
+ .with("extender", format!("/runs/{}/inventory/extender", self.id)));262
+ }263
+ Ok(())264
+ }265
+266
+ fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {267
+ self.touch();268
+ self.turn += 1;269
+ self.floor_turns += 1;270
+ self.realize();271
+ if self.items.extender {272
+ self.length += 0.004;273
+ }274
+ self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));275
+ if self.stamina <= 0 {276
+ self.stamina = 0;277
+ self.refractory_until = Some(now + REFRACTORY);278
+ }279
+ let chill = if self.floor == Floor::LockerRoom {280
+ 8281
+ } else {282
+ 0283
+ };284
+ self.eq = (self.eq + intensity * 2 - 6 - chill).clamp(0, 100);285
+286
+ let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));287
+ if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {288
+ suspicion *= 2;289
+ }290
+ if self.floor == Floor::Grandmas && lube == Some(Lube::CoconutOil) {291
+ suspicion += 2;292
+ }293
+ let was_below = self.suspicion < SUSPICION_LIMIT;294
+ self.suspicion =295
+ (i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)) as u8;296
+ if self.floor.has_mom() && !self.mom_present {297
+ let summoned = was_below && self.suspicion == SUSPICION_LIMIT;298
+ if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {299
+ self.mom_present = true;300
+ self.mom_errand = self.errand(summoned);301
+ self.nonce = self.fresh_nonce();302
+ }303
+ }304
+305
+ if let Some(observer) = self.floor.observer()306
+ && self.rng.random_ratio(1, 6)307
+ {308
+ self.observed = Some(observer);309
+ self.shame += 1;310
+ }311
+ if self.turn == super::BRANCH_TURN {312
+ self.branch_pending = true;313
+ }314
+ }315
+316
+ fn hazards(&mut self, intensity: i32) -> Result<(), Problem> {317
+ if self.floor == Floor::LockerRoom && self.rng.random_ratio(1, 10) {318
+ self.shame += 1;319
+ return Err(Problem::new(320
+ StatusCode::USE_PROXY,321
+ "friend",322
+ "A friend has offered to help. Use Proxy is deprecated, and the server has declined on your behalf.",323
+ ));324
+ }325
+ if self.peyronie && self.rng.random_ratio(1, 3) {326
+ return Err(Problem::new(327
+ StatusCode::MISDIRECTED_REQUEST,328
+ "misdirected",329
+ "The technique landed slightly to the left. It was meant for a different server.",330
+ ));331
+ }332
+ if intensity <= 2 && self.eq < 50 {333
+ self.eq = (self.eq - 10).max(0);334
+ return Err(Problem::new(335
+ StatusCode::REQUEST_TIMEOUT,336
+ "lost-it",337
+ "The request took too long. It was lost.",338
+ )339
+ .header(header::CONNECTION, "close"));340
+ }341
+ if self.eq > 95 {342
+ self.shame += 5;343
+ self.next_floor();344
+ return Err(Problem::new(345
+ StatusCode::TOO_EARLY,346
+ "premature",347
+ "The floor has ended early. Everyone involved has agreed not to discuss it.",348
+ )349
+ .with("floor", self.floor.number()));350
+ }351
+ if self.eq < 30 {352
+ return Err(Problem::new(353
+ StatusCode::NOT_FOUND,354
+ "cold",355
+ "The resource exists. It is cold.",356
+ )357
+ .with("eq", self.eq));358
+ }359
+ Ok(())360
+ }361
+362
+ fn roll(363
+ &mut self,364
+ technique: Technique,365
+ profile: &Profile,366
+ intensity: i32,367
+ lube: Option<Lube>,368
+ ) -> Result<Outcome, Problem> {369
+ let natural: i32 = self.rng.random_range(1..=20);370
+ let band = if (50..=70).contains(&self.eq) { 2 } else { 0 };371
+ let grip = match (self.grip, technique) {372
+ (Grip::Underhand, Technique::Stretch) => 2,373
+ (Grip::Underhand, Technique::Wet) => -1,374
+ _ => 0,375
+ };376
+ let modifier = lube.map_or(0, Lube::modifier) + band + grip;377
+ let dc = profile.dc + intensity / 3;378
+ let total = natural + modifier;379
+ let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");380
+ let timed = |p: Problem| p.header(SERVER_TIMING, &timing);381
+382
+ let scale = f64::from(intensity);383
+ let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);384
+ if self.teapot {385
+ length *= 0.5;386
+ girth *= 1.5;387
+ }388
+ if self.items.ring {389
+ girth *= 2.0;390
+ }391
+ let rupture_eq = if self.items.ring { 80 } else { 90 };392
+393
+ if natural == 1 {394
+ let touches_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);395
+ if touches_foreskin && self.foreskin.is_some() {396
+ self.foreskin = None;397
+ self.shame += 25;398
+ return Err(timed(399
+ Problem::new(400
+ StatusCode::GONE,401
+ "foreskin-lost",402
+ "The technique has removed the foreskin. It will not be returning.",403
+ )404
+ .header(405
+ header::CONTENT_LOCATION,406
+ format!("/runs/{}/foreskin", self.id),407
+ ),408
+ ));409
+ }410
+ if self.eq > 70 {411
+ return Err(timed(self.rupture()));412
+ }413
+ if technique == Technique::Stretch && !self.teapot {414
+ self.teapot = true;415
+ self.items.teapot_spout = true;416
+ self.shame += 5;417
+ return Err(timed(Problem::new(418
+ StatusCode::IM_A_TEAPOT,419
+ "teapot",420
+ "The subject is now short and stout. Length gains are halved for the rest of the floor.",421
+ )));422
+ }423
+ if technique == Technique::Uli && !self.peyronie {424
+ self.peyronie = true;425
+ self.shame += 5;426
+ return Err(timed(Problem::new(427
+ StatusCode::MISDIRECTED_REQUEST,428
+ "curvature",429
+ "A curvature has developed. Techniques will land slightly to the left until a professional is consulted.",430
+ )));431
+ }432
+ return Err(timed(not_extended()));433
+ }434
+ if self.eq > rupture_eq && total < dc {435
+ return Err(timed(self.rupture()));436
+ }437
+ if natural == 20 {438
+ self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);439
+ return Ok(Outcome::json(440
+ StatusCode::CREATED,441
+ json!({442
+ "detail": "A permanent gain has been created.",443
+ "length": round3(length * 2.0),444
+ "girth": round3(girth * 2.0),445
+ }),446
+ )447
+ .header(header::LOCATION, format!("/runs/{}/length", self.id))448
+ .header(SERVER_TIMING, timing));449
+ }450
+ if total < dc - 3 {451
+ return Err(timed(not_extended()));452
+ }453
+ if total < dc {454
+ return Ok(Outcome::empty(StatusCode::NO_CONTENT).header(SERVER_TIMING, timing));455
+ }456
+457
+ let outcome = if self.eq < 50 {458
+ self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);459
+ Outcome::json(460
+ StatusCode::PARTIAL_CONTENT,461
+ json!({462
+ "detail": "Partial gains were applied. EQ was below the band.",463
+ "length": round3(length / 2.0),464
+ "girth": round3(girth / 2.0),465
+ }),466
+ )467
+ } else if total >= dc + 5 {468
+ self.gain(length, girth, profile.foreskin_growth);469
+ Outcome::json(470
+ StatusCode::OK,471
+ json!({472
+ "detail": "Gains were applied.",473
+ "length": round3(length),474
+ "girth": round3(girth),475
+ }),476
+ )477
+ } else {478
+ let eta = self.rng.random_range(1..=4);479
+ self.pending.push(super::Pending {480
+ due: self.turn + eta,481
+ length,482
+ girth,483
+ });484
+ self.gain(0.0, 0.0, profile.foreskin_growth);485
+ Outcome::json(486
+ StatusCode::ACCEPTED,487
+ json!({488
+ "detail": "Gains have been queued. They may arrive.",489
+ "eta_turns": eta,490
+ }),491
+ )492
+ };493
+ Ok(outcome.header(SERVER_TIMING, timing))494
+ }495
+496
+ fn gossip(&mut self, outcome: Outcome) -> Outcome {497
+ let reportable = matches!(outcome.status.as_u16(), 200 | 202 | 206);498
+ if !self.roommate_told || !reportable {499
+ return outcome;500
+ }501
+ self.roommate_told = false;502
+ let mut outcome = outcome;503
+ outcome.status = StatusCode::ALREADY_REPORTED;504
+ if let super::Payload::Json(body) = &mut outcome.payload {505
+ body["detail"] = json!("The roommate has already told everyone.");506
+ }507
+ outcome508
+ }509
+510
+ fn warm_up(&mut self) -> Result<Outcome, Problem> {511
+ if self.floor == Floor::Hotel && self.rng.random_ratio(1, 3) {512
+ return Err(Problem::new(513
+ StatusCode::SERVICE_UNAVAILABLE,514
+ "service-unavailable",515
+ "Service is unavailable. The server estimates next week. The server is being pessimistic.",516
+ )517
+ .header(518
+ header::RETRY_AFTER,519
+ httpdate::fmt_http_date(SystemTime::now() + WEEK),520
+ ));521
+ }522
+ let (boost, detail) = if self.floor == Floor::Bathroom {523
+ (524
+ 35,525
+ "Warmed up. The shower is hot. The roommate's will not be.",526
+ )527
+ } else {528
+ (25, "Warmed up. The towel has cooled.")529
+ };530
+ self.touch();531
+ self.warmed_up = true;532
+ self.eq = (self.eq + boost).min(100);533
+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))534
+ }535
+536
+ fn ice(&mut self) -> Result<Outcome, Problem> {537
+ if self.items.ice_packs == 0 {538
+ return Err(Problem::new(539
+ StatusCode::FAILED_DEPENDENCY,540
+ "no-ice",541
+ "The ice technique depends on an ice pack. There are none.",542
+ ));543
+ }544
+ self.touch();545
+ self.items.ice_packs -= 1;546
+ self.eq = (self.eq - 40).max(0);547
+ Ok(Outcome::json(548
+ StatusCode::OK,549
+ json!({ "detail": "EQ has been reduced. The ice pack is gone." }),550
+ ))551
+ }552
+553
+ fn supplement(&mut self) -> Result<Outcome, Problem> {554
+ if self.items.supplements == 0 {555
+ return Err(Problem::new(556
+ StatusCode::FAILED_DEPENDENCY,557
+ "no-supplement",558
+ "The supplement technique depends on a supplement.",559
+ ));560
+ }561
+ self.items.supplements -= 1;562
+ self.touch();563
+ Err(Problem::new(564
+ StatusCode::BAD_GATEWAY,565
+ "bad-gateway",566
+ "The upstream returned an invalid response.",567
+ )568
+ .with("upstream", "Pump-Bro"))569
+ }570
+571
+ fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {572
+ self.commit(5, None, now, 1);573
+ self.edges += 1;574
+ self.eq = (self.eq + 11).min(100);575
+ self.girth += 0.004;576
+ if self.edges >= 3 {577
+ self.edges = 0;578
+ self.stamina = 0;579
+ self.refractory_until = Some(now + REFRACTORY);580
+ return Err(Problem::new(581
+ StatusCode::LOOP_DETECTED,582
+ "edging-loop",583
+ "Edging has looped. The server detected an infinite loop and terminated it.",584
+ ));585
+ }586
+ self.hazards(5)?;587
+ Ok(Outcome::json(588
+ StatusCode::OK,589
+ json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.edges }),590
+ ))591
+ }592
+593
+ fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {594
+ self.commit(4, None, now, 1);595
+ self.hazards(4)?;596
+ self.temp_length += 0.5;597
+ Ok(Outcome::json(598
+ StatusCode::TEMPORARY_REDIRECT,599
+ json!({ "detail": "Gains applied. They are temporary.", "length": 0.5 }),600
+ )601
+ .header(header::LOCATION, format!("/runs/{}/length", self.id)))602
+ }603
+}@@ -0,0 +1,666 @@
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::time::{Duration, Instant};9
+10
+use axum::{11
+ Json,12
+ http::{HeaderName, HeaderValue, StatusCode, header},13
+ response::{IntoResponse, Response},14
+};15
+use rand::{Rng, SeedableRng, rngs::StdRng};16
+use serde_json::{Value, json};17
+18
+use crate::problem::Problem;19
+20
+pub const MAX_STAMINA: i32 = 10;21
+pub const SUSPICION_LIMIT: u8 = 5;22
+pub const BRANCH_TURN: u32 = 10;23
+pub const FLOOR_TURNS: u32 = 6;24
+pub const STARTING_CASH: u32 = 20;25
+pub const FLOOR_ALLOWANCE: u32 = 10;26
+pub const REFRACTORY: Duration = Duration::from_secs(30);27
+pub const SELF_REPORT_INFLATION: f64 = 1.5;28
+pub const MM_PER_INCH: f64 = 25.4;29
+pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";30
+pub const CLINIC_ALLOW: &str = "GET, HEAD, PUT, PATCH, DELETE, OPTIONS, TRACE";31
+32
+pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");33
+pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");34
+35
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]36
+pub enum Floor {37
+ Bedroom,38
+ Bathroom,39
+ LockerRoom,40
+ Grandmas,41
+ Hotel,42
+ Forum,43
+ Clinic,44
+}45
+46
+impl Floor {47
+ const ALL: [Self; 7] = [48
+ Self::Bedroom,49
+ Self::Bathroom,50
+ Self::LockerRoom,51
+ Self::Grandmas,52
+ Self::Hotel,53
+ Self::Forum,54
+ Self::Clinic,55
+ ];56
+57
+ pub fn number(self) -> usize {58
+ Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + 159
+ }60
+61
+ fn next(self) -> Option<Self> {62
+ Self::ALL.get(self.number()).copied()63
+ }64
+65
+ pub fn name(self) -> &'static str {66
+ match self {67
+ Self::Bedroom => "the bedroom",68
+ Self::Bathroom => "the shared bathroom",69
+ Self::LockerRoom => "the gym locker room",70
+ Self::Grandmas => "grandma's house for Thanksgiving",71
+ Self::Hotel => "a business trip hotel",72
+ Self::Forum => "the forum",73
+ Self::Clinic => "the urologist's office",74
+ }75
+ }76
+77
+ pub fn note(self) -> &'static str {78
+ match self {79
+ Self::Bedroom => "The door does not lock. The cat is present.",80
+ Self::Bathroom => "The water is hot. The roommate showers at eight.",81
+ Self::LockerRoom => {82
+ "It is cold. Discretion is advised. Everyone here is lying. Pump-Bro is by the lockers."83
+ }84
+ Self::Grandmas => {85
+ "The walls are thin. Mom is here, and she has been reading about Digest authentication."86
+ }87
+ Self::Hotel => {88
+ "The wifi requires agreement. Housekeeping does not knock. Pump-Bro is in the lobby."89
+ }90
+ Self::Forum => "Nothing physical happens here. The Elder has a spreadsheet.",91
+ Self::Clinic => "The urologist will see you now. He is bored.",92
+ }93
+ }94
+95
+ fn observer(self) -> Option<&'static str> {96
+ match self {97
+ Self::Bedroom => Some("cat"),98
+ Self::LockerRoom => Some("greg"),99
+ Self::Grandmas => Some("grandma"),100
+ _ => None,101
+ }102
+ }103
+104
+ pub fn shop_open(self) -> bool {105
+ matches!(self, Self::LockerRoom | Self::Hotel)106
+ }107
+108
+ pub fn has_mom(self) -> bool {109
+ matches!(self, Self::Bedroom | Self::Grandmas)110
+ }111
+}112
+113
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]114
+pub enum Grip {115
+ Overhand,116
+ Underhand,117
+}118
+119
+impl Grip {120
+ pub fn name(self) -> &'static str {121
+ match self {122
+ Self::Overhand => "overhand",123
+ Self::Underhand => "underhand",124
+ }125
+ }126
+127
+ pub fn parse(protocol: &str) -> Option<Self> {128
+ match protocol.to_ascii_lowercase().as_str() {129
+ "overhand" => Some(Self::Overhand),130
+ "underhand" => Some(Self::Underhand),131
+ _ => None,132
+ }133
+ }134
+}135
+136
+#[derive(Default)]137
+pub struct Items {138
+ pub ruler: bool,139
+ pub ice_packs: u32,140
+ pub door_locks: u32,141
+ pub dnd_sign: bool,142
+ pub pump_pro: bool,143
+ pub extender: bool,144
+ pub ring: bool,145
+ pub supplements: u32,146
+ pub cage: bool,147
+ pub spreadsheet: bool,148
+ pub teapot_spout: bool,149
+}150
+151
+enum Payload {152
+ Empty,153
+ Json(Value),154
+ Raw(&'static str, Vec<u8>),155
+}156
+157
+pub struct Outcome {158
+ status: StatusCode,159
+ payload: Payload,160
+ headers: Vec<(HeaderName, String)>,161
+}162
+163
+impl Outcome {164
+ pub fn empty(status: StatusCode) -> Self {165
+ Self {166
+ status,167
+ payload: Payload::Empty,168
+ headers: Vec::new(),169
+ }170
+ }171
+172
+ pub fn json(status: StatusCode, body: Value) -> Self {173
+ Self {174
+ payload: Payload::Json(body),175
+ ..Self::empty(status)176
+ }177
+ }178
+179
+ pub fn raw(status: StatusCode, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {180
+ Self {181
+ payload: Payload::Raw(content_type, body.into()),182
+ ..Self::empty(status)183
+ }184
+ }185
+186
+ pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {187
+ self.headers.push((name, value.into()));188
+ self189
+ }190
+191
+ pub fn status(&self) -> StatusCode {192
+ self.status193
+ }194
+}195
+196
+impl IntoResponse for Outcome {197
+ fn into_response(self) -> Response {198
+ let mut response = match self.payload {199
+ // RFC 9110 §15.3.7 obliges a 206 to be a byte range of the full representation, so it is.200
+ Payload::Json(body) if self.status == StatusCode::PARTIAL_CONTENT => {201
+ let full = body.to_string().into_bytes();202
+ let half = full.len() / 2;203
+ let range = format!("bytes 0-{}/{}", half - 1, full.len());204
+ (205
+ self.status,206
+ [207
+ (header::CONTENT_TYPE, "application/json".to_owned()),208
+ (header::CONTENT_RANGE, range),209
+ ],210
+ full[..half].to_vec(),211
+ )212
+ .into_response()213
+ }214
+ Payload::Json(body) => (self.status, Json(body)).into_response(),215
+ Payload::Raw(content_type, body) => {216
+ (self.status, [(header::CONTENT_TYPE, content_type)], body).into_response()217
+ }218
+ Payload::Empty => self.status.into_response(),219
+ };220
+ for (name, value) in self.headers {221
+ let value = HeaderValue::from_str(&value).expect("header values are visible ascii");222
+ response.headers_mut().append(name, value);223
+ }224
+ response225
+ }226
+}227
+228
+pub struct Pending {229
+ due: u32,230
+ length: f64,231
+ girth: f64,232
+}233
+234
+pub struct Ending {235
+ pub cause: &'static str,236
+ pub detail: String,237
+ pub turn: u32,238
+}239
+240
+pub struct Run {241
+ pub id: String,242
+ pub origin_etag: String,243
+ pub version: u64,244
+ pub turn: u32,245
+ pub floor: Floor,246
+ pub floor_turns: u32,247
+ pub length: f64,248
+ pub temp_length: f64,249
+ pub girth: f64,250
+ pub foreskin: Option<f64>,251
+ pub eq: i32,252
+ pub stamina: i32,253
+ pub suspicion: u8,254
+ pub shame_base: u32,255
+ pub shame: u32,256
+ pub cash: u32,257
+ pub grip: Grip,258
+ pub items: Items,259
+ pub warmed_up: bool,260
+ pub teapot: bool,261
+ pub peyronie: bool,262
+ pub put_applied: bool,263
+ pub forum_read: bool,264
+ pub branch_pending: bool,265
+ pub mom_present: bool,266
+ pub mom_errand: &'static str,267
+ pub observed: Option<&'static str>,268
+ pub measured: bool,269
+ pub cured: bool,270
+ pub roommate_told: bool,271
+ pub wifi_accepted: bool,272
+ pub verified: bool,273
+ pub elder_beaten: bool,274
+ pub edges: u8,275
+ pub drawer_searches: u8,276
+ pub intake_attempts: usize,277
+ pub nonce: String,278
+ pub heard: Vec<String>,279
+ pub refractory_until: Option<Instant>,280
+ pub pending: Vec<Pending>,281
+ pub ended: Option<Ending>,282
+ rng: StdRng,283
+}284
+285
+pub fn round2(x: f64) -> f64 {286
+ (x * 100.0).round() / 100.0287
+}288
+289
+fn round3(x: f64) -> f64 {290
+ (x * 1000.0).round() / 1000.0291
+}292
+293
+fn tenths(x: f64) -> i64 {294
+ (x * 10.0).round() as i64295
+}296
+297
+fn not_extended() -> Problem {298
+ Problem::new(299
+ StatusCode::NOT_EXTENDED,300
+ "not-extended",301
+ "The requested extension was not applied. The server has recorded the attempt.",302
+ )303
+}304
+305
+pub fn parse_credentials<'a>(authorization: &'a str, scheme: &str) -> Option<&'a str> {306
+ let (given, rest) = authorization.trim().split_once(' ')?;307
+ given308
+ .eq_ignore_ascii_case(scheme)309
+ .then(|| rest.trim().trim_matches('"'))310
+}311
+312
+pub fn etag_matches(condition: &str, etag: &str) -> bool {313
+ condition314
+ .split(',')315
+ .map(str::trim)316
+ .any(|tag| tag == "*" || tag.trim_start_matches("W/") == etag)317
+}318
+319
+pub fn shaft(mm: usize) -> String {320
+ format!("8{}D", "=".repeat(mm.saturating_sub(2)))321
+}322
+323
+impl Run {324
+ pub fn new(id: String, seed: u64, shame_base: u32) -> Self {325
+ let mut rng = StdRng::seed_from_u64(seed);326
+ let length = round2(rng.random_range(4.5..6.5));327
+ let girth = round2(rng.random_range(4.0..5.0));328
+ let foreskin = round2(rng.random_range(0.4..2.4));329
+ let mut run = Self {330
+ id,331
+ origin_etag: String::new(),332
+ version: 0,333
+ turn: 0,334
+ floor: Floor::Bedroom,335
+ floor_turns: 0,336
+ length,337
+ temp_length: 0.0,338
+ girth,339
+ foreskin: Some(foreskin),340
+ eq: 20,341
+ stamina: MAX_STAMINA,342
+ suspicion: 0,343
+ shame_base,344
+ shame: 0,345
+ cash: STARTING_CASH,346
+ grip: Grip::Overhand,347
+ items: Items {348
+ ice_packs: 1,349
+ ..Items::default()350
+ },351
+ warmed_up: false,352
+ teapot: false,353
+ peyronie: false,354
+ put_applied: false,355
+ forum_read: false,356
+ branch_pending: false,357
+ mom_present: false,358
+ mom_errand: "She heard a noise.",359
+ observed: None,360
+ measured: false,361
+ cured: false,362
+ roommate_told: false,363
+ wifi_accepted: false,364
+ verified: false,365
+ elder_beaten: false,366
+ edges: 0,367
+ drawer_searches: 0,368
+ intake_attempts: 0,369
+ nonce: String::new(),370
+ heard: Vec::new(),371
+ refractory_until: None,372
+ pending: Vec::new(),373
+ ended: None,374
+ rng,375
+ };376
+ run.origin_etag = run.etag();377
+ run.nonce = run.fresh_nonce();378
+ run379
+ }380
+381
+ pub fn etag(&self) -> String {382
+ format!("\"{}-{}\"", self.id, self.version)383
+ }384
+385
+ pub fn touch(&mut self) {386
+ self.version += 1;387
+ }388
+389
+ fn fresh_nonce(&mut self) -> String {390
+ format!("{:016x}", self.rng.random::<u64>())391
+ }392
+393
+ pub fn current_length(&self) -> f64 {394
+ self.length + self.temp_length395
+ }396
+397
+ pub fn self_reported(&self) -> f64 {398
+ self.current_length() + SELF_REPORT_INFLATION399
+ }400
+401
+ pub fn total_shame(&self) -> u32 {402
+ self.shame_base + self.shame403
+ }404
+405
+ pub fn status(&self) -> &'static str {406
+ match (&self.ended, self.cured) {407
+ (Some(_), _) => "gone",408
+ (None, true) => "normal",409
+ (None, false) => "alive",410
+ }411
+ }412
+413
+ pub fn view(&self) -> Value {414
+ let conditions: Vec<&str> = [415
+ (self.teapot, "short and stout"),416
+ (self.peyronie, "curved"),417
+ (self.items.cage, "caged"),418
+ ]419
+ .into_iter()420
+ .filter_map(|(active, name)| active.then_some(name))421
+ .collect();422
+ let mut view = json!({423
+ "id": self.id,424
+ "status": self.status(),425
+ "turn": self.turn,426
+ "floor": { "number": self.floor.number(), "name": self.floor.name() },427
+ "self_reported_length": round2(self.self_reported()),428
+ "girth": round2(self.girth),429
+ "foreskin": self.foreskin.map(round2),430
+ "eq": self.eq,431
+ "stamina": self.stamina,432
+ "suspicion": self.suspicion,433
+ "shame": self.total_shame(),434
+ "cash": self.cash,435
+ "grip": self.grip.name(),436
+ "warmed_up": self.warmed_up,437
+ "conditions": conditions,438
+ });439
+ if self.items.ruler {440
+ view["length"] = json!(round2(self.current_length()));441
+ }442
+ view443
+ }444
+445
+ pub fn floor_view(&self) -> Value {446
+ let id = &self.id;447
+ let turns_required = match self.floor {448
+ Floor::Forum | Floor::Clinic => Value::Null,449
+ _ => json!(FLOOR_TURNS),450
+ };451
+ let mut view = json!({452
+ "number": self.floor.number(),453
+ "name": self.floor.name(),454
+ "note": self.floor.note(),455
+ "turns": self.floor_turns,456
+ "turns_required": turns_required,457
+ "exit": { "href": format!("/runs/{id}/floor"), "method": "POST" },458
+ });459
+ match self.floor {460
+ Floor::LockerRoom => {461
+ view["others"] = json!([462
+ { "name": "Greg", "self_reported_length": 9.5 },463
+ { "name": "Dmitri", "self_reported_length": 8.75 },464
+ { "name": "a man in a towel", "self_reported_length": 11.0 },465
+ ]);466
+ }467
+ Floor::Hotel => view["portal"] = json!(format!("/runs/{id}/portal")),468
+ Floor::Forum => {469
+ view["thread"] = json!(FORUM_THREAD);470
+ view["elder"] = json!(format!("/runs/{id}/elder"));471
+ }472
+ Floor::Clinic => view["diagnosis"] = json!(format!("/runs/{id}/diagnosis")),473
+ _ => {}474
+ }475
+ if self.floor.shop_open() {476
+ view["shop"] = json!(format!("/runs/{id}/shop"));477
+ }478
+ view479
+ }480
+481
+ pub fn inventory(&self) -> Vec<(&'static str, &'static str)> {482
+ let i = &self.items;483
+ let owned = [484
+ (485
+ true,486
+ "warm-towel",487
+ "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",488
+ ),489
+ (490
+ i.ice_packs > 0,491
+ "ice-pack",492
+ "Lowers EQ by 40. Use technique=ice.",493
+ ),494
+ (495
+ i.ruler,496
+ "ruler",497
+ "Measures honestly. The length resource now carries a Content-Length.",498
+ ),499
+ (500
+ i.door_locks > 0,501
+ "door-lock",502
+ "Keeps the roommate out once.",503
+ ),504
+ (505
+ i.dnd_sign,506
+ "do-not-disturb-sign",507
+ "Works on housekeeping. Mom does not read signs.",508
+ ),509
+ (510
+ i.pump_pro,511
+ "pump-pro",512
+ "Use technique=pump. Gains are temporary.",513
+ ),514
+ (515
+ i.extender,516
+ "extender",517
+ "Passive gains each turn. Takes up room. Can be removed with DELETE.",518
+ ),519
+ (520
+ i.ring,521
+ "cock-ring",522
+ "Girth gains doubled. Rupture risk raised. Responses are compressed if you accept gzip. Can be removed with DELETE.",523
+ ),524
+ (i.supplements > 0, "supplement", "Use technique=supplement."),525
+ (i.cage, "chastity-cage", "Locked."),526
+ (527
+ i.spreadsheet,528
+ "spreadsheet",529
+ "Charts your progress. Every line is flat.",530
+ ),531
+ (532
+ i.teapot_spout,533
+ "teapot-spout",534
+ "Does nothing. Cannot be dropped.",535
+ ),536
+ ];537
+ owned538
+ .into_iter()539
+ .filter_map(|(have, slug, description)| have.then_some((slug, description)))540
+ .collect()541
+ }542
+543
+ pub fn end(&mut self, cause: &'static str, detail: &str) {544
+ self.ended = Some(Ending {545
+ cause,546
+ detail: detail.into(),547
+ turn: self.turn,548
+ });549
+ }550
+551
+ fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {552
+ let Some(until) = self.refractory_until else {553
+ return Ok(());554
+ };555
+ if now < until {556
+ let seconds = (until - now).as_secs() + 1;557
+ return Err(Problem::new(558
+ StatusCode::TOO_MANY_REQUESTS,559
+ "overtrained",560
+ "Stamina is exhausted. The refractory period is in effect.",561
+ )562
+ .header(header::RETRY_AFTER, seconds.to_string()));563
+ }564
+ self.refractory_until = None;565
+ self.stamina = MAX_STAMINA;566
+ self.eq = 20;567
+ self.warmed_up = false;568
+ self.touch();569
+ Ok(())570
+ }571
+572
+ fn realize(&mut self) {573
+ let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)574
+ .into_iter()575
+ .partition(|p| p.due <= self.turn);576
+ self.pending = waiting;577
+ for p in due {578
+ self.length += p.length;579
+ self.girth += p.girth;580
+ }581
+ }582
+583
+ fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {584
+ self.length += length;585
+ self.girth += girth;586
+ if let Some(f) = &mut self.foreskin {587
+ *f += foreskin;588
+ }589
+ }590
+591
+ fn rupture(&mut self) -> Problem {592
+ let detail = if self.items.ice_packs > 0 {593
+ "A vessel has exceeded its rated pressure. The run has ended. An ice pack was available and was not used."594
+ } else {595
+ "A vessel has exceeded its rated pressure. The run has ended."596
+ };597
+ self.end("vascular-event", detail);598
+ Problem::new(StatusCode::INTERNAL_SERVER_ERROR, "vascular-event", detail)599
+ .with("eq_at_failure", self.eq)600
+ .with("turn", self.turn)601
+ }602
+603
+ fn next_floor(&mut self) {604
+ let Some(next) = self.floor.next() else {605
+ return;606
+ };607
+ self.floor = next;608
+ self.floor_turns = 0;609
+ self.temp_length = 0.0;610
+ self.teapot = false;611
+ self.warmed_up = false;612
+ self.eq = 20;613
+ self.suspicion = 0;614
+ self.mom_present = false;615
+ self.roommate_told = false;616
+ self.drawer_searches = 0;617
+ self.edges = 0;618
+ self.cash += FLOOR_ALLOWANCE;619
+ self.nonce = self.fresh_nonce();620
+ self.touch();621
+ }622
+623
+ pub fn advance(&mut self) -> Result<Outcome, Problem> {624
+ match self.floor {625
+ Floor::Clinic => {626
+ return Err(Problem::new(627
+ StatusCode::CONFLICT,628
+ "no-further-floor",629
+ "There is no further floor. There is only the diagnosis.",630
+ ));631
+ }632
+ Floor::Forum if !self.elder_beaten => {633
+ return Err(Problem::new(634
+ StatusCode::CONFLICT,635
+ "elder-unaddressed",636
+ "The Elder has not been addressed.",637
+ )638
+ .with("elder", format!("/runs/{}/elder", self.id)));639
+ }640
+ Floor::Forum => {}641
+ _ if self.floor_turns < FLOOR_TURNS => {642
+ return Err(Problem::new(643
+ StatusCode::TOO_EARLY,644
+ "floor-not-over",645
+ format!(646
+ "The floor is not over. {} turns remain.",647
+ FLOOR_TURNS - self.floor_turns648
+ ),649
+ ));650
+ }651
+ _ => {}652
+ }653
+ let reverted = self.temp_length;654
+ self.next_floor();655
+ let mut body = json!({656
+ "detail": format!("You have arrived at {}.", self.floor.name()),657
+ "floor": self.floor.number(),658
+ "note": self.floor.note(),659
+ });660
+ if reverted > 0.0 {661
+ body["reverted"] = json!(round2(reverted));662
+ }663
+ Ok(Outcome::json(StatusCode::SEE_OTHER, body)664
+ .header(header::LOCATION, format!("/runs/{}/floor", self.id)))665
+ }666
+}@@ -2,6 +2,7 @@ use std::{
22
collections::HashMap,33
convert::Infallible,44
fmt::Write as _,5
+ io::Write as _,56
sync::{Arc, Mutex},67
time::Instant,78
};@@ -15,13 +16,16 @@ use axum::{
1516
response::{IntoResponse, Response},1617
routing::any,1718
};19
+use flate2::{Compression, write::GzEncoder};20
+use hyper_util::rt::TokioIo;1821
use rand::Rng;1922
use serde_json::{Value, json};23
+use tokio::io::AsyncWriteExt;2024
2125
use crate::{2226
game::{23
- EXCUSES, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, PatchOp, Run, SUSPICION_LIMIT,24
- Technique, shaft,27
+ ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,28
+ PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,2529
},2630
problem::{PROBLEM_BASE, Problem},2731
};@@ -29,6 +33,8 @@ use crate::{
2933
const REL_BASE: &str = "https://jelq.dev/rels/";3034
const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";3135
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.";36
+const MAX_URI: usize = 512;37
+const MAX_HEADER: usize = 1024;3238
3339
#[derive(Default)]3440
pub struct AppState {@@ -43,6 +49,16 @@ impl AppState {
4349
4450
type Shared = Arc<AppState>;4551
52
+#[derive(Clone)]53
+struct Compress;54
+55
+/// Which household hazards stand between the request and the run.56
+#[derive(Clone, Copy, PartialEq, Eq)]57
+enum Scope {58
+ Home,59
+ Pockets,60
+}61
+4662
pub fn app() -> Router {4763
router(Arc::default())4864
}@@ -58,7 +74,14 @@ pub fn router(state: Shared) -> Router {
5874
.route("/runs/{id}/techniques", any(techniques))5975
.route("/runs/{id}/drawer", any(drawer))6076
.route("/runs/{id}/branches/{choice}", any(branch))77
+ .route("/runs/{id}/floor", any(floor))78
+ .route("/runs/{id}/shop", any(shop))79
+ .route("/runs/{id}/shop/{item}", any(buy))80
+ .route("/runs/{id}/portal", any(portal))81
+ .route("/runs/{id}/elder", any(elder))82
+ .route("/runs/{id}/grip", any(grip))6183
.route("/runs/{id}/diagnosis", any(diagnosis))84
+ .route("/forum/threads/{year}", any(thread))6285
.route("/graveyard", any(graveyard))6386
.route("/leaderboard", any(leaderboard))6487
.fallback(not_found)@@ -66,8 +89,74 @@ pub fn router(state: Shared) -> Router {
6689
.with_state(state)6790
}6891
92
+fn reject(request: &Request) -> Option<Problem> {93
+ if request.version() < Version::HTTP_11 {94
+ return Some(Problem::new(95
+ StatusCode::HTTP_VERSION_NOT_SUPPORTED,96
+ "pdf",97
+ "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.",98
+ ));99
+ }100
+ if request.uri().to_string().len() > MAX_URI {101
+ return Some(Problem::new(102
+ StatusCode::URI_TOO_LONG,103
+ "uri-too-long",104
+ "The URI is longer than anything else here.",105
+ ));106
+ }107
+ if request.headers().values().any(|v| v.len() > MAX_HEADER) {108
+ return Some(Problem::new(109
+ StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,110
+ "head-too-large",111
+ "Head too large.",112
+ ));113
+ }114
+ let expectation = request.headers().get(header::EXPECT)?;115
+ if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") {116
+ return None;117
+ }118
+ Some(Problem::new(119
+ StatusCode::EXPECTATION_FAILED,120
+ "expectations",121
+ format!(122
+ "Expect: {} cannot be met.",123
+ expectation.to_str().unwrap_or("that")124
+ ),125
+ ))126
+}127
+128
+async fn compress(response: Response) -> Response {129
+ let (mut parts, body) = response.into_parts();130
+ let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {131
+ return Response::from_parts(parts, Body::empty());132
+ };133
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());134
+ if encoder.write_all(&bytes).is_err() {135
+ return Response::from_parts(parts, Body::from(bytes));136
+ }137
+ let Ok(gzipped) = encoder.finish() else {138
+ return Response::from_parts(parts, Body::from(bytes));139
+ };140
+ parts141
+ .headers142
+ .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));143
+ parts.headers.remove(header::CONTENT_LENGTH);144
+ parts145
+ .headers146
+ .append(header::VARY, HeaderValue::from_static("Accept-Encoding"));147
+ Response::from_parts(parts, Body::from(gzipped))148
+}149
+69150
async fn chrome(request: Request, next: Next) -> Response {70
- let mut response = next.run(request).await;151
+ let gzip = header_str(request.headers(), header::ACCEPT_ENCODING)152
+ .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));153
+ let mut response = match reject(&request) {154
+ Some(problem) => problem.into_response(),155
+ None => next.run(request).await,156
+ };157
+ if gzip && response.extensions().get::<Compress>().is_some() {158
+ response = compress(response).await;159
+ }71160
let headers = response.headers_mut();72161
headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));73162
headers.insert("x-powered-by", HeaderValue::from_static("blood"));@@ -156,20 +245,26 @@ fn links(run: &Run) -> String {
156245
let id = &run.id;157246
let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];158247
for rel in [248
+ "floor",159249
"length",160250
"foreskin",161251
"inventory",162252
"techniques",163253
"drawer",254
+ "grip",164255
"diagnosis",165256
] {166257
links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));167258
}259
+ if run.floor.shop_open() {260
+ links.push(format!("</runs/{id}/shop>; rel=\"{REL_BASE}shop\""));261
+ }168262
links.join(", ")169263
}170264
171265
fn finish(run: &Run, mut response: Response) -> Response {172
- if response.status() == StatusCode::NOT_MODIFIED {266
+ let status = response.status();267
+ if status == StatusCode::NOT_MODIFIED {173268
return response;174269
}175270
let mut stats = vec![@@ -189,10 +284,12 @@ fn finish(run: &Run, mut response: Response) -> Response {
189284
format!("{}/{SUSPICION_LIMIT}", run.suspicion),190285
),191286
("x-shame", run.total_shame().to_string()),287
+ ("x-cash", run.cash.to_string()),288
+ ("x-floor", run.floor.number().to_string()),192289
("link", links(run)),193290
];194
- if run.observed {195
- stats.push(("x-observed-by", "cat".into()));291
+ if let Some(observer) = run.observed {292
+ stats.push(("x-observed-by", observer.into()));196293
}197294
if run.mom_present {198295
stats.push(("via", "1.1 mom".into()));@@ -206,13 +303,17 @@ fn finish(run: &Run, mut response: Response) -> Response {
206303
"set-cookie",207304
format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()),208305
));209
- if run.ruler {306
+ if run.items.ruler {210307
stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}")));211308
}212309
let headers = response.headers_mut();213310
for (name, value) in stats {214311
headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));215312
}313
+ let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;314
+ if run.items.ring && !bodiless {315
+ response.extensions_mut().insert(Compress);316
+ }216317
response217318
}218319
@@ -225,11 +326,25 @@ fn gone(run: &Run) -> Response {
225326
.into_response()226327
}227328
329
+fn captive_portal(run: &Run) -> Response {330
+ Problem::new(331
+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED,332
+ "captive-portal",333
+ "The network requires authentication. The hotel would like you to agree to some terms.",334
+ )335
+ .with("portal", format!("/runs/{}/portal", run.id))336
+ .header(337
+ header::LINK,338
+ format!("</runs/{}/portal>; rel=\"{REL_BASE}portal\"", run.id),339
+ )340
+ .into_response()341
+}342
+228343
fn with_run(229344
state: &AppState,230345
id: &str,231346
headers: &HeaderMap,232
- mom_applies: bool,347
+ scope: Scope,233348
handle: impl FnOnce(&mut Run) -> Response,234349
) -> Response {235350
let mut runs = state.runs.lock().unwrap();@@ -244,12 +359,17 @@ fn with_run(
244359
if run.ended.is_some() {245360
return gone(run);246361
}247
- run.observed = false;248
- if mom_applies249
- && run.mom_present250
- && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))251
- {252
- return finish(run, problem.into_response());362
+ run.observed = None;363
+ if scope == Scope::Home {364
+ if run.floor == Floor::Hotel && !run.wifi_accepted {365
+ return finish(run, captive_portal(run));366
+ }367
+ if run.floor.has_mom()368
+ && run.mom_present369
+ && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))370
+ {371
+ return finish(run, problem.into_response());372
+ }253373
}254374
let response = handle(run);255375
finish(run, response)@@ -314,39 +434,40 @@ async fn run(
314434
headers: HeaderMap,315435
body: Bytes,316436
) -> Response {317
- const ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";318437
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.",438
+ with_run(&state, &id, &headers, Scope::Home, |run| {439
+ match method.as_str() {440
+ "GET" | "HEAD" => {441
+ let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();442
+ if accept.contains("reassurance") {443
+ return Problem::new(444
+ StatusCode::NOT_ACCEPTABLE,445
+ "no-reassurance",446
+ "The server does not produce reassurance.",447
+ )448
+ .into_response();449
+ }450
+ (451
+ StatusCode::OK,452
+ [(header::ETAG, run.etag())],453
+ Json(run.view()),327454
)328
- .into_response();455
+ .into_response()329456
}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)457
+ "JELQ" => jelq(run, &query, &headers, now),458
+ "PUT" => run.put().into_response(),459
+ "PATCH" => patch(run, &headers, &body),460
+ "DELETE" => {461
+ run.end("abandoned", "The run was abandoned.");462
+ StatusCode::NO_CONTENT.into_response()463
+ }464
+ "OPTIONS" => options(RUN_ALLOW, true),465
+ "TRACE" => {466
+ run.shame += 1;467
+ trace(&method, &uri, version, &headers)468
+ }469
+ _ => unsupported(&method, RUN_ALLOW),348470
}349
- _ => unsupported(&method, ALLOW),350471
})351472
}352473
@@ -356,29 +477,29 @@ fn jelq(
356477
headers: &HeaderMap,357478
now: Instant,358479
) -> Response {359
- let technique = match query.get("technique") {360
- None => {480
+ let techniques_href = format!("/runs/{}/techniques", run.id);481
+ let Some(names) = query.get("technique") else {482
+ return Problem::new(483
+ StatusCode::BAD_REQUEST,484
+ "technique-required",485
+ "No technique was specified.",486
+ )487
+ .with("techniques", techniques_href)488
+ .into_response();489
+ };490
+ let mut techniques = Vec::new();491
+ for name in names.split(',').map(str::trim) {492
+ let Some(technique) = Technique::parse(name) else {361493
return Problem::new(362494
StatusCode::BAD_REQUEST,363
- "technique-required",364
- "No technique was specified.",495
+ "unknown-technique",496
+ format!("{name} is not a technique."),365497
)366
- .with("techniques", format!("/runs/{}/techniques", run.id))498
+ .with("techniques", techniques_href)367499
.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
- };500
+ };501
+ techniques.push(technique);502
+ }382503
let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {383504
None => 5,384505
Some(Ok(i)) if (1..=10).contains(&i) => i,@@ -395,7 +516,7 @@ fn jelq(
395516
Ok(lube) => lube,396517
Err(problem) => return problem.into_response(),397518
};398
- respond(run.jelq(technique, intensity, lube, now))519
+ respond(run.jelq(&techniques, intensity, lube, now))399520
}400521
401522
fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {@@ -423,6 +544,71 @@ fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
423544
}424545
}425546
547
+fn millimetres(inches: f64) -> usize {548
+ (inches * MM_PER_INCH).round() as usize549
+}550
+551
+fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {552
+ let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;553
+ let start: f64 = start.trim().parse().ok()?;554
+ let end: f64 = match end.trim() {555
+ "" => total,556
+ end => end.parse().ok()?,557
+ };558
+ (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))559
+}560
+561
+fn length_response(run: &Run, headers: &HeaderMap) -> Response {562
+ let inches = if run.items.ruler {563
+ run.current_length()564
+ } else {565
+ run.self_reported()566
+ };567
+ let body = shaft(millimetres(inches));568
+ let content_type = (569
+ header::CONTENT_TYPE,570
+ "text/plain; charset=us-ascii".to_owned(),571
+ );572
+ let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());573
+574
+ if let Some(range) = header_str(headers, header::RANGE)575
+ && range.starts_with("inches=")576
+ {577
+ let Some((start, end)) = parse_inch_range(range, inches) else {578
+ return Problem::new(579
+ StatusCode::RANGE_NOT_SATISFIABLE,580
+ "range-not-satisfiable",581
+ format!("The requested range is not available. {inches:.2} inches are."),582
+ )583
+ .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))584
+ .into_response();585
+ };586
+ let from = millimetres(start).min(body.len() - 1);587
+ let to = millimetres(end).clamp(from + 1, body.len());588
+ let content_range = (589
+ header::CONTENT_RANGE,590
+ format!("inches {start:.2}-{end:.2}/{inches:.2}"),591
+ );592
+ return (593
+ StatusCode::PARTIAL_CONTENT,594
+ [content_type, accept_ranges, content_range],595
+ body[from..to].to_owned(),596
+ )597
+ .into_response();598
+ }599
+600
+ if run.items.ruler {601
+ return (StatusCode::OK, [content_type, accept_ranges], body).into_response();602
+ }603
+ let chunks: Vec<Result<Bytes, Infallible>> = body604
+ .into_bytes()605
+ .chunks(16)606
+ .map(|c| Ok(Bytes::copy_from_slice(c)))607
+ .collect();608
+ let body = Body::from_stream(futures_util::stream::iter(chunks));609
+ (StatusCode::OK, [content_type, accept_ranges], body).into_response()610
+}611
+426612
async fn length(427613
State(state): State<Shared>,428614
Path(id): Path<String>,@@ -430,24 +616,12 @@ async fn length(
430616
headers: HeaderMap,431617
) -> Response {432618
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()619
+ with_run(&state, &id, &headers, Scope::Home, |run| {620
+ match method.as_str() {621
+ "GET" | "HEAD" => length_response(run, &headers),622
+ "OPTIONS" => options(ALLOW, false),623
+ _ => unsupported(&method, ALLOW),448624
}449
- "OPTIONS" => options(ALLOW, false),450
- _ => unsupported(&method, ALLOW),451625
})452626
}453627
@@ -458,50 +632,29 @@ async fn foreskin(
458632
headers: HeaderMap,459633
) -> Response {460634
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
- )635
+ with_run(&state, &id, &headers, Scope::Home, |run| {636
+ match method.as_str() {637
+ "GET" | "HEAD" => match run.foreskin {638
+ Some(f) => (639
+ StatusCode::OK,640
+ [(header::ETAG, run.etag())],641
+ Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),642
+ )643
+ .into_response(),644
+ None => Problem::new(645
+ StatusCode::GONE,646
+ "foreskin-gone",647
+ "The foreskin is gone. It will not be returning.",648
+ )468649
.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),650
+ },651
+ "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),652
+ "OPTIONS" => options(ALLOW, false),653
+ _ => unsupported(&method, ALLOW),654
+ }479655
})480656
}481657
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
- items503
-}504
-505658
async fn inventory(506659
State(state): State<Shared>,507660
Path(id): Path<String>,@@ -509,16 +662,27 @@ async fn inventory(
509662
headers: HeaderMap,510663
) -> Response {511664
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()665
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {666
+ match method.as_str() {667
+ "GET" | "HEAD" => {668
+ let listed: Vec<Value> = run669
+ .inventory()670
+ .into_iter()671
+ .map(|(name, _)| {672
+ json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) })673
+ })674
+ .collect();675
+ Json(json!({676
+ "items": listed,677
+ "ice_packs": run.items.ice_packs,678
+ "cash": run.cash,679
+ "excuses": run.excuses(),680
+ }))681
+ .into_response()682
+ }683
+ "OPTIONS" => options(ALLOW, false),684
+ _ => unsupported(&method, ALLOW),519685
}520
- "OPTIONS" => options(ALLOW, false),521
- _ => unsupported(&method, ALLOW),522686
})523687
}524688
@@ -529,8 +693,9 @@ async fn item(
529693
headers: HeaderMap,530694
) -> Response {531695
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 {696
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {697
+ let Some((name, description)) = run.inventory().into_iter().find(|(n, _)| *n == name)698
+ else {534699
return Problem::new(535700
StatusCode::NOT_FOUND,536701
"no-such-item",@@ -542,12 +707,7 @@ async fn item(
542707
"GET" | "HEAD" => {543708
Json(json!({ "name": name, "description": description })).into_response()544709
}545
- "DELETE" => Problem::new(546
- StatusCode::FORBIDDEN,547
- "cannot-be-dropped",548
- "Items cannot be dropped.",549
- )550
- .into_response(),710
+ "DELETE" => respond(run.drop_item(name)),551711
"OPTIONS" => options(ALLOW, false),552712
_ => unsupported(&method, ALLOW),553713
}@@ -561,23 +721,25 @@ async fn techniques(
561721
headers: HeaderMap,562722
) -> Response {563723
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::ALL567
- .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()724
+ with_run(&state, &id, &headers, Scope::Home, |run| {725
+ match method.as_str() {726
+ "GET" | "HEAD" => {727
+ let listed: Vec<Value> = Technique::ALL728
+ .into_iter()729
+ .map(|t| t.describe(run.forum_read))730
+ .collect();731
+ Json(json!({732
+ "usage": format!("JELQ /runs/{}?technique=<name>[,<name>]&intensity=<1-10>", run.id),733
+ "lubricant": "Declared with Content-Type.",734
+ "lubricants": LUBRICANTS,735
+ "eq_band": [50, 70],736
+ "techniques": listed,737
+ }))738
+ .into_response()739
+ }740
+ "OPTIONS" => options(ALLOW, false),741
+ _ => unsupported(&method, ALLOW),578742
}579
- "OPTIONS" => options(ALLOW, false),580
- _ => unsupported(&method, ALLOW),581743
})582744
}583745
@@ -587,10 +749,12 @@ async fn drawer(
587749
method: Method,588750
headers: HeaderMap,589751
) -> 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"),752
+ with_run(&state, &id, &headers, Scope::Home, |run| {753
+ match method.as_str() {754
+ "POST" => respond(run.search_drawer()),755
+ "OPTIONS" => options("POST, OPTIONS", false),756
+ _ => unsupported(&method, "POST, OPTIONS"),757
+ }594758
})595759
}596760
@@ -600,13 +764,164 @@ async fn branch(
600764
method: Method,601765
headers: HeaderMap,602766
) -> 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"),767
+ with_run(&state, &id, &headers, Scope::Home, |run| {768
+ match method.as_str() {769
+ "POST" => respond(run.choose(&choice)),770
+ "OPTIONS" => options("POST, OPTIONS", false),771
+ _ => unsupported(&method, "POST, OPTIONS"),772
+ }773
+ })774
+}775
+776
+async fn floor(777
+ State(state): State<Shared>,778
+ Path(id): Path<String>,779
+ method: Method,780
+ headers: HeaderMap,781
+) -> Response {782
+ const ALLOW: &str = "GET, HEAD, POST, OPTIONS";783
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {784
+ match method.as_str() {785
+ "GET" | "HEAD" => Json(run.floor_view()).into_response(),786
+ "POST" => respond(run.advance()),787
+ "OPTIONS" => options(ALLOW, false),788
+ _ => unsupported(&method, ALLOW),789
+ }790
+ })791
+}792
+793
+async fn shop(794
+ State(state): State<Shared>,795
+ Path(id): Path<String>,796
+ method: Method,797
+ headers: HeaderMap,798
+) -> Response {799
+ const ALLOW: &str = "GET, HEAD, OPTIONS";800
+ with_run(&state, &id, &headers, Scope::Home, |run| {801
+ match method.as_str() {802
+ "GET" | "HEAD" => respond(run.shop_view().map(Json)),803
+ "OPTIONS" => options(ALLOW, false),804
+ _ => unsupported(&method, ALLOW),805
+ }806
+ })807
+}808
+809
+async fn buy(810
+ State(state): State<Shared>,811
+ Path((id, item)): Path<(String, String)>,812
+ method: Method,813
+ headers: HeaderMap,814
+) -> Response {815
+ with_run(&state, &id, &headers, Scope::Home, |run| {816
+ match method.as_str() {817
+ "POST" => respond(run.buy(&item)),818
+ "OPTIONS" => options("POST, OPTIONS", false),819
+ _ => unsupported(&method, "POST, OPTIONS"),820
+ }821
+ })822
+}823
+824
+async fn portal(825
+ State(state): State<Shared>,826
+ Path(id): Path<String>,827
+ method: Method,828
+ headers: HeaderMap,829
+) -> Response {830
+ const ALLOW: &str = "GET, HEAD, POST, OPTIONS";831
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {832
+ match method.as_str() {833
+ "GET" | "HEAD" => Json(json!({834
+ "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.",835
+ "accept": { "href": format!("/runs/{}/portal", run.id), "method": "POST" },836
+ }))837
+ .into_response(),838
+ "POST" => respond(run.accept_terms()),839
+ "OPTIONS" => options(ALLOW, false),840
+ _ => unsupported(&method, ALLOW),841
+ }842
+ })843
+}844
+845
+async fn elder(846
+ State(state): State<Shared>,847
+ Path(id): Path<String>,848
+ method: Method,849
+ headers: HeaderMap,850
+) -> Response {851
+ const ALLOW: &str = "GET, HEAD, OPTIONS";852
+ with_run(&state, &id, &headers, Scope::Home, |run| {853
+ match method.as_str() {854
+ "GET" | "HEAD" => respond(run.elder(855
+ header_str(&headers, header::PROXY_AUTHORIZATION),856
+ header_str(&headers, header::IF_MODIFIED_SINCE),857
+ )),858
+ "OPTIONS" => options(ALLOW, false),859
+ _ => unsupported(&method, ALLOW),860
+ }607861
})608862
}609863
864
+fn requested_grip(headers: &HeaderMap) -> Option<Grip> {865
+ let connection = header_str(headers, header::CONNECTION)?.to_ascii_lowercase();866
+ if !connection.split(',').any(|token| token.trim() == "upgrade") {867
+ return None;868
+ }869
+ header_str(headers, header::UPGRADE)?870
+ .split(',')871
+ .find_map(|protocol| Grip::parse(protocol.trim()))872
+}873
+874
+async fn grip(875
+ State(state): State<Shared>,876
+ Path(id): Path<String>,877
+ mut request: Request,878
+) -> Response {879
+ const ALLOW: &str = "GET, HEAD, OPTIONS";880
+ let method = request.method().clone();881
+ let headers = request.headers().clone();882
+ let wanted = requested_grip(&headers);883
+ let upgrade = wanted.map(|_| hyper::upgrade::on(&mut request));884
+ let response = with_run(&state, &id, &headers, Scope::Home, |run| {885
+ match (method.as_str(), wanted) {886
+ ("GET" | "HEAD", Some(grip)) => {887
+ run.set_grip(grip);888
+ (889
+ StatusCode::SWITCHING_PROTOCOLS,890
+ [891
+ (header::UPGRADE, grip.name()),892
+ (header::CONNECTION, "upgrade"),893
+ ],894
+ )895
+ .into_response()896
+ }897
+ ("GET" | "HEAD", None) => (898
+ StatusCode::OK,899
+ [(header::UPGRADE, "overhand, underhand")],900
+ Json(json!({901
+ "grip": run.grip.name(),902
+ "detail": "Grips are protocols. Change grip by upgrading the connection.",903
+ })),904
+ )905
+ .into_response(),906
+ ("OPTIONS", _) => options(ALLOW, false),907
+ _ => unsupported(&method, ALLOW),908
+ }909
+ });910
+ if response.status() == StatusCode::SWITCHING_PROTOCOLS911
+ && let (Some(upgrade), Some(grip)) = (upgrade, wanted)912
+ {913
+ tokio::spawn(async move {914
+ if let Ok(upgraded) = upgrade.await {915
+ let mut io = TokioIo::new(upgraded);916
+ let line = format!("You are now jelqing {}.\r\n", grip.name());917
+ let _ = io.write_all(line.as_bytes()).await;918
+ let _ = io.shutdown().await;919
+ }920
+ });921
+ }922
+ response923
+}924
+610925
async fn diagnosis(611926
State(state): State<Shared>,612927
Path(id): Path<String>,@@ -614,16 +929,45 @@ async fn diagnosis(
614929
headers: HeaderMap,615930
) -> Response {616931
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),932
+ with_run(&state, &id, &headers, Scope::Pockets, |run| {933
+ match method.as_str() {934
+ "GET" | "HEAD" => respond(run.diagnose(935
+ header_str(&headers, header::AUTHORIZATION),936
+ header_str(&headers, header::IF_NONE_MATCH),937
+ )),938
+ "OPTIONS" => options(ALLOW, false),939
+ _ => unsupported(&method, ALLOW),940
+ }624941
})625942
}626943
944
+async fn thread(Path(year): Path<String>, method: Method) -> Response {945
+ if method != Method::GET && method != Method::HEAD {946
+ return unsupported(&method, "GET, HEAD");947
+ }948
+ match year.parse::<u32>() {949
+ Ok(2009) => (950
+ [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],951
+ Json(forum_thread()),952
+ )953
+ .into_response(),954
+ Ok(y) if y > 2009 => Problem::new(955
+ StatusCode::NOT_FOUND,956
+ "no-thread",957
+ "Nobody has posted since 2009.",958
+ )959
+ .with("thread", FORUM_THREAD)960
+ .into_response(),961
+ _ => Problem::new(962
+ StatusCode::NOT_FOUND,963
+ "no-thread",964
+ "The forum did not exist yet. The problem did.",965
+ )966
+ .with("thread", FORUM_THREAD)967
+ .into_response(),968
+ }969
+}970
+627971
async fn graveyard(State(state): State<Shared>, method: Method) -> Response {628972
if method != Method::GET && method != Method::HEAD {629973
return unsupported(&method, "GET, HEAD");@@ -675,26 +1019,25 @@ async fn leaderboard(
6751019
};6761020
let runs = state.runs.lock().unwrap();6771021
let mut entries: Vec<&Run> = runs.values().collect();678
- entries.sort_by(|a, b| {679
- let key = |r: &Run| {680
- if truthful {681
- r.length682
- } else {683
- r.self_reported()684
- }685
- };686
- key(b).total_cmp(&key(a))687
- });1022
+ let key = |r: &Run| {1023
+ if truthful {1024
+ r.current_length()1025
+ } else {1026
+ r.self_reported()1027
+ }1028
+ };1029
+ entries.sort_by(|a, b| key(b).total_cmp(&key(a)));6881030
let entries: Vec<Value> = entries6891031
.into_iter()6901032
.map(|run| {6911033
let mut entry = json!({6921034
"id": run.id,6931035
"status": run.status(),1036
+ "floor": run.floor.number(),6941037
"self_reported_length": (run.self_reported() * 100.0).round() / 100.0,6951038
});6961039
if truthful {697
- entry["content_length"] = json!((run.length * MM_PER_INCH).round() as u64);1040
+ entry["content_length"] = json!(millimetres(run.current_length()));6981041
}6991042
entry7001043
})@@ -29,6 +29,10 @@ impl Problem {
2929
}))3030
}3131
32
+ pub fn status(&self) -> StatusCode {33
+ self.0.status34
+ }35
+3236
pub fn with(mut self, key: &str, value: impl Into<Value>) -> Self {3337
self.0.extensions.insert(key.into(), value.into());3438
self@@ -6,7 +6,11 @@ use axum::{
66
http::{Method, Request, StatusCode, header},77
response::Response,88
};9
-use jelqd::{AppState, game::Run, router};9
+use jelqd::{10
+ AppState,11
+ game::{Floor, Run},12
+ router,13
+};1014
use serde_json::Value;1115
use tower::ServiceExt;1216
@@ -33,10 +37,10 @@ async fn json(response: Response) -> Value {
3337
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()3438
}3539
36
-fn seeded(turn: u32) -> (Router, String) {40
+fn seeded(floor: Floor) -> (Router, String) {3741
let state = Arc::new(AppState::default());3842
let mut run = Run::new("cafebabe".into(), 69, 0);39
- run.turn = turn;43
+ run.floor = floor;4044
let etag = run.origin_etag.clone();4145
state.insert(run);4246
(router(state), etag)@@ -54,7 +58,7 @@ async fn creating_a_run_returns_location_and_etag() {
5458
5559
#[tokio::test]5660
async fn jelqing_cold_fails_the_precondition() {57
- let (app, _) = seeded(0);61
+ let (app, _) = seeded(Floor::Bedroom);5862
let response = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;5963
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);6064
assert_eq!(@@ -65,7 +69,7 @@ async fn jelqing_cold_fails_the_precondition() {
6569
6670
#[tokio::test]6771
async fn wet_without_lube_is_unsupported_media() {68
- let (app, _) = seeded(0);72
+ let (app, _) = seeded(Floor::Bedroom);6973
send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;7074
let response = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &[], "").await;7175
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);@@ -82,14 +86,14 @@ async fn wet_without_lube_is_unsupported_media() {
8286
8387
#[tokio::test]8488
async fn unknown_methods_are_not_implemented() {85
- let (app, _) = seeded(0);89
+ let (app, _) = seeded(Floor::Bedroom);8690
let response = send(&app, "MILK", "/runs/cafebabe", &[], "").await;8791
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);8892
}8993
9094
#[tokio::test]9195
async fn patch_only_accepts_the_truth() {92
- let (app, _) = seeded(0);96
+ let (app, _) = seeded(Floor::Bedroom);9397
let patch = [("content-type", "application/json-patch+json")];9498
let replace = send(9599
&app,@@ -116,7 +120,7 @@ async fn patch_only_accepts_the_truth() {
116120
117121
#[tokio::test]118122
async fn deleting_the_foreskin_requires_certainty() {119
- let (app, _) = seeded(0);123
+ let (app, _) = seeded(Floor::Bedroom);120124
let response = send(&app, "DELETE", "/runs/cafebabe/foreskin", &[], "").await;121125
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);122126
let etag = send(&app, "GET", "/runs/cafebabe/foreskin", &[], "")@@ -140,7 +144,7 @@ async fn deleting_the_foreskin_requires_certainty() {
140144
141145
#[tokio::test]142146
async fn length_is_chunked_until_the_ruler() {143
- let (app, _) = seeded(0);147
+ let (app, _) = seeded(Floor::Bedroom);144148
let lie = send(&app, "GET", "/runs/cafebabe/length", &[], "").await;145149
assert!(!lie.headers().contains_key(header::CONTENT_LENGTH));146150
let found = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;@@ -158,7 +162,7 @@ async fn length_is_chunked_until_the_ruler() {
158162
159163
#[tokio::test]160164
async fn the_clinic_escalates_toward_honesty() {161
- let (app, _) = seeded(20);165
+ let (app, _) = seeded(Floor::Clinic);162166
let mut hints = Vec::new();163167
for _ in 0..3 {164168
let response = send(@@ -182,8 +186,8 @@ async fn the_clinic_escalates_toward_honesty() {
182186
183187
#[tokio::test]184188
async fn the_true_ending_is_not_modified() {185
- let (app, etag) = seeded(20);186
- let early = seeded(3).0;189
+ let (app, etag) = seeded(Floor::Clinic);190
+ let early = seeded(Floor::Bedroom).0;187191
let response = send(&early, "GET", "/runs/cafebabe/diagnosis", &[], "").await;188192
assert_eq!(response.status(), StatusCode::TOO_EARLY);189193
@@ -222,3 +226,202 @@ async fn mom_accepts_a_known_excuse() {
222226
.await;223227
assert_eq!(response.status(), StatusCode::OK);224228
}229
+230
+fn seeded_with(floor: Floor, setup: impl FnOnce(&mut Run)) -> Router {231
+ let state = Arc::new(AppState::default());232
+ let mut run = Run::new("cafebabe".into(), 69, 0);233
+ run.floor = floor;234
+ setup(&mut run);235
+ state.insert(run);236
+ router(state)237
+}238
+239
+#[tokio::test]240
+async fn floors_end_when_they_end() {241
+ let app = seeded_with(Floor::Bedroom, |_| {});242
+ let early = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;243
+ assert_eq!(early.status(), StatusCode::TOO_EARLY);244
+ let app = seeded_with(Floor::Bedroom, |run| run.floor_turns = 6);245
+ let next = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;246
+ assert_eq!(next.status(), StatusCode::SEE_OTHER);247
+ assert_eq!(next.headers()["x-floor"], "2");248
+}249
+250
+#[tokio::test]251
+async fn pump_bro_upsells() {252
+ let closed = seeded_with(Floor::Bedroom, |_| {});253
+ let response = send(&closed, "GET", "/runs/cafebabe/shop", &[], "").await;254
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);255
+256
+ let app = seeded_with(Floor::LockerRoom, |_| {});257
+ let pump = send(&app, "POST", "/runs/cafebabe/shop/pump", &[], "").await;258
+ assert_eq!(pump.status(), StatusCode::UPGRADE_REQUIRED);259
+ assert_eq!(pump.headers()[header::UPGRADE], "pump-pro/2.0");260
+ let pro = send(&app, "POST", "/runs/cafebabe/shop/pump-pro", &[], "").await;261
+ assert_eq!(pro.status(), StatusCode::CREATED);262
+ let broke = send(&app, "POST", "/runs/cafebabe/shop/extender", &[], "").await;263
+ assert_eq!(broke.status(), StatusCode::PAYMENT_REQUIRED);264
+}265
+266
+#[tokio::test]267
+async fn combos_are_multi_status_xml() {268
+ let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));269
+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;270
+ let response = send(271
+ &app,272
+ "JELQ",273
+ "/runs/cafebabe?technique=wet,stretch",274
+ &[("content-type", "application/baby-oil")],275
+ "",276
+ )277
+ .await;278
+ assert_eq!(response.status(), StatusCode::MULTI_STATUS);279
+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();280
+ assert!(String::from_utf8_lossy(&body).contains("<D:multistatus"));281
+}282
+283
+#[tokio::test]284
+async fn the_elder_is_stale() {285
+ let app = seeded_with(Floor::Forum, |run| run.length = 5.0);286
+ let unverified = send(&app, "GET", "/runs/cafebabe/elder", &[], "").await;287
+ assert_eq!(288
+ unverified.status(),289
+ StatusCode::PROXY_AUTHENTICATION_REQUIRED290
+ );291
+ let lie = send(292
+ &app,293
+ "GET",294
+ "/runs/cafebabe/elder",295
+ &[("proxy-authorization", "Ruler 9")],296
+ "",297
+ )298
+ .await;299
+ assert_eq!(lie.status(), StatusCode::BAD_REQUEST);300
+ let csv = send(301
+ &app,302
+ "GET",303
+ "/runs/cafebabe/elder",304
+ &[("proxy-authorization", "Ruler 5.0")],305
+ "",306
+ )307
+ .await;308
+ assert_eq!(csv.status(), StatusCode::OK);309
+ let stale = send(310
+ &app,311
+ "GET",312
+ "/runs/cafebabe/elder",313
+ &[("if-modified-since", "Mon, 15 Jun 2009 00:00:00 GMT")],314
+ "",315
+ )316
+ .await;317
+ assert_eq!(stale.status(), StatusCode::NOT_MODIFIED);318
+ let exit = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;319
+ assert_eq!(exit.status(), StatusCode::SEE_OTHER);320
+}321
+322
+#[tokio::test]323
+async fn the_hotel_has_a_captive_portal() {324
+ let app = seeded_with(Floor::Hotel, |_| {});325
+ let blocked = send(&app, "GET", "/runs/cafebabe", &[], "").await;326
+ assert_eq!(327
+ blocked.status(),328
+ StatusCode::NETWORK_AUTHENTICATION_REQUIRED329
+ );330
+ let accepted = send(&app, "POST", "/runs/cafebabe/portal", &[], "").await;331
+ assert_eq!(accepted.status(), StatusCode::SEE_OTHER);332
+ let online = send(&app, "GET", "/runs/cafebabe", &[], "").await;333
+ assert_eq!(online.status(), StatusCode::OK);334
+}335
+336
+#[tokio::test]337
+async fn mom_remembers_excuses_by_thanksgiving() {338
+ let app = seeded_with(Floor::Grandmas, |run| {339
+ run.mom_present = true;340
+ run.nonce = "abc".into();341
+ run.heard.push("homework".into());342
+ });343
+ let basic = send(344
+ &app,345
+ "GET",346
+ "/runs/cafebabe",347
+ &[("authorization", "Excuse \"changing\"")],348
+ "",349
+ )350
+ .await;351
+ assert_eq!(basic.status(), StatusCode::UNAUTHORIZED);352
+ assert!(353
+ basic.headers()[header::WWW_AUTHENTICATE]354
+ .to_str()355
+ .unwrap()356
+ .starts_with("Digest")357
+ );358
+ let replay = send(359
+ &app,360
+ "GET",361
+ "/runs/cafebabe",362
+ &[(363
+ "authorization",364
+ "Digest username=\"homework\", nonce=\"abc\"",365
+ )],366
+ "",367
+ )368
+ .await;369
+ assert_eq!(370
+ json(replay).await["type"],371
+ "https://jelq.dev/problems/excuse-replayed"372
+ );373
+ let fresh = send(374
+ &app,375
+ "GET",376
+ "/runs/cafebabe",377
+ &[(378
+ "authorization",379
+ "Digest username=\"praying\", nonce=\"abc\"",380
+ )],381
+ "",382
+ )383
+ .await;384
+ assert_eq!(fresh.status(), StatusCode::OK);385
+}386
+387
+#[tokio::test]388
+async fn length_supports_inch_ranges() {389
+ let app = seeded_with(Floor::Bedroom, |_| {});390
+ let response = send(391
+ &app,392
+ "GET",393
+ "/runs/cafebabe/length",394
+ &[("range", "inches=0-1")],395
+ "",396
+ )397
+ .await;398
+ assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);399
+ assert!(400
+ response.headers()[header::CONTENT_RANGE]401
+ .to_str()402
+ .unwrap()403
+ .starts_with("inches 0.00-1.00/")404
+ );405
+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();406
+ assert_eq!(body.len(), 25);407
+ let absurd = send(408
+ &app,409
+ "GET",410
+ "/runs/cafebabe/length",411
+ &[("range", "inches=40-50")],412
+ "",413
+ )414
+ .await;415
+ assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);416
+}417
+418
+#[tokio::test]419
+async fn http_1_0_gets_the_pdf_treatment() {420
+ let request = Request::builder()421
+ .uri("/graveyard")422
+ .version(axum::http::Version::HTTP_10)423
+ .body(Body::empty())424
+ .unwrap();425
+ let response = jelqd::app().oneshot(request).await.unwrap();426
+ assert_eq!(response.status(), StatusCode::HTTP_VERSION_NOT_SUPPORTED);427
+}