atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit e807266c323b

tarball

atagen <boss@atagen.co>2026-10-12 09:51 UTC

12 files changed+189-29
game: close the loopholes the review found

A refused JELQ reset the edging streak and an interrupted edge kept its
EQ and girth, so the 508 could be dodged for free and Mum could watch an
edge pay out. The drawer counted caged rummages as the floor's first
search, which lost the hotel and forum loot, and a u8 wrap handed it out
twice. The melon now stands for exactly the raiding streak it was found
in, the thread cannot be re-read before turn 10 mentions it, and a full
server buries ended runs before refusing new ones.

Greg's locker opens one search in 20 and the bathroom drawer gives the
vaseline DESIGN always promised.
Modified file: src/game/balance.rs+1 additions
@@ -51,6 +51,7 @@ pub const RUPTURE_PREMATURE: u32 = 3;
5151 pub const ZIP: u32 = 200;
5252 pub const RULER_IN_DRAWER: u32 = 2;
5353 pub const KEY_IN_DRAWER: u32 = 3;
54+pub const GREGS_LOCKER: u32 = 20;
5455 pub const DEGLOVING: u32 = 60;
5556 pub const DEGLOVING_INTENSITY: i32 = 8;
5657 pub const WINDOW: u32 = 60;
Modified file: src/game/floors.rs+24 additions−6 deletions
@@ -4,8 +4,8 @@ use serde_json::{Value, json};
44 use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
66 balance::{
7- EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, KEY_IN_DRAWER, RULER_IN_DRAWER,
8- STARTING_EQ, SUSPICION_LIMIT, ZIP,
7+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER, KEY_IN_DRAWER,
8+ RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,
99 },
1010 round2,
1111 };
@@ -130,17 +130,24 @@ impl Run {
130130 }
131131
132132 pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
133- self.here.drawer_searches += 1;
134133 self.touch();
135- let first = self.here.drawer_searches == 1;
134+ self.end_kitchen_visit();
136135 if self.items.cage {
137136 return Ok(self.rummage_for_key());
138137 }
138+ let first = !self.here.drawer_searched;
139+ self.here.drawer_searched = true;
139140 let ruler_here = self.floor == Floor::Bedroom
140141 && self.chance(
141142 self.decision("ruler", "Is there a ruler hiding in the drawer?"),
142143 RULER_IN_DRAWER,
143144 );
145+ let locker_open = self.floor == Floor::ChangingRoom
146+ && first
147+ && self.chance(
148+ self.decision("gregs-locker", "Has Greg left his locker open?"),
149+ GREGS_LOCKER,
150+ );
144151 match self.floor {
145152 Floor::Bedroom if first && !self.items.ruler && ruler_here => {
146153 self.items.grant(Item::Ruler);
@@ -152,7 +159,18 @@ impl Run {
152159 }
153160 Floor::Bathroom if first => {
154161 self.items.grant(Item::DoorLock);
155- Ok(self.found(Item::DoorLock, "A door lock. It works once."))
162+ self.items.grant(Item::Vaseline);
163+ Ok(self.found(
164+ Item::DoorLock,
165+ "A door lock, which works once, and a tub of vaseline behind it.",
166+ ))
167+ }
168+ Floor::ChangingRoom if locker_open => {
169+ self.items.grant(Item::BabyOil);
170+ Ok(self.found(
171+ Item::BabyOil,
172+ "Greg has left his locker open. The baby oil is yours now, legally speaking.",
173+ ))
156174 }
157175 Floor::ChangingRoom => {
158176 self.shame += 3;
@@ -232,6 +250,6 @@ impl Run {
232250
233251 /// Whether searching the drawer on this floor would turn anything up.
234252 pub fn drawer_worth_searching(&self) -> bool {
235- self.here.drawer_searches == 0 || self.items.cage
253+ !self.here.drawer_searched || self.items.cage
236254 }
237255 }
Modified file: src/game/forum.rs+7 additions
@@ -114,6 +114,13 @@ impl Run {
114114 if self.story == Story::Pending {
115115 return Ok(self.branch_offer());
116116 }
117+ if !self.thread_reachable() {
118+ return Err(Problem::new(
119+ StatusCode::NOT_FOUND,
120+ "no-thread",
121+ "There is no thread to re-read. Nobody has mentioned a forum yet.",
122+ ));
123+ }
117124 self.touch();
118125 self.turn += 1;
119126 self.here.turns += 1;
Modified file: src/game/kitchen.rs+1 additions
@@ -100,6 +100,7 @@ impl Run {
100100 ) {
101101 self.kitchen_tellings += 1;
102102 if self.kitchen_tellings >= KITCHEN_TELLINGS {
103+ self.end_kitchen_visit();
103104 return Err(Problem::new(
104105 StatusCode::GONE,
105106 "kitchen-off-limits",
Modified file: src/game/mod.rs+14 additions−2 deletions
@@ -34,7 +34,7 @@ use serde_json::{Value, json};
3434
3535 use crate::problem::Problem;
3636 use balance::{
37- HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
37+ BRANCH_TURN, HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
3838 STARTING_CASH, STARTING_EQ,
3939 };
4040 use oracle::{Oracle, OracleTemplate};
@@ -239,11 +239,17 @@ impl Run {
239239 self.eq = eq.max(floor);
240240 }
241241
242- /// A melon offer stands only while the raid continues. Any other turn ends it.
242+ /// A melon offer stands only while the raiding streak continues. Any other action ends it.
243243 pub(super) fn end_kitchen_visit(&mut self) {
244244 self.here.melon_offered = false;
245245 }
246246
247+ /// Going back to the jelq walks away from both the fridge and Pump-Bro.
248+ pub(super) fn step_away(&mut self) {
249+ self.end_kitchen_visit();
250+ self.here.shopping = false;
251+ }
252+
247253 fn fresh_nonce(&mut self) -> String {
248254 format!("{:016x}", self.oracle.u64())
249255 }
@@ -335,6 +341,12 @@ impl Run {
335341 self.story == Story::ReadForum
336342 }
337343
344+ pub fn thread_reachable(&self) -> bool {
345+ self.turn >= BRANCH_TURN
346+ || self.forum_read()
347+ || self.floor.number() >= Floor::Forum.number()
348+ }
349+
338350 pub fn footage(&self) -> Option<(SystemTime, u64)> {
339351 let filmed = self.filmed_at?;
340352 let age = filmed.elapsed().map_or(0, |d| d.as_secs());
Modified file: src/game/oracle.rs+3 additions−1 deletions
@@ -228,6 +228,8 @@ impl Clm {
228228 .unwrap_or_else(|| panic!("oracle choice answer for {site} missing"));
229229 choice
230230 .parse::<usize>()
231- .unwrap_or_else(|_| panic!("oracle chose {choice:?} for {site}, not an option index"))
231+ .ok()
232+ .filter(|&i| i < options.len())
233+ .unwrap_or_else(|| panic!("oracle chose {choice:?} for {site}, not an option index"))
232234 }
233235 }
Modified file: src/game/state.rs+1 additions−1 deletions
@@ -135,7 +135,7 @@ impl Grip {
135135 #[derive(Default)]
136136 pub struct FloorState {
137137 pub turns: u32,
138- pub drawer_searches: u8,
138+ pub drawer_searched: bool,
139139 pub edges: u8,
140140 pub temp_length: f64,
141141 pub teapot: bool,
Modified file: src/game/turn.rs+12 additions−12 deletions
@@ -45,7 +45,6 @@ impl Run {
4545 "The cage is locked. The key is not in the box.",
4646 ));
4747 }
48- self.here.shopping = false;
4948 match *techniques {
5049 [Technique::Utility(utility)] => self.utility(utility, lube, now),
5150 [Technique::Training(training)] => self.single(training, intensity, lube, now),
@@ -79,12 +78,12 @@ impl Run {
7978 Utility::Energy => return self.energy(),
8079 Utility::Edge | Utility::Pump => {}
8180 }
82- if utility == Utility::Pump {
83- self.here.edges = 0;
84- }
8581 self.ready(Technique::Utility(utility), lube)?;
8682 self.check_storage()?;
8783 self.floor_enemy()?;
84+ if utility == Utility::Pump {
85+ self.here.edges = 0;
86+ }
8887 match utility {
8988 Utility::Edge => self.edge(now),
9089 _ => self.pump(now),
@@ -98,11 +97,11 @@ impl Run {
9897 lube: Option<Lube>,
9998 now: Instant,
10099 ) -> Result<Outcome, Problem> {
101- self.here.edges = 0;
102100 self.ready(Technique::Training(training), lube)?;
103101 self.check_storage()?;
104102 self.lube_available(lube)?;
105103 self.floor_enemy()?;
104+ self.here.edges = 0;
106105 self.take_lube(lube)?;
107106 self.chafe(lube);
108107 self.commit(intensity, lube, now, 1);
@@ -131,12 +130,12 @@ impl Run {
131130 "A technique cannot be combined with itself. The server has checked.",
132131 ));
133132 }
134- self.here.edges = 0;
135133 self.ready(Technique::Training(a), lube)?;
136134 self.ready(Technique::Training(b), lube)?;
137135 self.check_storage()?;
138136 self.lube_available(lube)?;
139137 self.floor_enemy()?;
138+ self.here.edges = 0;
140139 self.take_lube(lube)?;
141140 self.chafe(lube);
142141 self.commit(intensity, lube, now, 2);
@@ -376,7 +375,7 @@ impl Run {
376375 self.touch();
377376 self.turn += 1;
378377 self.here.turns += 1;
379- self.end_kitchen_visit();
378+ self.step_away();
380379 self.realise();
381380 if self.items.extender {
382381 self.length += EXTENDER_GAIN;
@@ -485,17 +484,17 @@ impl Run {
485484 "The cage is locked. The key is not in the box.",
486485 ));
487486 }
487+ self.touch();
488+ self.turn += 1;
489+ self.here.turns += 1;
490+ self.step_away();
491+ self.realise();
488492 if self.oracle.chance_not(
489493 self.decision("rupture", "The pause is uneventful"),
490494 RUPTURE_HOLD,
491495 ) {
492496 return Err(self.rupture());
493497 }
494- self.touch();
495- self.turn += 1;
496- self.here.turns += 1;
497- self.end_kitchen_visit();
498- self.realise();
499498 let was_below = self.suspicion < SUSPICION_LIMIT;
500499 if self.chance(
501500 self.decision(
@@ -528,6 +527,7 @@ impl Run {
528527 ));
529528 }
530529 self.here.meditated = true;
530+ self.step_away();
531531 self.reset_eq(self.eq - MEDITATION_EQ);
532532 self.shed(FRICTION_MEDITATE);
533533 self.touch();
Modified file: src/game/utility.rs+7 additions−3 deletions
@@ -49,6 +49,7 @@ impl Run {
4949 (WARMUP_EQ, "Warmed up. The towel has cooled.")
5050 };
5151 self.touch();
52+ self.step_away();
5253 self.warmed_up = true;
5354 self.eq = (self.eq + boost).min(100);
5455 Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
@@ -63,6 +64,7 @@ impl Run {
6364 ));
6465 }
6566 self.touch();
67+ self.step_away();
6668 self.items.ice_packs -= 1;
6769 if self.conditions.priapism_turns > 0 {
6870 return Ok(Outcome::json(
@@ -89,6 +91,7 @@ impl Run {
8991 ));
9092 }
9193 self.items.energy_drinks -= 1;
94+ self.step_away();
9295 self.stamina = (self.stamina + ENERGY_STAMINA).min(self.max_stamina());
9396 self.touch();
9497 Ok(Outcome::json(
@@ -106,6 +109,7 @@ impl Run {
106109 ));
107110 }
108111 self.items.supplements -= 1;
112+ self.step_away();
109113 self.touch();
110114 if self.chance(
111115 self.decision(
@@ -132,6 +136,9 @@ impl Run {
132136
133137 pub(super) fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {
134138 self.commit(5, None, now, 1);
139+ if let ControlFlow::Break(interrupted) = self.hazards(5)? {
140+ return Ok(interrupted);
141+ }
135142 self.here.edges += 1;
136143 self.eq = (self.eq + EDGE_EQ).min(100);
137144 self.girth += EDGE_GIRTH;
@@ -144,9 +151,6 @@ impl Run {
144151 "Edging has looped. The server detected an infinite loop and terminated it.",
145152 ));
146153 }
147- if let ControlFlow::Break(interrupted) = self.hazards(5)? {
148- return Ok(interrupted);
149- }
150154 Ok(Outcome::json(
151155 StatusCode::OK,
152156 json!({ "detail": "Edged. EQ is rising. Nothing else is.", "edges": self.here.edges }),
Modified file: src/http/meta.rs+14 additions
@@ -1,5 +1,7 @@
11 //! Resources that do not belong to a single run.
22
3+use std::time::Instant;
4+
35 use axum::{
46 Json,
57 http::{StatusCode, header},
@@ -69,6 +71,18 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
6971 let mut runs = state.runs();
7072 runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);
7173 if runs.len() >= config.max_runs {
74+ let mut buried: Vec<(Instant, String)> = runs
75+ .iter()
76+ .filter(|(_, run)| run.ended.is_some())
77+ .map(|(id, run)| (run.last_seen, id.clone()))
78+ .collect();
79+ buried.sort_unstable();
80+ let excess = runs.len() + 1 - config.max_runs;
81+ for (_, id) in buried.into_iter().take(excess) {
82+ runs.remove(&id);
83+ }
84+ }
85+ if runs.len() >= config.max_runs {
7286 return Problem::new(
7387 StatusCode::SERVICE_UNAVAILABLE,
7488 "server-full",
Modified file: src/http/run.rs+2 additions−3 deletions
@@ -13,7 +13,7 @@ use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource,
1313 use crate::{
1414 game::{
1515 Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
16- balance::{BRANCH_TURN, FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},
16+ balance::{FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},
1717 },
1818 headers::{
1919 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
@@ -97,8 +97,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9797 /// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
9898 /// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
9999 fn links(run: &Run, now: Instant) -> String {
100- let thread_reachable =
101- run.turn >= BRANCH_TURN || run.forum_read() || run.floor.number() >= Floor::Forum.number();
100+ let thread_reachable = run.thread_reachable();
102101 let linked: [(&str, &RunResource, bool); 15] = [
103102 ("", &runs::RUN, true),
104103 ("floor", &runs::FLOOR, true),
Modified file: src/tests.rs+103 additions−1 deletions
@@ -16,7 +16,7 @@ use crate::{
1616 AppState, Config,
1717 game::{
1818 Floor, MumVisit, Run, Story,
19- balance::{STORAGE_GIRTH, SUSPICION_LIMIT},
19+ balance::{BRANCH_TURN, STORAGE_GIRTH, SUSPICION_LIMIT},
2020 },
2121 router,
2222 };
@@ -903,6 +903,7 @@ async fn either_story_choice_is_reachable_during_cooldown() {
903903 let app = seeded_with(Floor::Bathroom, |run| {
904904 run.turn = 9;
905905 run.warmed_up = true;
906+ run.eq = 45;
906907 run.stamina = 1;
907908 run.items.door_locks = 1;
908909 });
@@ -996,6 +997,7 @@ async fn rereading_the_thread_teaches_uli() {
996997 let mut ruptured = 0;
997998 for seed in 0..100u64 {
998999 let app = app_with(Floor::Bedroom, seed, |run| {
1000+ run.turn = BRANCH_TURN;
9991001 run.warmed_up = true;
10001002 run.story = Story::ClosedTab;
10011003 });
@@ -1791,3 +1793,103 @@ async fn the_kitchen_rolls_the_normal_tiers() {
17911793 assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");
17921794 assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");
17931795 }
1796+
1797+#[tokio::test]
1798+async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {
1799+ let app = seeded_with(Floor::Bedroom, |run| run.warmed_up = true);
1800+ let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1801+ assert_eq!(read.status(), StatusCode::NOT_FOUND);
1802+ let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1803+ assert_eq!(header(&after, "x-floor"), "1");
1804+ assert!(!header(&after, "link").contains("/re-read"));
1805+}
1806+
1807+#[tokio::test]
1808+async fn any_other_action_takes_the_melon_off_the_table() {
1809+ for (method, uri) in [
1810+ ("JELQ", "/runs/cafebabe?technique=warmup"),
1811+ ("POST", "/runs/cafebabe/drawer"),
1812+ ] {
1813+ let app = seeded_with(Floor::Bedroom, |run| {
1814+ run.eq = 95;
1815+ run.here.melon_offered = true;
1816+ });
1817+ send(&app, method, uri, &[], "").await;
1818+ let melon = send(&app, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1819+ assert_eq!(melon.status(), StatusCode::CONFLICT, "{method} {uri}");
1820+ }
1821+}
1822+
1823+#[tokio::test]
1824+async fn a_refused_jelq_does_not_break_the_edging_streak() {
1825+ let app = seeded_with(Floor::ChangingRoom, |run| {
1826+ run.warmed_up = true;
1827+ run.eq = 45;
1828+ run.here.edges = 2;
1829+ });
1830+ let refused = send(
1831+ &app,
1832+ "JELQ",
1833+ "/runs/cafebabe?technique=dry",
1834+ &[("content-type", "application/baby-oil")],
1835+ "",
1836+ )
1837+ .await;
1838+ assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1839+ let third = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1840+ assert_eq!(third.status(), StatusCode::LOOP_DETECTED);
1841+}
1842+
1843+#[tokio::test]
1844+async fn an_interrupted_edge_keeps_no_gains() {
1845+ let app = seeded_with(Floor::ChangingRoom, |run| {
1846+ run.warmed_up = true;
1847+ run.eq = 10;
1848+ });
1849+ let before = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1850+ let girth = header(&before, "x-girth").to_owned();
1851+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1852+ assert_eq!(edge.status(), StatusCode::NOT_FOUND);
1853+ assert_eq!(header(&edge, "x-girth"), girth);
1854+}
1855+
1856+#[tokio::test]
1857+async fn rummaging_for_the_key_leaves_the_drawer_unsearched() {
1858+ let mut found = 0;
1859+ for seed in 0..20u64 {
1860+ let app = app_with(Floor::Hotel, seed, |run| {
1861+ run.here.wifi_accepted = true;
1862+ run.items.cage = true;
1863+ });
1864+ for _ in 0..30 {
1865+ let rummage = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1866+ if rummage.status() == StatusCode::OK {
1867+ break;
1868+ }
1869+ }
1870+ let search = send(&app, "POST", "/runs/cafebabe/drawer", &[], "").await;
1871+ if search.status() == StatusCode::FOUND {
1872+ assert!(header(&search, "location").ends_with("/inventory/do-not-disturb-sign"));
1873+ found += 1;
1874+ }
1875+ }
1876+ assert!(
1877+ found >= 18,
1878+ "only {found} of 20 hotel drawers survived the rummage"
1879+ );
1880+}
1881+
1882+#[tokio::test]
1883+async fn a_full_server_buries_the_dead_to_make_room() {
1884+ let app = crate::app(Config {
1885+ max_runs: 1,
1886+ ..Config::default()
1887+ });
1888+ let first = send(&app, "POST", "/runs", &[], "").await;
1889+ let location = header(&first, "location").to_owned();
1890+ let full = send(&app, "POST", "/runs", &[], "").await;
1891+ assert_eq!(full.status(), StatusCode::SERVICE_UNAVAILABLE);
1892+ send(&app, "DELETE", &location, &[], "").await;
1893+ let second = send(&app, "POST", "/runs", &[], "").await;
1894+ assert_eq!(second.status(), StatusCode::CREATED);
1895+}