atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 97c05b741d8f

tarball

atagen <boss@atagen.co>2026-10-13 11:30 UTC

5 files changed+70-33
game: report what happens to the foreskin

Stretch and dry grew the foreskin on every landed roll, but the gains body only listed length and girth, so the growth looked like it never happened. Degloving also left the foreskin intact on a run that had just lost its outer layer. Both now report the foreskin delta in the body, and degloving takes it the way a crit fail does.
Modified file: src/game/hazards.rs+12 additions−2 deletions
@@ -11,6 +11,7 @@ use super::{
1111 RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM, SOFT_INTENSITY, SUSPICION_LIMIT,
1212 WANDERER_VISIT, WINDOW,
1313 },
14+ measure::round3,
1415 techniques::Training,
1516 travel::Exit,
1617 };
@@ -49,7 +50,7 @@ impl Run {
4950 self.decision("cold-degloving", "Cold and unprepared, does the skin tear?"),
5051 COLD_DEGLOVING,
5152 ) {
52- return Err(self.end(Cause::Degloved));
53+ return Err(self.deglove());
5354 }
5455 }
5556 if training == Training::Dry
@@ -59,11 +60,20 @@ impl Run {
5960 DEGLOVING,
6061 )
6162 {
62- return Err(self.end(Cause::Degloved));
63+ return Err(self.deglove());
6364 }
6465 Ok(ControlFlow::Continue(()))
6566 }
6667
68+ fn deglove(&mut self) -> Problem {
69+ let lost = self.body.foreskin.take();
70+ let problem = self.end(Cause::Degloved);
71+ match lost {
72+ Some(lost) => problem.with("foreskin", -round3(lost)),
73+ None => problem,
74+ }
75+ }
76+
6777 pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {
6878 if !self.hotter_than(PREMATURE_EQ)
6979 && self.chance(
Modified file: src/game/roll.rs+27 additions−21 deletions
@@ -10,7 +10,15 @@ use super::{
1010 };
1111 use crate::{headers::SERVER_TIMING, problem::Problem};
1212 use axum::http::{StatusCode, header};
13-use serde_json::json;
13+use serde_json::{Value, json};
14+
15+fn gains(detail: &str, length: f64, girth: f64, foreskin: Option<f64>) -> Value {
16+ let mut body = json!({ "detail": detail, "length": round3(length), "girth": round3(girth) });
17+ if let Some(foreskin) = foreskin {
18+ body["foreskin"] = json!(round3(foreskin));
19+ }
20+ body
21+}
1422
1523 impl Run {
1624 pub(super) fn roll(
@@ -52,14 +60,15 @@ impl Run {
5260 return Err(timed(self.rupture()));
5361 }
5462 if natural == 20 {
55- self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
63+ let foreskin = self.gain(length * 2.0, girth * 2.0, profile.foreskin_growth);
5664 return Ok(Outcome::json(
5765 StatusCode::PERMANENT_REDIRECT,
58- json!({
59- "detail": "Your length has moved permanently.",
60- "length": round3(length * 2.0),
61- "girth": round3(girth * 2.0),
62- }),
66+ gains(
67+ "Your length has moved permanently.",
68+ length * 2.0,
69+ girth * 2.0,
70+ foreskin,
71+ ),
6372 )
6473 .header(header::LOCATION, self.path("/length"))
6574 .header(SERVER_TIMING, timing));
@@ -96,24 +105,21 @@ impl Run {
96105
97106 fn land(&mut self, profile: &Profile, length: f64, girth: f64, clean: bool) -> Outcome {
98107 if self.body.eq < *EQ_BAND.start() {
99- self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
108+ let foreskin = self.gain(length / 2.0, girth / 2.0, profile.foreskin_growth);
100109 Outcome::json(
101110 StatusCode::PARTIAL_CONTENT,
102- json!({
103- "detail": "Partial gains were applied. EQ was below the band.",
104- "length": round3(length / 2.0),
105- "girth": round3(girth / 2.0),
106- }),
111+ gains(
112+ "Partial gains were applied. EQ was below the band.",
113+ length / 2.0,
114+ girth / 2.0,
115+ foreskin,
116+ ),
107117 )
108118 } else if clean {
109- self.gain(length, girth, profile.foreskin_growth);
119+ let foreskin = self.gain(length, girth, profile.foreskin_growth);
110120 Outcome::json(
111121 StatusCode::OK,
112- json!({
113- "detail": "Gains were applied.",
114- "length": round3(length),
115- "girth": round3(girth),
116- }),
122+ gains("Gains were applied.", length, girth, foreskin),
117123 )
118124 } else {
119125 let eta = self.oracle.span_int(
@@ -165,14 +171,14 @@ impl Run {
165171
166172 pub(super) fn critical_failure(&mut self, training: Training, profile: &Profile) -> Problem {
167173 let uses_foreskin = profile.foreskin.is_some_and(|(min, _)| min > 0.0);
168- if uses_foreskin && self.body.foreskin.is_some() {
169- self.body.foreskin = None;
174+ if uses_foreskin && let Some(lost) = self.body.foreskin.take() {
170175 self.standing.shame += 25;
171176 return Problem::new(
172177 StatusCode::GONE,
173178 "foreskin-lost",
174179 "The technique has removed the foreskin. It will not be returning.",
175180 )
181+ .with("foreskin", -round3(lost))
176182 .header(header::CONTENT_LOCATION, self.path("/foreskin"));
177183 }
178184 if self.hotter_than(DANGER_EQ) {
Modified file: src/game/run.rs+4 additions−4 deletions
@@ -362,12 +362,12 @@ impl Run {
362362 }
363363 }
364364
365- pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) {
365+ pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) -> Option<f64> {
366366 self.body.length += length;
367367 self.body.girth += girth;
368- if let Some(f) = &mut self.body.foreskin {
369- *f += foreskin;
370- }
368+ let f = self.body.foreskin.as_mut().filter(|_| foreskin != 0.0)?;
369+ *f += foreskin;
370+ Some(foreskin)
371371 }
372372
373373 pub(super) fn rupture(&mut self) -> Problem {
Modified file: src/tests/body.rs+19 additions
@@ -415,3 +415,22 @@ fn the_drawer_starts_with_at_most_one_bottle() {
415415 "{stocked}/400 started with lube"
416416 );
417417 }
418+
419+#[tokio::test]
420+async fn stretching_reports_the_foreskin_it_grew() {
421+ let mut landed = 0;
422+ for seed in 0..40u64 {
423+ let app = app_with(Floor::Bedroom, seed, |run| {
424+ run.body.warmed_up = true;
425+ run.body.eq = 60;
426+ run.body.foreskin = Some(1.0);
427+ });
428+ let stretch = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
429+ if stretch.status() == StatusCode::OK {
430+ assert_eq!(header(&stretch, "x-foreskin"), "1.03");
431+ assert_eq!(json(stretch).await["foreskin"], 0.03);
432+ landed += 1;
433+ }
434+ }
435+ assert!(landed > 0, "forty stretches and none landed clean");
436+}
Modified file: src/tests/rolls.rs+8 additions−6 deletions
@@ -215,12 +215,14 @@ async fn degloving_takes_one_cold_stroke_in_twenty() {
215215 run.body.foreskin = Some(1.0);
216216 });
217217 let cold = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
218- if cold.status() == StatusCode::GONE
219- && json(cold).await["type"]
220- .as_str()
221- .unwrap()
222- .ends_with("degloved")
223- {
218+ if cold.status() != StatusCode::GONE {
219+ continue;
220+ }
221+ let bare = header(&cold, "x-foreskin") == "gone";
222+ let problem = json(cold).await;
223+ if problem["type"].as_str().unwrap().ends_with("degloved") {
224+ assert!(bare, "degloving takes the foreskin with it");
225+ assert_eq!(problem["foreskin"], -1.0);
224226 degloved += 1;
225227 }
226228 }