an http based roguelike rpg about jelqing
Commit f16ab17c8ff6
tarball9 files changed+141-47
@@ -5,7 +5,6 @@ pub const KETTLE_ODDS: u32 = 6;
55
/// A telling is suspicion + 1 in this many, so a quiet house tolerates a raid and a noisy one does not.66
pub const KITCHEN_TOLD: u32 = 8;77
pub const RAID_NOISE: i32 = 1;8
-pub const KITCHEN_TELLINGS: u8 = 2;98
pub const WATERMELON: (u32, u32) = (2, 5);109
/// Caught with the melon is suspicion + 2 in this many.1110
pub const CAUGHT_MELONED: u32 = 9;@@ -144,5 +144,6 @@ pub struct Drawer {
144144
#[derive(Default)]145145
pub struct KitchenVisit {146146
pub(crate) kettle_boiled: bool,147
+ pub(crate) closed: bool,147148
pub(crate) melon_offered: bool,148149
}@@ -4,7 +4,7 @@ use serde_json::{Value, json};
44
55
use super::{66
Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,7
- balance::{DANGER_EQ, KITCHEN_TELLINGS},7
+ balance::DANGER_EQ,88
techniques::{Training, Utility},99
};1010
@@ -87,7 +87,7 @@ impl Run {
8787
Reach {8888
foreskin: self.body.foreskin.is_some(),8989
drawer: self.drawer_worth_searching(),90
- kitchen: self.floor.has_kitchen() && self.household.kitchen_tellings < KITCHEN_TELLINGS,90
+ kitchen: self.floor.has_kitchen() && !self.here.kitchen.closed,9191
thread: self.thread_reachable(),9292
shop: self.floor.shop_open(),9393
portal: self.needs_portal(),@@ -101,7 +101,6 @@ pub struct Household {
101101
pub(crate) mum: Option<MumVisit>,102102
/// Every excuse Mum has accepted this run. Digest Mum remembers them all.103103
pub(crate) heard: Vec<Excuse>,104
- pub(crate) kitchen_tellings: u8,105104
}106105
107106
pub struct MumVisit {@@ -135,6 +134,17 @@ fn spoken(authorization: &str) -> &str {
135134
.map_or("", |(_, rest)| rest.trim().trim_matches('"'))136135
}137136
137
+fn names_a_scheme(authorization: &str) -> bool {138
+ authorization139
+ .trim()140
+ .split_once(' ')141
+ .is_some_and(|(scheme, _)| {142
+ ["basic", "digest", "excuse"]143
+ .iter()144
+ .any(|known| known.eq_ignore_ascii_case(scheme))145
+ })146
+}147
+138148
fn turned_away(code: &'static str, detail: &'static str) -> Problem {139149
Problem::new(StatusCode::UNAUTHORIZED, code, detail)140150
}@@ -226,6 +236,17 @@ impl Run {
226236
self.confess();227237
return Ok(());228238
}239
+ if !names_a_scheme(authorization) {240
+ self.touch();241
+ return Err(turned_away(242
+ "excuse-unheard",243
+ if digest {244
+ "Mum did not catch that. At Christmas she uses Digest, with the excuse as the username."245
+ } else {246
+ "Mum did not catch that. Excuses are given as Excuse \"homework\"."247
+ },248
+ ));249
+ }229250
if parse_credentials(authorization, Scheme::Basic).is_some() {230251
return Err(self.mum_strike(turned_away(231252
"basic",@@ -2,10 +2,10 @@ use axum::http::{StatusCode, header};
22
use serde_json::json;33
44
use super::{5
- Cause, Floor, Item, Notice, Outcome, Run, Story,5
+ Cause, Floor, Item, Outcome, Run, Story,66
balance::{7
- CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,8
- KITCHEN_TOLD, MELON_SHAME, RAID_NOISE, RUPTURE_LINE, WATERMELON,7
+ CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TOLD,8
+ MELON_SHAME, RAID_NOISE, RUPTURE_LINE, WATERMELON,99
},1010
};1111
use crate::problem::Problem;@@ -20,12 +20,8 @@ impl Run {
2020
"There is no kitchen on this floor.",2121
));2222
}23
- if self.household.kitchen_tellings >= KITCHEN_TELLINGS {24
- return Err(Problem::new(25
- StatusCode::GONE,26
- "kitchen-off-limits",27
- "The kitchen is off limits. You have been told twice.",28
- ));23
+ if self.here.kitchen.closed {24
+ return Err(self.off_limits());2925
}3026
if self.online.story == Story::Pending {3127
return Ok(self.branch_offer());@@ -77,20 +73,9 @@ impl Run {
7773
u32::from(self.standing.suspicion) + 1,7874
KITCHEN_TOLD,7975
) {80
- self.household.kitchen_tellings += 1;81
- if self.household.kitchen_tellings >= KITCHEN_TELLINGS {82
- self.end_kitchen_visit();83
- return Err(Problem::new(84
- StatusCode::GONE,85
- "kitchen-off-limits",86
- "The kitchen is off limits. You have been told twice.",87
- ));88
- }89
- self.notices.push(if self.floor == Floor::Nans {90
- Notice::StopRaidingTheFridge91
- } else {92
- Notice::StopRaidingTheKitchen93
- });76
+ self.here.kitchen.closed = true;77
+ self.end_kitchen_visit();78
+ return Err(self.off_limits());9479
}9580
if melon {9681
self.here.kitchen.melon_offered = true;@@ -99,6 +84,15 @@ impl Run {
9984
Ok(self.unpack(loot))10085
}10186
87
+ fn off_limits(&self) -> Problem {88
+ let detail = if self.floor == Floor::Nans {89
+ "Stop raiding the fridge. The kitchen is off limits."90
+ } else {91
+ "Stop raiding the kitchen. The kitchen is off limits."92
+ };93
+ Problem::new(StatusCode::GONE, "kitchen-off-limits", detail)94
+ }95
+10296
fn unpack(&mut self, loot: usize) -> Outcome {10397
let (item, detail) = match loot {10498
0 => (@@ -4,8 +4,6 @@ pub enum Notice {
44
BrotherTriedTheDoor,55
GymStaffLeaveIt,66
HousekeepingReadTheSign,7
- StopRaidingTheKitchen,8
- StopRaidingTheFridge,97
KevIsTheBar,108
DrawerJammed,119
DrawerComesLoose,@@ -21,8 +19,6 @@ impl Notice {
2119
Self::BrotherTriedTheDoor => "your brother tried the door",2220
Self::GymStaffLeaveIt => "the gym staff have seen worse, and they leave it there",2321
Self::HousekeepingReadTheSign => "housekeeping read the sign",24
- Self::StopRaidingTheKitchen => "Stop raiding the kitchen.",25
- Self::StopRaidingTheFridge => "Stop raiding the fridge.",2622
Self::KevIsTheBar => "Kev is 8 inches, he says. Nobody asked you, which is worse.",2723
Self::DrawerJammed => "The drawer has jammed.",2824
Self::DrawerComesLoose => "The drawer comes loose. That took a while.",@@ -317,7 +317,7 @@ async fn raids_get_noisier_and_the_household_notices() {
317317
let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);318318
run.body.eq = 40;319319
run.standing.suspicion = suspicion;320
- run.raid_kitchen().is_ok() && run.household.kitchen_tellings == 1320
+ run.raid_kitchen().is_err() && run.here.kitchen.closed321321
})322322
.count()323323
};@@ -334,6 +334,65 @@ async fn raids_get_noisier_and_the_household_notices() {
334334
335335
let mut run = Run::new("cafebabe".into(), "cafe".into(), SEED);336336
run.body.eq = 40;337
- assert!(run.raid_kitchen().is_ok());337
+ let _ = run.raid_kitchen();338338
assert_eq!(run.standing.suspicion, 1, "every raid makes a little noise");339339
}340
+341
+#[tokio::test]342
+async fn one_telling_closes_the_kitchen_and_its_link() {343
+ let app = seeded_with(Floor::Bedroom, |run| run.standing.suspicion = 4);344
+ let mut told = None;345
+ for _ in 0..20 {346
+ let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;347
+ if raid.status() == StatusCode::GONE {348
+ told = Some(raid);349
+ break;350
+ }351
+ assert!(352
+ warnings(&raid).iter().all(|w| !w.contains("Stop raiding")),353
+ "there is no warning before the telling"354
+ );355
+ }356
+ let told = told.expect("a noisy house tells within twenty raids");357
+ assert!(358
+ json(told).await["detail"]359
+ .as_str()360
+ .unwrap()361
+ .starts_with("Stop raiding the kitchen.")362
+ );363
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;364
+ assert!(!header(&view, "link").contains("/kitchen"));365
+}366
+367
+#[tokio::test]368
+async fn an_excuse_without_a_scheme_is_explained_for_free() {369
+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);370
+ for said in ["homework", "on a call", "homework", "homework"] {371
+ let response = send(372
+ &app,373
+ "GET",374
+ "/runs/cafebabe",375
+ &[("authorization", said)],376
+ "",377
+ )378
+ .await;379
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);380
+ assert!(381
+ json(response).await["detail"]382
+ .as_str()383
+ .unwrap()384
+ .contains("Excuse \"homework\"")385
+ );386
+ }387
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;388
+ assert_eq!(header(&view, "x-shame"), "0");389
+ let excused = send(390
+ &app,391
+ "GET",392
+ "/runs/cafebabe",393
+ &[("authorization", "Excuse \"homework\"")],394
+ "",395
+ )396
+ .await;397
+ assert_eq!(excused.status(), StatusCode::OK);398
+}@@ -122,22 +122,35 @@ async fn the_kitchen_pays_out_at_the_documented_odds() {
122122
let mut oil = 0;123123
let mut drinks = 0;124124
let mut packs = 0;125
- for seed in 0..600u64 {125
+ let mut raids = 0;126
+ for seed in 0..800u64 {126127
let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);127
- run.household.kitchen_tellings = 0;128128
let held = (129129
run.items.lubes.coconut_oil,130130
run.items.stores.energy_drinks,131131
run.items.stores.ice_packs,132132
);133
- let _ = run.raid_kitchen();133
+ if run.raid_kitchen().is_err() {134
+ continue;135
+ }136
+ raids += 1;134137
oil += u32::from(run.items.lubes.coconut_oil > held.0);135138
drinks += u32::from(run.items.stores.energy_drinks > held.1);136139
packs += u32::from(run.items.stores.ice_packs > held.2);137140
}138
- assert!((170..=230).contains(&oil), "coconut {oil} in 600");139
- assert!((90..=150).contains(&drinks), "energy {drinks} in 600");140
- assert!((45..=105).contains(&packs), "ice {packs} in 600");141
+ let rate = |found: u32| f64::from(found) / f64::from(raids);142
+ assert!(143
+ (0.28..=0.39).contains(&rate(oil)),144
+ "coconut {oil} in {raids}"145
+ );146
+ assert!(147
+ (0.15..=0.25).contains(&rate(drinks)),148
+ "energy {drinks} in {raids}"149
+ );150
+ assert!(151
+ (0.08..=0.17).contains(&rate(packs)),152
+ "ice {packs} in {raids}"153
+ );141154
}142155
143156
#[tokio::test]@@ -25,13 +25,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
2525
<form id="form">2626
<h3>Method</h3>2727
<div id="methods"></div>28
-<datalist id="excuses">29
-<option value="changing"><option value="homework"><option value="pulled a muscle">30
-<option value="tidying"><option value="the washing up"><option value="fed the cat">31
-<option value="revising"><option value="on a call"><option value="wrapping presents">32
-<option value="folding the washing"><option value="hanging the lights">33
-<option value="helping Nan"><option value="peeling sprouts"><option value="the truth">34
-</datalist>28
+<datalist id="excuses"></datalist>3529
3630
<h3>Request</h3>3731
<table id="fields" cellpadding="2"></table>@@ -60,6 +54,8 @@ const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"
6054
const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];6155
const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "run-ended"];6256
let stocked = null;57
+let excuses = [];58
+let nonce = null;6359
let inventorySeq = 0;6460
const STATS = [6561
["x-floor", "Floor"],@@ -285,6 +281,13 @@ function fieldNames() {
285281
return (asked ? ["authorization"] : []).concat(names);286282
}287283
284
+function renderExcuses() {285
+ const said = excuses.concat(["the truth"]);286
+ $("excuses").replaceChildren(...said.map(e => el("option", {287
+ value: nonce ? 'Digest username="' + e + '", nonce="' + nonce + '"' : 'Excuse "' + e + '"',288
+ })));289
+}290
+288291
function renderFields() {289292
const table = $("fields");290293
table.replaceChildren();@@ -380,6 +383,8 @@ function absorb(r, sentMethod, sentPath) {
380383
if (digest && !(values.authorization || "").includes(digest[1])) {381384
values.authorization = 'Digest username="", nonce="' + digest[1] + '"';382385
}386
+ if ("www-authenticate" in headers) nonce = digest ? digest[1] : null;387
+ renderExcuses();383388
let body = null;384389
try { body = JSON.parse(r.body); } catch (e) {}385390
const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;@@ -402,7 +407,13 @@ function absorb(r, sentMethod, sentPath) {
402407
.then(r => r.json())403408
.then(r => {404409
if (run !== active || seq !== inventorySeq) return;405
- try { stocked = (JSON.parse(r.body).items || []).map(i => i.name); render(); } catch (e) {}410
+ try {411
+ const held = JSON.parse(r.body);412
+ stocked = (held.items || []).map(i => i.name);413
+ excuses = held.excuses || [];414
+ renderExcuses();415
+ render();416
+ } catch (e) {}406417
});407418
}408419
offered = body ? hrefs(body, []) : [];