atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 886159fd0fe7

tarball

atagen <boss@atagen.co>2026-10-13 12:50 UTC

7 files changed+143-41
game: keep Mum's nonce in view and let her leave in character

The challenge only rode on 401s, so the response where Mum arrived carried no nonce and the page built Digest answers from the previous visit, which then cost a strike as a stale conversation. Every response while she is at the door now carries WWW-Authenticate, as RFC 9110 permits, and a stale nonce is explained for free. Her parting line was the washing whatever she came for, so each errand now has its own.
Modified file: src/game/gauges.rs+2 additions−2 deletions
@@ -25,7 +25,7 @@ pub struct Gauges {
2525 pub(crate) cash: u32,
2626 pub(crate) floor: u8,
2727 pub(crate) observed: Option<Observer>,
28- pub(crate) mum_at_door: bool,
28+ pub(crate) mum_challenge: Option<String>,
2929 pub(crate) warnings: Vec<&'static str>,
3030 pub(crate) arrived: Option<Arrival>,
3131 pub(crate) ruler: bool,
@@ -81,7 +81,7 @@ impl Run {
8181 cash: self.standing.cash,
8282 floor: self.floor.number(),
8383 observed: self.observed,
84- mum_at_door: self.household.mum.is_some(),
84+ mum_challenge: self.mum_challenge(),
8585 warnings,
8686 arrived,
8787 ruler: self.items.kit.ruler,
Modified file: src/game/household.rs+82 additions−35 deletions
@@ -1,6 +1,6 @@
11 use std::collections::HashMap;
22
3-use axum::http::{StatusCode, header};
3+use axum::http::StatusCode;
44
55 use super::{
66 Cause, Floor, Notice, Run,
@@ -83,17 +83,56 @@ fn is_truth(spoken: &str) -> bool {
8383 .any(|truth| truth.eq_ignore_ascii_case(spoken))
8484 }
8585
86-const BEDROOM_ERRANDS: [&str; 4] = [
87- "She has brought your washing.",
88- "She wants to know if you have eaten.",
89- "She needs the wifi password.",
90- "She would like to talk about your future.",
86+/// Why Mum came to the door, and how she leaves once she believes you.
87+#[derive(Clone, Copy)]
88+pub struct Errand {
89+ pub(crate) knock: &'static str,
90+ pub(crate) parting: &'static str,
91+}
92+
93+pub const HEARD_A_NOISE: Errand = Errand {
94+ knock: "She heard a noise.",
95+ parting: "Mum seems satisfied. It must have been the pipes.",
96+};
97+const VIRAL: Errand = Errand {
98+ knock: "A neighbour has sent her something.",
99+ parting: "Mum seems satisfied. She has decided not to open the attachment.",
100+};
101+const BEDROOM_ERRANDS: [Errand; 4] = [
102+ Errand {
103+ knock: "She has brought your washing.",
104+ parting: "Mum seems satisfied. She leaves the washing on the chair.",
105+ },
106+ Errand {
107+ knock: "She wants to know if you have eaten.",
108+ parting: "Mum seems satisfied. There will be a plate in the oven.",
109+ },
110+ Errand {
111+ knock: "She needs the wifi password.",
112+ parting: "Mum seems satisfied. She has written the wifi password on her hand.",
113+ },
114+ Errand {
115+ knock: "She would like to talk about your future.",
116+ parting: "Mum seems satisfied. The future can wait until dinner.",
117+ },
91118 ];
92-const CHRISTMAS_ERRANDS: [&str; 4] = [
93- "Nan would like to see you.",
94- "Christmas dinner is in ten minutes.",
95- "The King's speech is about to start.",
96- "An uncle has asked where you went.",
119+const CHRISTMAS_ERRANDS: [Errand; 4] = [
120+ Errand {
121+ knock: "Nan would like to see you.",
122+ parting: "Mum seems satisfied. She tells Nan you are on your way.",
123+ },
124+ Errand {
125+ knock: "Christmas dinner is in ten minutes.",
126+ parting: "Mum seems satisfied. She will save you a pig in a blanket.",
127+ },
128+ Errand {
129+ knock: "The King's speech is about to start.",
130+ parting: "Mum seems satisfied. She will tell you what the King said.",
131+ },
132+ Errand {
133+ knock: "An uncle has asked where you went.",
134+ parting: "Mum seems satisfied. She tells the uncle you are helping in the kitchen.",
135+ },
97136 ];
98137
99138 #[derive(Default)]
@@ -104,7 +143,7 @@ pub struct Household {
104143 }
105144
106145 pub struct MumVisit {
107- pub(crate) errand: &'static str,
146+ pub(crate) errand: Errand,
108147 pub(crate) nonce: String,
109148 pub(crate) strikes: u8,
110149 pub(crate) knocked: bool,
@@ -159,9 +198,9 @@ impl Run {
159198 .collect()
160199 }
161200
162- pub(super) fn errand(&mut self, summoned: bool) -> &'static str {
201+ pub(super) fn errand(&mut self, summoned: bool) -> Errand {
163202 if summoned {
164- return "She heard a noise.";
203+ return HEARD_A_NOISE;
165204 }
166205 if self.online.filmed_at.is_some()
167206 && self.chance(
@@ -169,7 +208,7 @@ impl Run {
169208 VIRAL_ERRAND,
170209 )
171210 {
172- return "A neighbour has sent her something.";
211+ return VIRAL;
173212 }
174213 let errands = if self.floor == Floor::Nans {
175214 CHRISTMAS_ERRANDS
@@ -177,33 +216,38 @@ impl Run {
177216 BEDROOM_ERRANDS
178217 };
179218 let weights = vec![1; errands.len()];
219+ let knocks = errands.map(|errand| errand.knock);
180220 let i = self.oracle.pick(
181221 self.decision("errand", "What has brought Mum to the door?"),
182222 &weights,
183- &errands,
223+ &knocks,
184224 );
185225 errands[i]
186226 }
187227
188228 /// Mum answers every request on the run until she has been given something she believes.
189229 pub fn answer_door(&mut self, authorization: Option<&str>) -> Result<(), Problem> {
190- let Some(visit) = &self.household.mum else {
230+ if self.household.mum.is_none() {
191231 return Ok(());
192- };
193- let digest = self.floor == Floor::Nans;
194- let challenge = if digest {
232+ }
233+ match authorization {
234+ None => Err(self.knock()),
235+ Some(authorization) => self.hear(authorization, self.floor == Floor::Nans),
236+ }
237+ }
238+
239+ /// Sent on every response while she is at the door, so a client always holds the current nonce.
240+ pub fn mum_challenge(&self) -> Option<String> {
241+ let visit = self
242+ .household
243+ .mum
244+ .as_ref()
245+ .filter(|_| self.ended.is_none())?;
246+ Some(if self.floor == Floor::Nans {
195247 format!("Digest realm=\"household\", nonce=\"{}\"", visit.nonce)
196248 } else {
197249 "Excuse realm=\"household\"".to_owned()
198- };
199- let result = match authorization {
200- None => Err(self.knock()),
201- Some(authorization) => self.hear(authorization, digest),
202- };
203- if self.ended.is_some() {
204- return result;
205- }
206- result.map_err(|problem| problem.header(header::WWW_AUTHENTICATE, &challenge))
250+ })
207251 }
208252
209253 /// The first knock is free. Every one after it is a strike.
@@ -211,7 +255,7 @@ impl Run {
211255 let Some(visit) = &mut self.household.mum else {
212256 unreachable!("knock is only called while Mum is at the door");
213257 };
214- let errand = visit.errand;
258+ let errand = visit.errand.knock;
215259 if !std::mem::replace(&mut visit.knocked, true) {
216260 self.touch();
217261 return Problem::new(
@@ -302,10 +346,11 @@ impl Run {
302346 .as_ref()
303347 .is_some_and(|visit| params.get("nonce") == Some(&visit.nonce));
304348 if !current {
305- return Err(self.mum_strike(turned_away(
349+ self.touch();
350+ return Err(turned_away(
306351 "stale-nonce",
307- "That was a different conversation.",
308- )));
352+ "That was a different conversation. Mum has started a new one, with a new nonce.",
353+ ));
309354 }
310355 Ok(params.remove("username").unwrap_or_default())
311356 }
@@ -332,9 +377,11 @@ impl Run {
332377 }
333378
334379 fn leave_door(&mut self) {
335- self.household.mum = None;
380+ if let Some(visit) = self.household.mum.take() {
381+ self.notices
382+ .push(Notice::MumSatisfied(visit.errand.parting));
383+ }
336384 self.standing.suspicion = 0;
337- self.notices.push(Notice::MumSatisfied);
338385 self.touch();
339386 }
340387 }
Modified file: src/game/mod.rs+2 additions
@@ -37,6 +37,8 @@ pub use clinic::Clinic;
3737 pub use floor::{Floor, FloorState, Observer};
3838 pub use forum::{Branch, ELDER_LAST_MODIFIED, Elder, FORUM_THREAD, Online, Story, forum_thread};
3939 pub use gauges::{Gauges, Shown};
40+#[cfg(test)]
41+pub use household::HEARD_A_NOISE;
4042 pub use household::{Excuse, Household, MumVisit, vocabulary as excuse_vocabulary};
4143 pub use inventory::Items;
4244 pub use items::{Item, SHOP};
Modified file: src/game/notice.rs+2 additions−2 deletions
@@ -13,7 +13,7 @@ pub enum Notice {
1313 DrawerComesLoose,
1414 ThreadHasReplies,
1515 Reflection,
16- MumSatisfied,
16+ MumSatisfied(&'static str),
1717 ColdStart,
1818 RunningLong(Floor),
1919 LastTurn,
@@ -34,7 +34,7 @@ impl Notice {
3434 Self::DrawerComesLoose => "The drawer comes loose. That took a while.",
3535 Self::ThreadHasReplies => "Somewhere, a forum thread from 2009 still has replies.",
3636 Self::Reflection => "the count reached ten, and then the reflection started",
37- Self::MumSatisfied => "Mum seems satisfied. She takes the washing with her.",
37+ Self::MumSatisfied(parting) => parting,
3838 Self::ColdStart => "Cold start. The server has noted this.",
3939 Self::RunningLong(floor) => match floor {
4040 Floor::Bedroom => "Mum is coming up the stairs.",
Modified file: src/http/run.rs+2 additions−1 deletions
@@ -162,9 +162,10 @@ fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
162162 if let Some(observer) = gauges.observed {
163163 stats.push((X_OBSERVED_BY, observer.name().into()));
164164 }
165- if gauges.mum_at_door {
165+ if let Some(challenge) = &gauges.mum_challenge {
166166 stats.push((header::VIA, "1.1 mum".into()));
167167 stats.push((header::VARY, "Mum".into()));
168+ stats.push((header::WWW_AUTHENTICATE, challenge.clone()));
168169 }
169170 if let Some(arrived) = &gauges.arrived {
170171 let mut gains = format!("length={:.3}, girth={:.3}", arrived.length, arrived.girth);
Modified file: src/tests/household.rs+52 additions
@@ -393,3 +393,55 @@ async fn an_excuse_without_a_scheme_is_explained_for_free() {
393393 .await;
394394 assert_eq!(excused.status(), StatusCode::OK);
395395 }
396+
397+#[tokio::test]
398+async fn mum_leaves_the_way_her_errand_suggests() {
399+ let app = seeded_with(Floor::Bedroom, mum_at_the_door);
400+ let pockets = send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await;
401+ assert_eq!(
402+ header(&pockets, "www-authenticate"),
403+ "Excuse realm=\"household\""
404+ );
405+ let excused = send(
406+ &app,
407+ "GET",
408+ "/runs/cafebabe",
409+ &[("authorization", "Excuse \"homework\"")],
410+ "",
411+ )
412+ .await;
413+ assert!(warnings(&excused).iter().any(|w| w.contains("the pipes")));
414+ assert!(excused.headers().get("www-authenticate").is_none());
415+}
416+
417+#[tokio::test]
418+async fn a_stale_nonce_is_explained_for_free() {
419+ let app = seeded_with(Floor::Nans, mum_at_the_door);
420+ for _ in 0..4 {
421+ let stale = send(
422+ &app,
423+ "GET",
424+ "/runs/cafebabe",
425+ &[(
426+ "authorization",
427+ "Digest username=\"helping Nan\", nonce=\"old\"",
428+ )],
429+ "",
430+ )
431+ .await;
432+ assert_eq!(stale.status(), StatusCode::UNAUTHORIZED);
433+ assert!(header(&stale, "www-authenticate").contains("nonce=\"abc\""));
434+ }
435+ let current = send(
436+ &app,
437+ "GET",
438+ "/runs/cafebabe",
439+ &[(
440+ "authorization",
441+ "Digest username=\"helping Nan\", nonce=\"abc\"",
442+ )],
443+ "",
444+ )
445+ .await;
446+ assert_eq!(current.status(), StatusCode::OK);
447+}
Modified file: src/tests/mod.rs+1 additions−1 deletions
@@ -108,7 +108,7 @@ pub fn app() -> Router {
108108
109109 pub fn mum_at_the_door(run: &mut Run) {
110110 run.household.mum = Some(MumVisit {
111- errand: "She heard a noise.",
111+ errand: crate::game::HEARD_A_NOISE,
112112 nonce: "abc".into(),
113113 strikes: 0,
114114 knocked: false,