atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit f16ab17c8ff6

tarball

atagen <boss@atagen.co>2026-10-13 11:18 UTC

9 files changed+141-47
game: guide a schemeless excuse and close the kitchen on the first telling

A bare Authorization like "homework" parsed as no excuse at all and drew the not-heard rejection and a strike, and the UI's datalist produced exactly that header. It now gets a free hint naming the right scheme, and the UI offers only the floor's excuses as full Excuse or Digest credentials. The kitchen's first telling was a Warning the next raid ignored, so the telling now closes it for the floor and drops its link.
Modified file: src/game/balance/kitchen.rs−1 deletions
@@ -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;
Modified file: src/game/floor.rs+1 additions
@@ -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 }
Modified file: src/game/gauges.rs+2 additions−2 deletions
@@ -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(),
Modified file: src/game/household.rs+22 additions−1 deletions
@@ -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+ authorization
139+ .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",
Modified file: src/game/kitchen.rs+17 additions−23 deletions
@@ -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::StopRaidingTheFridge
91- } else {
92- Notice::StopRaidingTheKitchen
93- });
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 => (
Modified file: src/game/notice.rs−4 deletions
@@ -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.",
Modified file: src/tests/household.rs+61 additions−2 deletions
@@ -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 == 1
320+ run.raid_kitchen().is_err() && run.here.kitchen.closed
321321 })
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+}
Modified file: src/tests/rolls.rs+19 additions−6 deletions
@@ -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]
Modified file: src/ui.html+19 additions−8 deletions
@@ -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, []) : [];