atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 139164489032

tarball

atagen <boss@atagen.co>2026-10-11 05:51 UTC

5 files changed+133-15
game: the review collects what the batches let slip

The friction burn now shifts every rupture tier, including the per-turn hazards roll and the natural one critical failure, instead of only the two checks in roll(). A refused jelq leaves the bottle sealed, the empty bottle is 415 as the status table already promised, meditation holds the priapism EQ floor, and the kitchen pays out at its documented marginals from one draw instead of a cascade. The ui ignores a late inventory reply from a run that has already ended.
Modified file: src/game/hazards.rs+10 additions−5 deletions
@@ -7,8 +7,8 @@ use serde_json::json;
77 use super::{
88 Cause, Floor, Outcome, Run,
99 balance::{
10- COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING, DEGLOVING_INTENSITY, FRIEND, HOT_EQ,
11- PREMATURE_EQ, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,
10+ BURN_RUPTURE, COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING, DEGLOVING_INTENSITY, FRIEND,
11+ HOT_EQ, PREMATURE_EQ, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,
1212 SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,
1313 },
1414 techniques::Training,
@@ -28,9 +28,14 @@ impl Run {
2828
2929 /// Everything that can go wrong on a committed turn before the roll.
3030 pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {
31- let odds = if self.eq <= DANGER_EQ {
31+ let burn = if self.conditions.friction_burn {
32+ BURN_RUPTURE
33+ } else {
34+ 0
35+ };
36+ let odds = if self.eq <= DANGER_EQ - burn {
3237 RUPTURE_IN_BAND
33- } else if self.eq <= HOT_EQ {
38+ } else if self.eq <= HOT_EQ - burn {
3439 RUPTURE_WARM
3540 } else {
3641 RUPTURE_HOT
@@ -73,7 +78,7 @@ impl Run {
7378 )
7479 .header(header::CONNECTION, "close"));
7580 }
76- if self.eq > PREMATURE_EQ {
81+ if self.eq > PREMATURE_EQ - burn {
7782 if self.chance(RUPTURE_PREMATURE) {
7883 return Err(self.rupture());
7984 }
Modified file: src/game/kitchen.rs+8 additions−3 deletions
@@ -36,23 +36,28 @@ impl Run {
3636 self.here.turns += 1;
3737 self.realise();
3838 let was_below = self.suspicion < SUSPICION_LIMIT;
39+ // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5 and 1 in 8.
40+ let draw = self.rng.random_range(0..120);
41+ let oil = 120 / COCONUT_ODDS;
42+ let energy = oil + 120 / ENERGY_ODDS;
43+ let ice = energy + 120 / KITCHEN_ICE_ODDS;
3944 let outcome = if self.eq > RUPTURE_LINE && self.rng.random_ratio(WATERMELON.0, WATERMELON.1)
4045 {
4146 self.here.melon_offered = true;
4247 self.melon_offer()
43- } else if self.chance(COCONUT_ODDS) {
48+ } else if draw < oil {
4449 self.items.grant(Item::CoconutOil);
4550 Ok(Outcome::json(
4651 StatusCode::OK,
4752 json!({ "detail": "A jar of coconut oil, half of it already gone." }),
4853 ))
49- } else if self.chance(ENERGY_ODDS) {
54+ } else if draw < energy {
5055 self.items.grant(Item::EnergyDrink);
5156 Ok(Outcome::json(
5257 StatusCode::OK,
5358 json!({ "detail": "An energy drink, cold and faintly alarming." }),
5459 ))
55- } else if self.chance(KITCHEN_ICE_ODDS) {
60+ } else if draw < ice {
5661 self.items.grant(Item::IcePack);
5762 Ok(Outcome::json(
5863 StatusCode::OK,
Modified file: src/game/turn.rs+15 additions−5 deletions
@@ -101,9 +101,9 @@ impl Run {
101101 ) -> Result<Outcome, Problem> {
102102 self.here.edges = 0;
103103 self.ready(Technique::Training(training), lube)?;
104- self.take_lube(lube)?;
105104 self.check_storage()?;
106105 self.floor_enemy()?;
106+ self.take_lube(lube)?;
107107 self.chafe(lube);
108108 self.commit(intensity, lube, now, 1);
109109 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
@@ -134,9 +134,9 @@ impl Run {
134134 self.here.edges = 0;
135135 self.ready(Technique::Training(a), lube)?;
136136 self.ready(Technique::Training(b), lube)?;
137- self.take_lube(lube)?;
138137 self.check_storage()?;
139138 self.floor_enemy()?;
139+ self.take_lube(lube)?;
140140 self.chafe(lube);
141141 self.commit(intensity, lube, now, 2);
142142 for training in [a, b] {
@@ -268,7 +268,7 @@ impl Run {
268268 };
269269 if *held == 0 {
270270 return Err(Problem::new(
271- StatusCode::PRECONDITION_FAILED,
271+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
272272 "lubricant-exhausted",
273273 format!("{name} was declared, but the bottle is empty."),
274274 ));
@@ -482,7 +482,12 @@ impl Run {
482482 ));
483483 }
484484 self.here.meditated = true;
485- self.eq = (self.eq - MEDITATION_EQ).clamp(0, 100);
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);
486491 self.shed(FRICTION_MEDITATE);
487492 self.touch();
488493 Ok(Outcome::json(
@@ -662,7 +667,12 @@ impl Run {
662667 )
663668 .header(header::CONTENT_LOCATION, self.path("/foreskin"));
664669 }
665- if self.eq > DANGER_EQ {
670+ let burn = if self.conditions.friction_burn {
671+ BURN_RUPTURE
672+ } else {
673+ 0
674+ };
675+ if self.eq > DANGER_EQ - burn {
666676 return self.rupture();
667677 }
668678 if training == Training::Stretch && !self.here.teapot {
Modified file: src/tests.rs+98 additions−2 deletions
@@ -14,7 +14,10 @@ use tower::ServiceExt;
1414
1515 use crate::{
1616 AppState, Config,
17- game::{Floor, MumVisit, Run, Story, balance::BRANCH_TURN, balance::SUSPICION_LIMIT},
17+ game::{
18+ Floor, MumVisit, Run, Story,
19+ balance::{BRANCH_TURN, STORAGE_GIRTH, SUSPICION_LIMIT},
20+ },
1821 router,
1922 };
2023
@@ -1246,7 +1249,7 @@ async fn lubes_run_out_but_spit_never_does() {
12461249 let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
12471250 assert_ne!(first.status(), StatusCode::PRECONDITION_FAILED);
12481251 let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1249- assert_eq!(second.status(), StatusCode::PRECONDITION_FAILED);
1252+ assert_eq!(second.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
12501253
12511254 let spit = [("content-type", "text/plain; charset=spit")];
12521255 for _ in 0..3 {
@@ -1438,3 +1441,96 @@ async fn the_burn_warns_and_the_ice_treats_it() {
14381441 .collect();
14391442 assert!(!warnings.contains(&"199 - \"friction burn\""));
14401443 }
1444+
1445+#[tokio::test]
1446+async fn the_burn_shifts_the_rupture_tiers() {
1447+ async fn ruptures(burned: bool) -> u32 {
1448+ let mut gone = 0;
1449+ for seed in 0..400u64 {
1450+ let app = app_with(Floor::Bedroom, seed, |run| {
1451+ run.warmed_up = true;
1452+ run.eq = 60;
1453+ run.items.baby_oil = 9;
1454+ run.conditions.friction_burn = burned;
1455+ });
1456+ let lube = [("content-type", "application/baby-oil")];
1457+ let jelq = send(
1458+ &app,
1459+ "JELQ",
1460+ "/runs/cafebabe?technique=wet&intensity=5",
1461+ &lube,
1462+ "",
1463+ )
1464+ .await;
1465+ if jelq.status() == StatusCode::INTERNAL_SERVER_ERROR {
1466+ gone += 1;
1467+ }
1468+ }
1469+ gone
1470+ }
1471+ let burned = ruptures(true).await;
1472+ let calm = ruptures(false).await;
1473+ assert!(
1474+ burned >= 8 && calm <= 4,
1475+ "burned {burned} ruptures against {calm}"
1476+ );
1477+}
1478+
1479+#[tokio::test]
1480+async fn meditation_respects_the_priapism_floor() {
1481+ let app = seeded_with(Floor::Bedroom, |run| {
1482+ run.meditation_taught = true;
1483+ run.eq = 80;
1484+ run.conditions.priapism_turns = 3;
1485+ });
1486+ let calmed = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1487+ assert_eq!(calmed.status(), StatusCode::OK);
1488+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1489+ assert_eq!(header(&view, "x-eq"), "75");
1490+}
1491+
1492+#[tokio::test]
1493+async fn the_kitchen_pays_out_at_the_documented_odds() {
1494+ let mut oil = 0;
1495+ let mut drinks = 0;
1496+ let mut packs = 0;
1497+ for seed in 0..600u64 {
1498+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1499+ run.kitchen_tellings = 0;
1500+ let held = (
1501+ run.items.coconut_oil,
1502+ run.items.energy_drinks,
1503+ run.items.ice_packs,
1504+ );
1505+ let _ = run.raid_kitchen();
1506+ oil += u32::from(run.items.coconut_oil > held.0);
1507+ drinks += u32::from(run.items.energy_drinks > held.1);
1508+ packs += u32::from(run.items.ice_packs > held.2);
1509+ }
1510+ assert!((170..=230).contains(&oil), "coconut {oil} in 600");
1511+ assert!((90..=150).contains(&drinks), "energy {drinks} in 600");
1512+ assert!((45..=105).contains(&packs), "ice {packs} in 600");
1513+}
1514+
1515+#[tokio::test]
1516+async fn a_refused_jelq_leaves_the_lube_alone() {
1517+ let app = seeded_with(Floor::Bedroom, |run| {
1518+ run.warmed_up = true;
1519+ run.items.extender = true;
1520+ run.girth = STORAGE_GIRTH + 1.0;
1521+ });
1522+ let lube = [("content-type", "application/baby-oil")];
1523+ let full = send(
1524+ &app,
1525+ "JELQ",
1526+ "/runs/cafebabe?technique=wet&intensity=5",
1527+ &lube,
1528+ "",
1529+ )
1530+ .await;
1531+ assert_eq!(full.status(), StatusCode::INSUFFICIENT_STORAGE);
1532+ let inv = send(&app, "GET", "/runs/cafebabe/inventory", &[], "").await;
1533+ let body = json(inv).await;
1534+ let held = body["items"].as_array().unwrap();
1535+ assert!(held.iter().any(|i| i["name"] == "baby-oil"));
1536+}
Modified file: src/ui.html+2 additions
@@ -395,9 +395,11 @@ function absorb(r, sentMethod, sentPath) {
395395 }
396396 if (headers.link) [linked, techniques] = parseLinks(headers.link);
397397 if (run) {
398+ const active = run;
398399 fetch("/ui/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method: "GET", path: run + "/inventory", headers: [], body: "" }) })
399400 .then(r => r.json())
400401 .then(r => {
402+ if (run !== active) return;
401403 try { stocked = (JSON.parse(r.body).items || []).map(i => i.name); render(); } catch (e) {}
402404 });
403405 }