atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 6033b0db186c

tarball

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

5 files changed+76-16
game: check the clock before the lube and deliver queued gains on the way out

The overstay check lived in begin_turn, which runs after the lube is taken and the stroke has chafed, so a turn that never happened still cost both. Queued gains were due on a run-wide turn and could trail a player onto later floors. The 202 also now reports foreskin torn by the stroke it queued.
Modified file: src/game/dispatch.rs+3 additions
@@ -66,6 +66,7 @@ impl Run {
6666 }
6767 self.ready(Technique::Utility(utility), lube)?;
6868 self.check_storage()?;
69+ self.check_clock()?;
6970 self.floor_enemy()?;
7071 match utility {
7172 Utility::Edge => self.edge(),
@@ -82,6 +83,7 @@ impl Run {
8283 self.ready(Technique::Training(training), lube)?;
8384 self.check_storage()?;
8485 self.lube_available(lube)?;
86+ self.check_clock()?;
8587 self.floor_enemy()?;
8688 self.take_lube(lube)?;
8789 let cold = self.cold();
@@ -115,6 +117,7 @@ impl Run {
115117 self.ready(Technique::Training(b), lube)?;
116118 self.check_storage()?;
117119 self.lube_available(lube)?;
120+ self.check_clock()?;
118121 self.floor_enemy()?;
119122 self.take_lube(lube)?;
120123 let cold = self.cold();
Modified file: src/game/roll.rs+9 additions−7 deletions
@@ -133,13 +133,15 @@ impl Run {
133133 girth,
134134 foreskin: profile.foreskin_growth,
135135 });
136- Outcome::json(
137- StatusCode::ACCEPTED,
138- json!({
139- "detail": "Gains have been queued. They may arrive.",
140- "eta_turns": eta,
141- }),
142- )
136+ let mut body = json!({
137+ "detail": "Gains have been queued. They may arrive.",
138+ "eta_turns": eta,
139+ });
140+ let torn = std::mem::take(&mut self.body.torn);
141+ if torn > 0.0 {
142+ body["foreskin"] = json!(-round3(torn));
143+ }
144+ Outcome::json(StatusCode::ACCEPTED, body)
143145 }
144146 }
145147
Modified file: src/game/run.rs+5 additions−6 deletions
@@ -149,15 +149,13 @@ impl Run {
149149 }
150150
151151 pub(super) fn begin_turn(&mut self) -> Result<(), Problem> {
152- if self.floor.timed() && self.here.turns + 1 >= FLOOR_LIMIT {
153- return Err(self.overstay());
154- }
152+ self.check_clock()?;
155153 self.touch();
156154 self.turn += 1;
157155 self.here.turns += 1;
158156 self.body.torn = 0.0;
159157 self.standing.at_turn_start = self.standing.suspicion;
160- self.realise();
158+ self.realise(false);
161159 if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {
162160 self.notices.push(Notice::RunningLong(self.floor));
163161 } else if self.floor.timed() && self.here.turns + 1 == FLOOR_LIMIT {
@@ -359,10 +357,11 @@ impl Run {
359357 self.body.exhausted = true;
360358 }
361359
362- pub(super) fn realise(&mut self) {
360+ /// Leaving a floor lets time pass, so everything still queued arrives at once.
361+ pub(super) fn realise(&mut self, everything: bool) {
363362 let (due, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.body.pending)
364363 .into_iter()
365- .partition(|p| p.due <= self.turn);
364+ .partition(|p| everything || p.due <= self.turn);
366365 self.body.pending = waiting;
367366 for p in due {
368367 let foreskin = self.gain(p.length, p.girth, p.foreskin);
Modified file: src/game/travel.rs+12 additions−3 deletions
@@ -1,8 +1,9 @@
11 use super::{
22 Cause, Elder, Floor, FloorState, Notice, Outcome, Run,
33 balance::{
4- EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, LOCKER_ROOM_BAR, LOCKER_ROOM_OTHERS,
5- LOCKER_ROOM_SHAME, OVERSTAY_BUSTED, OVERSTAY_EQ, OVERSTAY_SHAME, STARTING_EQ, ZIP,
4+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_LIMIT, FLOOR_TURNS, FRICTION_FLOOR, LOCKER_ROOM_BAR,
5+ LOCKER_ROOM_OTHERS, LOCKER_ROOM_SHAME, OVERSTAY_BUSTED, OVERSTAY_EQ, OVERSTAY_SHAME,
6+ STARTING_EQ, ZIP,
67 },
78 measure::round2,
89 };
@@ -29,6 +30,7 @@ impl Run {
2930 warm_arrival: warm,
3031 ..FloorState::default()
3132 };
33+ self.realise(true);
3234 self.reset_eq(STARTING_EQ);
3335 self.standing.suspicion = 0;
3436 self.shed(FRICTION_FLOOR);
@@ -43,8 +45,15 @@ impl Run {
4345 self.touch();
4446 }
4547
48+ pub(super) fn check_clock(&mut self) -> Result<(), Problem> {
49+ if self.floor.timed() && self.here.turns + 1 >= FLOOR_LIMIT {
50+ return Err(self.overstay());
51+ }
52+ Ok(())
53+ }
54+
4655 /// A floor past its limit either ends the run or moves you along, shamed and deflated.
47- pub(super) fn overstay(&mut self) -> Problem {
56+ fn overstay(&mut self) -> Problem {
4857 self.touch();
4958 if self.chance(
5059 self.decision(
Modified file: src/tests/turns.rs+47 additions
@@ -355,3 +355,50 @@ async fn ice_treats_a_burn_even_through_priapism() {
355355 assert_eq!(header(&iced, "x-eq"), "80");
356356 assert!(warnings(&iced).iter().all(|w| !w.contains("friction burn")));
357357 }
358+
359+#[tokio::test]
360+async fn leaving_a_floor_delivers_every_queued_gain() {
361+ let app = seeded_with(Floor::ChangingRoom, |run| {
362+ run.here.turns = 6;
363+ run.body.pending.push(crate::game::Pending {
364+ due: run.turn + 4,
365+ length: 0.2,
366+ girth: 0.1,
367+ foreskin: 0.0,
368+ });
369+ });
370+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
371+ assert_eq!(left.status(), StatusCode::SEE_OTHER);
372+ assert_eq!(
373+ header(&left, "x-gains-arrived"),
374+ "length=0.200, girth=0.100"
375+ );
376+}
377+
378+#[tokio::test]
379+async fn an_overstayed_jelq_spends_no_lube() {
380+ let app = seeded_with(Floor::ChangingRoom, |run| {
381+ run.body.eq = 60;
382+ run.here.turns = 9;
383+ run.items.lubes.baby_oil = 1;
384+ });
385+ let late = send(
386+ &app,
387+ "JELQ",
388+ "/runs/cafebabe?technique=wet",
389+ &[("content-type", "application/baby-oil")],
390+ "",
391+ )
392+ .await;
393+ assert!([StatusCode::GONE, StatusCode::REQUEST_TIMEOUT].contains(&late.status()));
394+ if late.status() == StatusCode::REQUEST_TIMEOUT {
395+ let held = json(send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await).await;
396+ assert!(
397+ held["items"]
398+ .as_array()
399+ .unwrap()
400+ .iter()
401+ .any(|i| i["name"] == "baby-oil")
402+ );
403+ }
404+}