atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 0d604ee39751

tarball

atagen <boss@atagen.co>2026-10-10 15:15 UTC

8 files changed+107-20
game: close the ignore-Mum and cage key holes

Requests without Authorization used to be free while Mum waited, so ignoring her cost nothing and the floor could be left behind her back. The second search of Nan's drawer always handed over the cage key, and leaving Nan's caged with under £15 was a softlock because no other drawer could help.
Modified file: src/game/balance.rs+1 additions
@@ -50,6 +50,7 @@ pub const RUPTURE_HOT: u32 = 10;
5050 pub const RUPTURE_PREMATURE: u32 = 3;
5151 pub const ZIP: u32 = 200;
5252 pub const RULER_IN_DRAWER: u32 = 2;
53+pub const KEY_IN_DRAWER: u32 = 3;
5354 pub const DEGLOVING: u32 = 60;
5455 pub const DEGLOVING_INTENSITY: i32 = 8;
5556 pub const WINDOW: u32 = 60;
Modified file: src/game/floors.rs+23 additions−9 deletions
@@ -3,7 +3,10 @@ use serde_json::{Value, json};
33
44 use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
6- balance::{FLOOR_ALLOWANCE, FLOOR_TURNS, RULER_IN_DRAWER, STARTING_EQ, ZIP},
6+ balance::{
7+ FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT,
8+ ZIP,
9+ },
710 round2,
811 };
912 use crate::problem::Problem;
@@ -113,6 +116,9 @@ impl Run {
113116 self.here.drawer_searches += 1;
114117 self.touch();
115118 let first = self.here.drawer_searches == 1;
119+ if self.items.cage {
120+ return Ok(self.rummage_for_key());
121+ }
116122 let ruler_here = self.floor == Floor::Bedroom && self.chance(RULER_IN_DRAWER);
117123 match self.floor {
118124 Floor::Bedroom if first && !self.items.ruler && ruler_here => {
@@ -143,13 +149,6 @@ impl Run {
143149 "A small box. Inside was a cage. It is now on. The key is not in the box.",
144150 ))
145151 }
146- Floor::Nans if self.items.cage => {
147- self.items.grant(Item::CageKey);
148- Ok(Outcome::json(
149- StatusCode::OK,
150- json!({ "detail": "The key was under where the box had been. The cage is off." }),
151- ))
152- }
153152 Floor::Hotel if first => {
154153 self.items.grant(Item::DndSign);
155154 self.items.grant(Item::IcePack);
@@ -192,8 +191,23 @@ impl Run {
192191 .header(header::LOCATION, self.path("")))
193192 }
194193
194+ /// Searching while caged is noisy, and the key only turns up some of the time.
195+ fn rummage_for_key(&mut self) -> Outcome {
196+ let was_below = self.suspicion < SUSPICION_LIMIT;
197+ self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);
198+ self.maybe_summon_mum(was_below);
199+ if !self.chance(KEY_IN_DRAWER) {
200+ return Outcome::empty(StatusCode::NO_CONTENT);
201+ }
202+ self.items.grant(Item::CageKey);
203+ Outcome::json(
204+ StatusCode::OK,
205+ json!({ "detail": "The key was in the drawer. The cage is off." }),
206+ )
207+ }
208+
195209 /// Whether searching the drawer on this floor would turn anything up.
196210 pub fn drawer_worth_searching(&self) -> bool {
197- self.here.drawer_searches == 0 || (self.floor == Floor::Nans && self.items.cage)
211+ self.here.drawer_searches == 0 || self.items.cage
198212 }
199213 }
Modified file: src/game/household.rs+26 additions−7 deletions
@@ -94,13 +94,28 @@ impl Run {
9494 let challenge = |p: Problem| p.header(header::WWW_AUTHENTICATE, &challenge_value);
9595
9696 let Some(authorization) = authorization else {
97- return Err(challenge(
98- Problem::new(
97+ let knocked_before = self.mum.as_ref().is_some_and(|visit| visit.knocked);
98+ if let Some(visit) = &mut self.mum {
99+ visit.knocked = true;
100+ }
101+ if !knocked_before {
102+ self.touch();
103+ return Err(challenge(
104+ Problem::new(
105+ StatusCode::UNAUTHORIZED,
106+ "someone-at-the-door",
107+ format!("Someone is at the door. {errand}"),
108+ )
109+ .with("inventory", self.path("/inventory")),
110+ ));
111+ }
112+ return Err(self.strike(
113+ challenge(Problem::new(
99114 StatusCode::UNAUTHORIZED,
100- "someone-at-the-door",
101- format!("Someone is at the door. {errand}"),
102- )
103- .with("inventory", self.path("/inventory")),
115+ "knocking-again",
116+ format!("Mum is knocking again. {errand}"),
117+ )),
118+ Cause::Opened,
104119 ));
105120 };
106121
@@ -173,6 +188,10 @@ impl Run {
173188 }
174189
175190 fn mum_strike(&mut self, problem: Problem) -> Problem {
191+ self.strike(problem, Cause::Caught)
192+ }
193+
194+ fn strike(&mut self, problem: Problem, cause: Cause) -> Problem {
176195 self.touch();
177196 let Some(visit) = &mut self.mum else {
178197 return problem;
@@ -181,7 +200,7 @@ impl Run {
181200 if visit.strikes < PATIENCE {
182201 return problem;
183202 }
184- self.end(Cause::Caught)
203+ self.end(cause)
185204 }
186205
187206 fn leave_door(&mut self) {
Modified file: src/game/state.rs+7 additions−1 deletions
@@ -151,6 +151,7 @@ pub struct MumVisit {
151151 pub errand: &'static str,
152152 pub nonce: String,
153153 pub strikes: u8,
154+ pub knocked: bool,
154155 }
155156
156157 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -179,6 +180,7 @@ pub enum Story {
179180 pub enum Cause {
180181 Ruptured { ice_unused: bool },
181182 Caught,
183+ Opened,
182184 Banned,
183185 Degloved,
184186 Zip,
@@ -190,6 +192,7 @@ impl Cause {
190192 match self {
191193 Self::Ruptured { .. } => "vascular-event",
192194 Self::Caught => "caught",
195+ Self::Opened => "opened",
193196 Self::Banned => "banned",
194197 Self::Degloved => "degloved",
195198 Self::Zip => "zip",
@@ -208,6 +211,9 @@ impl Cause {
208211 Self::Caught => {
209212 "Mum has seen enough. The run has ended. Nobody will mention it at dinner."
210213 }
214+ Self::Opened => {
215+ "Mum has stopped knocking and opened the door. The run has ended. Nobody will mention it at dinner."
216+ }
211217 Self::Banned => "Your account has been banned. The forum can tell. The run has ended.",
212218 Self::Degloved => {
213219 "The outer layer has been removed in one piece. It will not be returning. The run has ended."
@@ -222,7 +228,7 @@ impl Cause {
222228 fn status(self) -> StatusCode {
223229 match self {
224230 Self::Ruptured { .. } => StatusCode::INTERNAL_SERVER_ERROR,
225- Self::Caught | Self::Degloved | Self::Abandoned => StatusCode::GONE,
231+ Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned => StatusCode::GONE,
226232 Self::Banned => StatusCode::FORBIDDEN,
227233 Self::Zip => StatusCode::CONFLICT,
228234 }
Modified file: src/game/turn.rs+2 additions−1 deletions
@@ -365,7 +365,7 @@ impl Run {
365365 }
366366 }
367367
368- fn maybe_summon_mum(&mut self, was_below: bool) {
368+ pub(super) fn maybe_summon_mum(&mut self, was_below: bool) {
369369 if !self.floor.has_mum() || self.mum.is_some() {
370370 return;
371371 }
@@ -378,6 +378,7 @@ impl Run {
378378 errand,
379379 nonce,
380380 strikes: 0,
381+ knocked: false,
381382 });
382383 }
383384 }
Modified file: src/http/runs.rs+1 additions−1 deletions
@@ -79,7 +79,7 @@ pub static TECHNIQUES: RunResource = home(&[Verb::new("GET", get_techniques)]);
7979 pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);
8080 pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);
8181 pub static FLOOR: RunResource =
82- pockets(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);
82+ home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);
8383 pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);
8484 pub static BUY: RunResource = home(&[Verb::new("POST", buy)]);
8585 pub static PORTAL: RunResource = pockets(&[
Modified file: src/tests.rs+46 additions
@@ -83,6 +83,7 @@ fn mum_at_the_door(run: &mut Run) {
8383 errand: "She heard a noise.",
8484 nonce: "abc".into(),
8585 strikes: 0,
86+ knocked: false,
8687 });
8788 }
8889
@@ -778,3 +779,48 @@ async fn links_hide_resources_that_would_do_nothing() {
778779 let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
779780 assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
780781 }
782+
783+#[tokio::test]
784+async fn ignoring_mum_has_consequences() {
785+ let app = seeded_with(Floor::Bedroom, |run| {
786+ mum_at_the_door(run);
787+ run.here.turns = 6;
788+ });
789+ let knock = send(&app, "GET", "/runs/cafebabe", &[], "").await;
790+ assert_eq!(
791+ json(knock).await["type"],
792+ "https://jelq.dev/problems/someone-at-the-door"
793+ );
794+ let leave = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
795+ assert_eq!(
796+ leave.status(),
797+ StatusCode::UNAUTHORIZED,
798+ "no leaving with Mum at the door"
799+ );
800+ send(&app, "GET", "/runs/cafebabe", &[], "").await;
801+ let opened = send(&app, "GET", "/runs/cafebabe", &[], "").await;
802+ assert_eq!(opened.status(), StatusCode::GONE);
803+ assert_eq!(
804+ json(opened).await["type"],
805+ "https://jelq.dev/problems/opened"
806+ );
807+}
808+
809+#[tokio::test]
810+async fn caged_drawers_are_noisy_and_eventually_give_up_the_key() {
811+ let app = seeded_with(Floor::ChangingRoom, |run| run.items.cage = true);
812+ let mut statuses = Vec::new();
813+ for _ in 0..12 {
814+ let response = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
815+ statuses.push(response.status());
816+ if response.status() == StatusCode::OK {
817+ break;
818+ }
819+ }
820+ assert_eq!(statuses.last(), Some(&StatusCode::OK), "{statuses:?}");
821+ assert!(
822+ statuses[..statuses.len() - 1]
823+ .iter()
824+ .all(|s| *s == StatusCode::NO_CONTENT)
825+ );
826+}
Modified file: src/ui.html+1 additions−1 deletions
@@ -51,7 +51,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5151 const REL = "https://jelq.dev/rels/";
5252 const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
5353 const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
54-const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];
54+const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "run-ended"];
5555 const STATS = [
5656 ["x-floor", "Floor"],
5757 ["x-self-reported-length", "Self-reported length"],