atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 53bce0d93000

tarball

atagen <boss@atagen.co>2026-10-14 13:42 UTC

5 files changed+58-15
game: judge ruptures by the cooler of the turn's start and its end

EQ moves mid-turn before the rupture checks, so one hard stroke could carry a run from Ideal past a line and rupture it there, and X-EQ never got to warn. The new tier now has to survive one response before it can rupture anything.
Modified file: src/game/body.rs+3 additions
@@ -13,6 +13,8 @@ pub struct Body {
1313 pub(crate) foreskin: Option<f64>,
1414 pub(crate) inflation: f64,
1515 pub(crate) eq: i32,
16+ /// `heat` as the current turn began, so no rupture is judged hotter than the turn started.
17+ pub(crate) heat_at_turn_start: i32,
1618 pub(crate) stamina: i32,
1719 pub(crate) friction: i32,
1820 pub(crate) grip: Grip,
@@ -34,6 +36,7 @@ impl Body {
3436 foreskin: Some(foreskin),
3537 inflation,
3638 eq: STARTING_EQ,
39+ heat_at_turn_start: STARTING_EQ,
3740 stamina: MAX_STAMINA,
3841 friction: 0,
3942 grip: Grip::Overhand,
Modified file: src/game/hazards.rs+24 additions−12 deletions
@@ -20,21 +20,31 @@ use crate::problem::Problem;
2020 type Interruption = Result<ControlFlow<Outcome>, Problem>;
2121
2222 impl Run {
23- /// Whether EQ is past `line`, with a friction burn dragging every line down.
24- pub(super) fn hotter_than(&self, line: i32) -> bool {
23+ /// EQ with a friction burn dragging every line down.
24+ pub(super) fn heat(&self) -> i32 {
2525 let burn = if self.body.conditions.friction_burn {
2626 BURN_RUPTURE
2727 } else {
2828 0
2929 };
30- self.body.eq > line - burn
30+ self.body.eq + burn
31+ }
32+
33+ pub(super) fn hotter_than(&self, line: i32) -> bool {
34+ self.heat() > line
35+ }
36+
37+ /// Ruptures go by the cooler of now and the turn's start, so a line crossed this turn cannot rupture
38+ /// before `X-EQ` has had a chance to name it.
39+ pub(super) fn risked(&self, line: i32) -> bool {
40+ self.heat().min(self.body.heat_at_turn_start) > line
3141 }
3242
3343 /// The rupture tier for any turn that is not a jelq, so never the premature band's 1 in 3.
3444 pub(super) fn rupture_odds(&self) -> u32 {
35- if !self.hotter_than(DANGER_EQ) {
45+ if !self.risked(DANGER_EQ) {
3646 RUPTURE_IN_BAND
37- } else if !self.hotter_than(HOT_EQ) {
47+ } else if !self.risked(HOT_EQ) {
3848 RUPTURE_WARM
3949 } else {
4050 RUPTURE_HOT
@@ -183,13 +193,15 @@ impl Run {
183193 .header(header::CONNECTION, "close"));
184194 }
185195 if self.hotter_than(PREMATURE_EQ) {
186- if self.chance(
187- self.decision(
188- "rupture",
189- "Right at the edge of finishing, does it rupture instead?",
190- ),
191- RUPTURE_PREMATURE,
192- ) {
196+ if self.risked(PREMATURE_EQ)
197+ && self.chance(
198+ self.decision(
199+ "rupture",
200+ "Right at the edge of finishing, does it rupture instead?",
201+ ),
202+ RUPTURE_PREMATURE,
203+ )
204+ {
193205 return Err(self.rupture());
194206 }
195207 self.standing.shame += 5;
Modified file: src/game/roll.rs+3 additions−3 deletions
@@ -49,10 +49,10 @@ impl Run {
4949 return Err(timed(self.critical_failure(training, &profile)));
5050 }
5151 self.here.warm_arrival = false;
52- if natural == 2 && self.hotter_than(DANGER_EQ) {
52+ if natural == 2 && self.risked(DANGER_EQ) {
5353 return Err(timed(self.rupture()));
5454 }
55- if self.hotter_than(rupture_line) && total < dc {
55+ if self.risked(rupture_line) && total < dc {
5656 return Err(timed(self.rupture()));
5757 }
5858 if natural == 20 {
@@ -178,7 +178,7 @@ impl Run {
178178 .with("foreskin", -round3(lost))
179179 .header(header::CONTENT_LOCATION, self.path("/foreskin"));
180180 }
181- if self.hotter_than(DANGER_EQ) {
181+ if self.risked(DANGER_EQ) {
182182 return self.rupture();
183183 }
184184 if training == Training::Stretch && !self.here.teapot {
Modified file: src/game/run.rs+1 additions
@@ -163,6 +163,7 @@ impl Run {
163163 self.here.turns += 1;
164164 self.body.torn = 0.0;
165165 self.standing.at_turn_start = self.standing.suspicion;
166+ self.body.heat_at_turn_start = self.heat();
166167 self.realise(false);
167168 if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {
168169 self.notices.push(Notice::RunningLong(self.floor));
Modified file: src/tests/rolls.rs+27 additions
@@ -227,3 +227,30 @@ async fn degloving_takes_one_cold_stroke_in_twenty() {
227227 "{degloved} degloved in 1200 cold strokes, about 60 expected"
228228 );
229229 }
230+
231+#[tokio::test]
232+async fn a_turn_cannot_rupture_for_heat_it_only_just_reached() {
233+ let mut ruptured = 0;
234+ for seed in 0..300u64 {
235+ let app = app_with(Floor::Bedroom, seed, |run| run.body.eq = 70);
236+ let turn = send(
237+ &app,
238+ "JELQ",
239+ "/runs/cafebabe?technique=stretch&intensity=10",
240+ &[],
241+ "",
242+ )
243+ .await;
244+ if turn
245+ .headers()
246+ .get("x-run-over")
247+ .is_some_and(|v| v == "ruptured")
248+ {
249+ ruptured += 1;
250+ }
251+ }
252+ assert!(
253+ ruptured <= 6,
254+ "Ideal at the start of the turn kept the in-band 1 in 250, but {ruptured} of 300 ruptured"
255+ );
256+}