atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 14de3e1e8dc2

tarball

atagen <boss@atagen.co>2026-10-11 06:26 UTC

7 files changed+141-22
game: refuse the empty bottle before the enemy stirs

lube_available now runs the empty-bottle check before floor_enemy, so a 415 can no longer spend a door lock, reset suspicion or add shame on a turn that never commits. The rupture tiers are exclusive, so the band above the premature line rolls the documented single 1 in 3 instead of a 1 in 10 hot roll stacked underneath it. reset_eq applies the priapism floor to every EQ reset, floor exits, refractory recovery and the watermelon, where only meditation had it.
Modified file: src/game/floors.rs+1 additions−1 deletions
@@ -23,7 +23,7 @@ impl Run {
2323 ..FloorState::default()
2424 };
2525 self.warmed_up = false;
26- self.eq = STARTING_EQ;
26+ self.reset_eq(STARTING_EQ);
2727 self.suspicion = 0;
2828 self.shed(FRICTION_FLOOR);
2929 self.mum = None;
Modified file: src/game/hazards.rs+7 additions−5 deletions
@@ -33,14 +33,16 @@ impl Run {
3333 } else {
3434 0
3535 };
36- let odds = if self.eq <= DANGER_EQ - burn {
37- RUPTURE_IN_BAND
36+ let odds = if self.eq > PREMATURE_EQ - burn {
37+ None
38+ } else if self.eq <= DANGER_EQ - burn {
39+ Some(RUPTURE_IN_BAND)
3840 } else if self.eq <= HOT_EQ - burn {
39- RUPTURE_WARM
41+ Some(RUPTURE_WARM)
4042 } else {
41- RUPTURE_HOT
43+ Some(RUPTURE_HOT)
4244 };
43- if self.chance(odds) {
45+ if odds.is_some_and(|odds| self.chance(odds)) {
4446 return Err(self.rupture());
4547 }
4648 if self.floor.has_window() && !self.items.blinds && self.chance(WINDOW) {
Modified file: src/game/kitchen.rs+1 additions−1 deletions
@@ -116,7 +116,7 @@ impl Run {
116116 ));
117117 }
118118 self.here.melon_offered = false;
119- self.eq = 0;
119+ self.reset_eq(0);
120120 self.shame += MELON_SHAME;
121121 self.touch();
122122 if self.chance(CAUGHT_MELONED) {
Modified file: src/game/mod.rs+13 additions−3 deletions
@@ -34,8 +34,8 @@ use serde_json::{Value, json};
3434
3535 use crate::problem::Problem;
3636 use balance::{
37- HONEST_TOLERANCE, MAX_STAMINA, PULLED_STAMINA, SELF_REPORT_INFLATION, STARTING_CASH,
38- STARTING_EQ,
37+ HONEST_TOLERANCE, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
38+ STARTING_CASH, STARTING_EQ,
3939 };
4040 use state::Pending;
4141
@@ -197,6 +197,16 @@ impl Run {
197197 self.friction = (self.friction - friction).max(0);
198198 }
199199
200+ /// Priapism holds EQ up no matter what else moves it.
201+ pub(super) fn reset_eq(&mut self, eq: i32) {
202+ let floor = if self.conditions.priapism_turns > 0 {
203+ PRIAPISM_EQ
204+ } else {
205+ 0
206+ };
207+ self.eq = eq.max(floor);
208+ }
209+
200210 fn fresh_nonce(&mut self) -> String {
201211 format!("{:016x}", self.rng.random::<u64>())
202212 }
@@ -281,7 +291,7 @@ impl Run {
281291 if self.refractory_until.is_some_and(|until| now >= until) {
282292 self.refractory_until = None;
283293 self.stamina = self.max_stamina();
284- self.eq = STARTING_EQ;
294+ self.reset_eq(STARTING_EQ);
285295 self.warmed_up = false;
286296 self.touch();
287297 }
Modified file: src/game/turn.rs+20 additions−11 deletions
@@ -102,6 +102,7 @@ impl Run {
102102 self.here.edges = 0;
103103 self.ready(Technique::Training(training), lube)?;
104104 self.check_storage()?;
105+ self.lube_available(lube)?;
105106 self.floor_enemy()?;
106107 self.take_lube(lube)?;
107108 self.chafe(lube);
@@ -135,6 +136,7 @@ impl Run {
135136 self.ready(Technique::Training(a), lube)?;
136137 self.ready(Technique::Training(b), lube)?;
137138 self.check_storage()?;
139+ self.lube_available(lube)?;
138140 self.floor_enemy()?;
139141 self.take_lube(lube)?;
140142 self.chafe(lube);
@@ -258,13 +260,13 @@ impl Run {
258260 }
259261
260262 /// One unit of whatever was declared. Spit is free and the tap is always on.
261- fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
263+ fn lube_available(&self, lube: Option<Lube>) -> Result<(), Problem> {
262264 let (held, name) = match lube {
263265 None | Some(Lube::Spit) => return Ok(()),
264- Some(Lube::BabyOil) => (&mut self.items.baby_oil, "baby-oil"),
265- Some(Lube::CoconutOil) => (&mut self.items.coconut_oil, "coconut-oil"),
266- Some(Lube::Lotion) => (&mut self.items.lotion, "lotion"),
267- Some(Lube::Vaseline) => (&mut self.items.vaseline, "vaseline"),
266+ Some(Lube::BabyOil) => (&self.items.baby_oil, "baby-oil"),
267+ Some(Lube::CoconutOil) => (&self.items.coconut_oil, "coconut-oil"),
268+ Some(Lube::Lotion) => (&self.items.lotion, "lotion"),
269+ Some(Lube::Vaseline) => (&self.items.vaseline, "vaseline"),
268270 };
269271 if *held == 0 {
270272 return Err(Problem::new(
@@ -273,6 +275,18 @@ impl Run {
273275 format!("{name} was declared, but the bottle is empty."),
274276 ));
275277 }
278+ Ok(())
279+ }
280+
281+ fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {
282+ self.lube_available(lube)?;
283+ let held = match lube {
284+ None | Some(Lube::Spit) => return Ok(()),
285+ Some(Lube::BabyOil) => &mut self.items.baby_oil,
286+ Some(Lube::CoconutOil) => &mut self.items.coconut_oil,
287+ Some(Lube::Lotion) => &mut self.items.lotion,
288+ Some(Lube::Vaseline) => &mut self.items.vaseline,
289+ };
276290 *held -= 1;
277291 Ok(())
278292 }
@@ -482,12 +496,7 @@ impl Run {
482496 ));
483497 }
484498 self.here.meditated = true;
485- let floor = if self.conditions.priapism_turns > 0 {
486- PRIAPISM_EQ
487- } else {
488- 0
489- };
490- self.eq = (self.eq - MEDITATION_EQ).max(floor);
499+ self.reset_eq(self.eq - MEDITATION_EQ);
491500 self.shed(FRICTION_MEDITATE);
492501 self.touch();
493502 Ok(Outcome::json(
Modified file: src/tests.rs+96 additions
@@ -1534,3 +1534,99 @@ async fn a_refused_jelq_leaves_the_lube_alone() {
15341534 let held = body["items"].as_array().unwrap();
15351535 assert!(held.iter().any(|i| i["name"] == "baby-oil"));
15361536 }
1537+
1538+#[tokio::test]
1539+async fn an_empty_bottle_is_declined_before_the_enemy_stirs() {
1540+ let control = seeded_with(Floor::ChangingRoom, |run| {
1541+ run.warmed_up = true;
1542+ run.suspicion = SUSPICION_LIMIT;
1543+ });
1544+ let lube = [("content-type", "application/baby-oil")];
1545+ let caught = send(
1546+ &control,
1547+ "JELQ",
1548+ "/runs/cafebabe?technique=wet&intensity=5",
1549+ &lube,
1550+ "",
1551+ )
1552+ .await;
1553+ assert_eq!(caught.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
1554+
1555+ let app = seeded_with(Floor::ChangingRoom, |run| {
1556+ run.warmed_up = true;
1557+ run.suspicion = SUSPICION_LIMIT;
1558+ run.items.baby_oil = 0;
1559+ });
1560+ let refused = send(
1561+ &app,
1562+ "JELQ",
1563+ "/runs/cafebabe?technique=wet&intensity=5",
1564+ &lube,
1565+ "",
1566+ )
1567+ .await;
1568+ assert_eq!(refused.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1569+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1570+ assert_eq!(header(&view, "x-suspicion"), "5/5");
1571+ assert_eq!(header(&view, "x-shame"), "0");
1572+}
1573+
1574+#[tokio::test]
1575+async fn the_top_tier_rolls_a_single_third() {
1576+ let mut ruptures = 0;
1577+ let mut early = 0;
1578+ for seed in 0..400u64 {
1579+ let app = app_with(Floor::Bedroom, seed, |run| {
1580+ run.warmed_up = true;
1581+ run.eq = 96;
1582+ run.items.baby_oil = 9;
1583+ run.items.blinds = true;
1584+ });
1585+ let lube = [("content-type", "application/baby-oil")];
1586+ let jelq = send(
1587+ &app,
1588+ "JELQ",
1589+ "/runs/cafebabe?technique=wet&intensity=5",
1590+ &lube,
1591+ "",
1592+ )
1593+ .await;
1594+ match jelq.status() {
1595+ StatusCode::INTERNAL_SERVER_ERROR => ruptures += 1,
1596+ StatusCode::TOO_EARLY => early += 1,
1597+ other => panic!("seed {seed} ended in {other}"),
1598+ }
1599+ }
1600+ assert!(
1601+ (90..=175).contains(&ruptures),
1602+ "ruptures {ruptures} of 400 against {early} early"
1603+ );
1604+ assert!(
1605+ (225..=310).contains(&early),
1606+ "early {early} of 400 against {ruptures} ruptures"
1607+ );
1608+}
1609+
1610+#[tokio::test]
1611+async fn priapism_holds_the_floor_through_every_reset() {
1612+ let exit = seeded_with(Floor::Bedroom, |run| {
1613+ run.warmed_up = true;
1614+ run.eq = 90;
1615+ run.here.turns = 99;
1616+ run.conditions.priapism_turns = 3;
1617+ });
1618+ let out = send(&exit, "POST", "/runs/cafebabe/floor", &[], "").await;
1619+ assert_eq!(out.status(), StatusCode::SEE_OTHER);
1620+ let view = send(&exit, "GET", "/runs/cafebabe", &[], "").await;
1621+ assert_eq!(header(&view, "x-eq"), "75");
1622+
1623+ let melon = seeded_with(Floor::Bedroom, |run| {
1624+ run.eq = 95;
1625+ run.here.melon_offered = true;
1626+ run.conditions.priapism_turns = 2;
1627+ });
1628+ let bite = send(&melon, "POST", "/runs/cafebabe/kitchen/watermelon", &[], "").await;
1629+ assert_eq!(bite.status(), StatusCode::OK);
1630+ let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
1631+ assert_eq!(header(&view, "x-eq"), "75");
1632+}
Modified file: src/ui.html+3 additions−1 deletions
@@ -60,6 +60,7 @@ const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"
6060 const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
6161 const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "run-ended"];
6262 let stocked = null;
63+let inventorySeq = 0;
6364 const STATS = [
6465 ["x-floor", "Floor"],
6566 ["x-self-reported-length", "Self-reported length"],
@@ -396,10 +397,11 @@ function absorb(r, sentMethod, sentPath) {
396397 if (headers.link) [linked, techniques] = parseLinks(headers.link);
397398 if (run) {
398399 const active = run;
400+ const seq = ++inventorySeq;
399401 fetch("/ui/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method: "GET", path: run + "/inventory", headers: [], body: "" }) })
400402 .then(r => r.json())
401403 .then(r => {
402- if (run !== active) return;
404+ if (run !== active || seq !== inventorySeq) return;
403405 try { stocked = (JSON.parse(r.body).items || []).map(i => i.name); render(); } catch (e) {}
404406 });
405407 }