atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 058650ec79d6

tarball

atagen <boss@atagen.co>2026-10-12 11:01 UTC

11 files changed+222-26
game: give length, girth and foreskin their own consequences

EQ drove every decision while the stats it grows only fed the
leaderboard, so each now bites somewhere of its own. Foreskin glides
unlubed strokes, girth earns respect from the gym staff and forum mods
at 5 and chafes and outgrows the underhand grip past 5.5, and length
decides whether the changing room counts your suspicion twice. The
clinic reports all three, and the leaderboard ranks by volume.
Added file: .cade+1 additions
@@ -0,0 +1 @@
1+load flake
Modified file: src/game/balance.rs+5 additions
@@ -39,6 +39,11 @@ pub const MM_PER_INCH: f64 = 25.4;
3939 pub const HONEST_TOLERANCE: f64 = 0.06;
4040 pub const EXAGGERATION: f64 = 1.0;
4141 pub const STORAGE_GIRTH: f64 = 5.0;
42+pub const GIRTH_RESPECTED: f64 = 5.0;
43+pub const GIRTH_THICK: f64 = 5.5;
44+pub const FORESKIN_GLIDE: f64 = 1.0;
45+pub const LOCKER_ROOM_BAR: f64 = 8.0;
46+pub const LOCKER_ROOM_SHAME: u32 = 3;
4247 pub const PUT_GAIN: f64 = 0.01;
4348 pub const PUMP_GAIN: f64 = 0.5;
4449 pub const EXTENDER_GAIN: f64 = 0.004;
Modified file: src/game/body.rs+33 additions−2 deletions
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
44
55 use super::{
66 Grip, Outcome, Run,
7- balance::{EXAGGERATION, PUT_GAIN},
7+ balance::{EXAGGERATION, GIRTH_RESPECTED, GIRTH_THICK, LOCKER_ROOM_BAR, PUT_GAIN},
88 etag_matches, honest,
99 };
1010 use crate::problem::Problem;
@@ -114,8 +114,39 @@ impl Run {
114114 Ok(Outcome::empty(StatusCode::NO_CONTENT))
115115 }
116116
117- pub fn set_grip(&mut self, grip: Grip) {
117+ pub fn set_grip(&mut self, grip: Grip) -> Result<(), Problem> {
118+ self.grip_closes(grip)?;
118119 self.grip = grip;
119120 self.touch();
121+ Ok(())
122+ }
123+
124+ pub(super) fn grip_closes(&self, grip: Grip) -> Result<(), Problem> {
125+ if grip == Grip::Underhand && self.girth > GIRTH_THICK {
126+ return Err(Problem::new(
127+ StatusCode::PAYLOAD_TOO_LARGE,
128+ "grip-too-small",
129+ format!(
130+ "The underhand grip no longer closes around {:.2} inches of girth.",
131+ self.girth
132+ ),
133+ )
134+ .with("maximum", GIRTH_THICK));
135+ }
136+ Ok(())
137+ }
138+
139+ /// Everyone in the changing room self-reports, so the comparison is self-report against self-report.
140+ pub fn holds_own_in_the_locker_room(&self) -> bool {
141+ self.self_reported() >= LOCKER_ROOM_BAR
142+ }
143+
144+ pub fn girth_respected(&self) -> bool {
145+ self.girth >= GIRTH_RESPECTED
146+ }
147+
148+ /// A cylinder whose circumference is the girth.
149+ pub fn volume(&self, length: f64) -> f64 {
150+ self.girth * self.girth * length / (4.0 * std::f64::consts::PI)
120151 }
121152 }
Modified file: src/game/clinic.rs+14 additions−2 deletions
@@ -2,7 +2,10 @@ use axum::http::{StatusCode, header};
22 use serde_json::json;
33
44 use super::{Floor, Outcome, Run, etag_matches, parse_credentials, round2};
5-use crate::{headers::X_DIAGNOSIS, problem::Problem};
5+use crate::{
6+ headers::{SERVER_TIMING, X_DIAGNOSIS},
7+ problem::Problem,
8+};
69
710 const INTAKE: [&str; 3] = [
811 "The clinic does not accept excuses.",
@@ -60,14 +63,23 @@ impl Run {
6063 self.shame += 15;
6164 }
6265 }
66+ let volume = self.volume(self.length);
67+ let foreskin = self.foreskin.unwrap_or(0.0);
68+ let timing = format!(
69+ "length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",
70+ self.length, self.girth
71+ );
6372 Ok(Outcome::json(
6473 StatusCode::OK,
6574 json!({
6675 "diagnosis": "within normal range",
6776 "measured_length": round2(self.length),
77+ "measured_girth": round2(self.girth),
78+ "volume": round2(volume),
6879 "prescription": "Please stop.",
6980 }),
7081 )
71- .header(header::ETAG, etag))
82+ .header(header::ETAG, etag)
83+ .header(SERVER_TIMING, timing))
7284 }
7385 }
Modified file: src/game/floors.rs+8 additions−1 deletions
@@ -5,7 +5,8 @@ use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
66 balance::{
77 DRAWER_JAMS, EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER,
8- KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,
8+ KEY_IN_DRAWER, LOCKER_ROOM_BAR, LOCKER_ROOM_SHAME, RULER_IN_DRAWER, STARTING_EQ,
9+ SUSPICION_LIMIT, ZIP,
910 },
1011 round2,
1112 };
@@ -27,6 +28,11 @@ impl Run {
2728 self.suspicion = 0;
2829 self.shed(FRICTION_FLOOR);
2930 self.mum = None;
31+ if self.floor == Floor::ChangingRoom && !self.holds_own_in_the_locker_room() {
32+ self.shame += LOCKER_ROOM_SHAME;
33+ self.notices
34+ .push("Kev is 8 inches, he says. Nobody asked you, which is worse.");
35+ }
3036 if paid {
3137 self.cash += FLOOR_ALLOWANCE;
3238 }
@@ -106,6 +112,7 @@ impl Run {
106112 { "name": "Greg", "self_reported_length": 9.5 },
107113 { "name": "Dmitri", "self_reported_length": 8.75 },
108114 { "name": "a man in a towel", "self_reported_length": 11.0 },
115+ { "name": "Kev", "self_reported_length": LOCKER_ROOM_BAR },
109116 ]);
110117 }
111118 Floor::Hotel => view["portal"] = json!(self.path("/portal")),
Modified file: src/game/forum.rs+8 additions−1 deletions
@@ -195,7 +195,14 @@ impl Run {
195195 }
196196 if !honest(claimed, actual) {
197197 self.shame += 1;
198- return Err(self.elder_strike(verification("Verification failed. The forum can tell.")));
198+ let failed = verification("Verification failed. The forum can tell.");
199+ if self.girth_respected() {
200+ return Err(failed.header(
201+ X_FORUM_NOTE,
202+ "The mods have let this one go. Girth is also a dimension.",
203+ ));
204+ }
205+ return Err(self.elder_strike(failed));
199206 }
200207 self.elder = Elder::Verified;
201208 self.touch();
Modified file: src/game/hazards.rs+6 additions−1 deletions
@@ -176,8 +176,13 @@ impl Run {
176176 }
177177 Floor::ChangingRoom => {
178178 self.suspicion = 0;
179- self.shame += 5;
180179 self.touch();
180+ if self.girth_respected() {
181+ self.notices
182+ .push("the gym staff have seen worse, and they leave it there");
183+ return Ok(());
184+ }
185+ self.shame += 5;
181186 Err(Problem::new(
182187 StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS,
183188 "gym-staff",
Modified file: src/game/turn.rs+18 additions−7 deletions
@@ -7,11 +7,11 @@ use super::{
77 Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
88 balance::{
99 ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ,
10- EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FRICTION_BURN, FRICTION_BURN_AT, FRICTION_DRY,
11- FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT, GREED_HOT,
12- GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS,
13- PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM, RING_RUPTURE_LINE,
14- RUPTURE_HOLD, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
10+ EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FORESKIN_GLIDE, FRICTION_BURN, FRICTION_BURN_AT,
11+ FRICTION_DRY, FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT,
12+ GIRTH_THICK, GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ,
13+ PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM,
14+ RING_RUPTURE_LINE, RUPTURE_HOLD, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
1515 },
1616 not_extended, round3,
1717 state::{MumVisit, Observer, Pending},
@@ -194,6 +194,9 @@ impl Run {
194194 )
195195 .with("technique", Utility::Warmup.name()));
196196 }
197+ if matches!(technique, Technique::Training(_)) {
198+ self.grip_closes(self.grip)?;
199+ }
197200 match technique {
198201 Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(
199202 StatusCode::FAILED_DEPENDENCY,
@@ -342,7 +345,10 @@ impl Run {
342345 6..=8 => 1,
343346 _ => 2,
344347 };
345- let mut friction = (base + vigour - slip).max(0);
348+ let unlubed = matches!(lube, None | Some(Lube::Spit));
349+ let thick = i32::from(unlubed && self.girth > GIRTH_THICK);
350+ let glide = i32::from(unlubed && self.foreskin.is_some_and(|f| f >= FORESKIN_GLIDE));
351+ let mut friction = (base + vigour + thick - slip - glide).max(0);
346352 if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
347353 friction -= FRICTION_GRIP;
348354 }
@@ -391,7 +397,12 @@ impl Run {
391397 self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);
392398
393399 let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));
394- if matches!(self.floor, Floor::ChangingRoom | Floor::Nans) {
400+ let doubled = match self.floor {
401+ Floor::Nans => true,
402+ Floor::ChangingRoom => !self.holds_own_in_the_locker_room(),
403+ _ => false,
404+ };
405+ if doubled {
395406 suspicion *= 2;
396407 }
397408 if self.floor == Floor::Nans && lube == Some(Lube::CoconutOil) {
Modified file: src/http/meta.rs+23 additions−11 deletions
@@ -162,11 +162,22 @@ fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
162162 Json(json!({ "runs": dead })).into_response()
163163 }
164164
165+#[derive(Clone, Copy, PartialEq, Eq)]
166+enum Sort {
167+ SelfReported,
168+ ContentLength,
169+ /// Self-reported length against real girth, the only metric the forum respects.
170+ Volume,
171+}
172+
165173 fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
166- let truthful = match ctx.query("sort") {
167- None | Some("self-reported-length") => false,
174+ let sort = match ctx.query("sort") {
175+ None | Some("self-reported-length") => Sort::SelfReported,
176+ Some("volume") => Sort::Volume,
168177 Some("content-length") => match ctx.cookie("ruler") {
169- Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => true,
178+ Some(ruler) if state.config.secret.verify("ruler", ruler) == Some("1") => {
179+ Sort::ContentLength
180+ }
170181 Some(_) => {
171182 return Problem::new(
172183 StatusCode::BAD_REQUEST,
@@ -189,7 +200,7 @@ fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
189200 return Problem::new(
190201 StatusCode::BAD_REQUEST,
191202 "unknown-sort",
192- "The leaderboard sorts by self-reported-length or content-length.",
203+ "The leaderboard sorts by self-reported-length, volume or content-length.",
193204 )
194205 .into_response();
195206 }
@@ -200,12 +211,10 @@ fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
200211 .values()
201212 .filter(|run| include_seeded || !run.seeded)
202213 .collect();
203- let key = |run: &Run| {
204- if truthful {
205- run.current_length()
206- } else {
207- run.self_reported()
208- }
214+ let key = |run: &Run| match sort {
215+ Sort::SelfReported => run.self_reported(),
216+ Sort::ContentLength => run.current_length(),
217+ Sort::Volume => run.volume(run.self_reported()),
209218 };
210219 entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
211220 let entries: Vec<Value> = entries
@@ -219,7 +228,10 @@ fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
219228 "ranked": !run.seeded,
220229 "viral": run.filmed_at.is_some(),
221230 });
222- if truthful {
231+ if sort == Sort::Volume {
232+ entry["volume"] = json!((run.volume(run.self_reported()) * 100.0).round() / 100.0);
233+ }
234+ if sort == Sort::ContentLength {
223235 entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());
224236 }
225237 entry
Modified file: src/http/runs.rs+3 additions−1 deletions
@@ -513,7 +513,9 @@ fn requested_grip(ctx: &Ctx) -> Option<Grip> {
513513
514514 fn get_grip(run: &mut Run, ctx: &Ctx) -> Response {
515515 if let Some(grip) = requested_grip(ctx) {
516- run.set_grip(grip);
516+ if let Err(problem) = run.set_grip(grip) {
517+ return problem.into_response();
518+ }
517519 let mut response = (
518520 StatusCode::SWITCHING_PROTOCOLS,
519521 [
Modified file: src/tests.rs+103 additions
@@ -1421,6 +1421,7 @@ async fn the_working_supplement_resets_stamina() {
14211421 async fn dry_jelqs_build_friction_and_real_lube_does_not() {
14221422 let app = seeded_with(Floor::Bedroom, |run| {
14231423 run.warmed_up = true;
1424+ run.foreskin = Some(0.9);
14241425 });
14251426 let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
14261427 assert_eq!(header(&dry, "x-friction"), "2/10");
@@ -1450,6 +1451,7 @@ async fn dry_jelqs_build_friction_and_real_lube_does_not() {
14501451 async fn switching_grips_makes_the_friction_cheaper() {
14511452 let app = seeded_with(Floor::Bedroom, |run| {
14521453 run.warmed_up = true;
1454+ run.foreskin = Some(0.9);
14531455 });
14541456 let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
14551457 assert_eq!(header(&dry, "x-friction"), "2/10");
@@ -1936,6 +1938,7 @@ async fn friction_rises_with_vigour_and_edging_chafes() {
19361938 run.here.wifi_accepted = true;
19371939 run.warmed_up = true;
19381940 run.eq = 55;
1941+ run.foreskin = Some(0.5);
19391942 });
19401943 let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");
19411944 let jelq = send(&app, "JELQ", &uri, &spit, "").await;
@@ -1949,7 +1952,107 @@ async fn friction_rises_with_vigour_and_edging_chafes() {
19491952 run.here.wifi_accepted = true;
19501953 run.warmed_up = true;
19511954 run.eq = 55;
1955+ run.foreskin = Some(0.5);
19521956 });
19531957 let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
19541958 assert_eq!(header(&edge, "x-friction"), "2/10");
19551959 }
1960+
1961+#[tokio::test]
1962+async fn the_body_changes_how_the_skin_takes_it() {
1963+ let spit = [("content-type", "text/plain; charset=spit")];
1964+ for (foreskin, girth, friction) in [(0.5, 4.5, "1/10"), (1.2, 4.5, "0/10"), (0.5, 5.8, "2/10")]
1965+ {
1966+ let app = seeded_with(Floor::Hotel, |run| {
1967+ run.here.wifi_accepted = true;
1968+ run.warmed_up = true;
1969+ run.eq = 55;
1970+ run.foreskin = Some(foreskin);
1971+ run.girth = girth;
1972+ });
1973+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;
1974+ assert_eq!(header(&jelq, "x-friction"), friction, "{foreskin} {girth}");
1975+ }
1976+}
1977+
1978+#[tokio::test]
1979+async fn a_thick_girth_outgrows_the_underhand_grip() {
1980+ let app = seeded_with(Floor::Bedroom, |run| run.girth = 5.8);
1981+ let grip = send(
1982+ &app,
1983+ "GET",
1984+ "/runs/cafebabe/grip",
1985+ &[("upgrade", "underhand"), ("connection", "upgrade")],
1986+ "",
1987+ )
1988+ .await;
1989+ assert_eq!(grip.status(), StatusCode::PAYLOAD_TOO_LARGE);
1990+}
1991+
1992+#[tokio::test]
1993+async fn respectable_girth_waves_off_the_gym_staff() {
1994+ for (girth, status) in [
1995+ (4.5, StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS),
1996+ (5.2, StatusCode::OK),
1997+ ] {
1998+ let app = seeded_with(Floor::ChangingRoom, |run| {
1999+ run.warmed_up = true;
2000+ run.eq = 45;
2001+ run.girth = girth;
2002+ run.suspicion = SUSPICION_LIMIT;
2003+ });
2004+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
2005+ if girth > 5.0 {
2006+ assert_ne!(edge.status(), StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS);
2007+ assert!(header(&edge, "warning").contains("gym staff"));
2008+ } else {
2009+ assert_eq!(edge.status(), status);
2010+ }
2011+ }
2012+}
2013+
2014+#[tokio::test]
2015+async fn kev_is_the_bar_in_the_changing_room() {
2016+ for (length, shamed) in [(5.0, true), (7.0, false)] {
2017+ let app = seeded_with(Floor::Bathroom, |run| {
2018+ run.length = length;
2019+ run.here.turns = 6;
2020+ });
2021+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
2022+ assert_eq!(header(&left, "x-floor"), "3");
2023+ let warning = left
2024+ .headers()
2025+ .get("warning")
2026+ .map(|w| w.to_str().unwrap().to_owned());
2027+ assert_eq!(
2028+ warning.is_some_and(|w| w.contains("Kev")),
2029+ shamed,
2030+ "length {length}"
2031+ );
2032+ }
2033+}
2034+
2035+#[tokio::test]
2036+async fn the_forum_respects_volume() {
2037+ let state = Arc::new(AppState::default());
2038+ for (id, length, girth) in [("slim", 7.0, 4.0), ("stout", 5.0, 5.5)] {
2039+ let mut run = Run::new(id.into(), id.into(), 1);
2040+ run.length = length;
2041+ run.girth = girth;
2042+ state.insert(run);
2043+ }
2044+ let app = router(state);
2045+ let board = json(
2046+ send(
2047+ &app,
2048+ "GET",
2049+ "/leaderboard?sort=volume&seeded=include",
2050+ &[],
2051+ "",
2052+ )
2053+ .await,
2054+ )
2055+ .await;
2056+ assert_eq!(board["entries"][0]["public_id"], "stout");
2057+ assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);
2058+}