atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 5dfebcc0ac43

tarball

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

11 files changed+225-16
game: friction makes the dry jelq a debt the skin collects

Friction rides the run across floors, builds only on jelqs without a real lubricant, and the grip switch discounts it by one, so alternating grips becomes the poor man's lubricant. At 6 the skin rolls for a burn each jelq, which halves gains and drops every rupture tier 15 EQ until an ice pack treats it. HOLD, meditation and a floor change shed it, and X-Friction carries the count.
Modified file: src/game/balance.rs+10 additions
@@ -74,6 +74,16 @@ pub const WATERMELON: (u32, u32) = (2, 5);
7474 pub const CAUGHT_MELONED: u32 = 3;
7575 pub const MELON_SHAME: u32 = 5;
7676 pub const ENERGY_STAMINA: i32 = 3;
77+pub const FRICTION_MAX: i32 = 10;
78+pub const FRICTION_DRY: i32 = 2;
79+pub const FRICTION_SPIT: i32 = 1;
80+pub const FRICTION_GRIP: i32 = 1;
81+pub const FRICTION_HOLD: i32 = 2;
82+pub const FRICTION_MEDITATE: i32 = 2;
83+pub const FRICTION_FLOOR: i32 = 1;
84+pub const FRICTION_BURN_AT: i32 = 6;
85+pub const FRICTION_BURN: u32 = 10;
86+pub const BURN_RUPTURE: i32 = 15;
7787 pub const OBSERVED: u32 = 6;
7888 pub const CAT_TAKES_TOWEL: u32 = 5;
7989 pub const FRIEND: u32 = 10;
Modified file: src/game/floors.rs+3 additions−2 deletions
@@ -4,8 +4,8 @@ use serde_json::{Value, json};
44 use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
66 balance::{
7- EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ,
8- SUSPICION_LIMIT, ZIP,
7+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, KEY_IN_DRAWER, RULER_IN_DRAWER,
8+ STARTING_EQ, SUSPICION_LIMIT, ZIP,
99 },
1010 round2,
1111 };
@@ -25,6 +25,7 @@ impl Run {
2525 self.warmed_up = false;
2626 self.eq = STARTING_EQ;
2727 self.suspicion = 0;
28+ self.shed(FRICTION_FLOOR);
2829 self.mum = None;
2930 if paid {
3031 self.cash += FLOOR_ALLOWANCE;
Modified file: src/game/mod.rs+8 additions
@@ -59,6 +59,8 @@ pub struct Run {
5959 pub shame_total: u32,
6060 pub cash: u32,
6161 pub grip: Grip,
62+ pub friction: i32,
63+ pub jelq_grip: Option<Grip>,
6264 pub items: Items,
6365 pub conditions: Conditions,
6466 pub warmed_up: bool,
@@ -146,6 +148,8 @@ impl Run {
146148 shame_total: 0,
147149 cash: STARTING_CASH,
148150 grip: Grip::Overhand,
151+ friction: 0,
152+ jelq_grip: None,
149153 items: Items::default(),
150154 conditions: Conditions::default(),
151155 warmed_up: false,
@@ -189,6 +193,10 @@ impl Run {
189193 self.version += 1;
190194 }
191195
196+ pub(super) fn shed(&mut self, friction: i32) {
197+ self.friction = (self.friction - friction).max(0);
198+ }
199+
192200 fn fresh_nonce(&mut self) -> String {
193201 format!("{:016x}", self.rng.random::<u64>())
194202 }
Modified file: src/game/state.rs+1 additions
@@ -153,6 +153,7 @@ pub struct Conditions {
153153 pub peyronie: bool,
154154 pub pulled_muscle: bool,
155155 pub priapism_turns: u8,
156+ pub friction_burn: bool,
156157 }
157158
158159 pub struct MumVisit {
Modified file: src/game/techniques.rs+2 additions−2 deletions
@@ -178,11 +178,11 @@ impl Utility {
178178 fn effect(self) -> &'static str {
179179 match self {
180180 Self::Warmup => "EQ +25. Satisfies the warm-up precondition.",
181- Self::Ice => "EQ -40. Consumes an ice pack.",
181+ Self::Ice => "EQ -40. Treats a friction burn. Consumes an ice pack.",
182182 Self::Edge => "EQ +15 and a little girth. Three in a row is a loop.",
183183 Self::Pump => "Large gains, all temporary. Requires a pump.",
184184 Self::Supplement => "Consumes a supplement.",
185- Self::Meditate => "EQ -10. Taught by the forum. Once per floor, no stamina.",
185+ Self::Meditate => "EQ -10, sheds 2 friction. Taught by the forum. Once per floor.",
186186 Self::Energy => "+3 stamina. Consumes an energy drink. Tastes like a battery.",
187187 }
188188 }
Modified file: src/game/turn.rs+40 additions−6 deletions
@@ -7,10 +7,12 @@ use serde_json::json;
77 use super::{
88 Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
99 balance::{
10- ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ,
11- EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ,
12- OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION,
13- RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
10+ ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, BURN_RUPTURE, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL,
11+ DANGER_EQ, EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FRICTION_BURN, FRICTION_BURN_AT,
12+ FRICTION_DRY, FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT,
13+ GREED_HOT, GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ,
14+ PRIAPISM_TURNS, PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM,
15+ RING_RUPTURE_LINE, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
1416 },
1517 not_extended, round3,
1618 state::{MumVisit, Observer, Pending},
@@ -102,6 +104,7 @@ impl Run {
102104 self.take_lube(lube)?;
103105 self.check_storage()?;
104106 self.floor_enemy()?;
107+ self.chafe(lube);
105108 self.commit(intensity, lube, now, 1);
106109 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
107110 return Ok(interrupted);
@@ -134,6 +137,7 @@ impl Run {
134137 self.take_lube(lube)?;
135138 self.check_storage()?;
136139 self.floor_enemy()?;
140+ self.chafe(lube);
137141 self.commit(intensity, lube, now, 2);
138142 for training in [a, b] {
139143 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
@@ -343,6 +347,25 @@ impl Run {
343347 Ok(())
344348 }
345349
350+ fn chafe(&mut self, lube: Option<Lube>) {
351+ let mut friction = match lube {
352+ None => FRICTION_DRY,
353+ Some(Lube::Spit) => FRICTION_SPIT,
354+ Some(_) => 0,
355+ };
356+ if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
357+ friction -= FRICTION_GRIP;
358+ }
359+ self.jelq_grip = Some(self.grip);
360+ self.friction = (self.friction + friction).min(FRICTION_MAX);
361+ if self.friction >= FRICTION_BURN_AT
362+ && !self.conditions.friction_burn
363+ && self.chance(FRICTION_BURN)
364+ {
365+ self.conditions.friction_burn = true;
366+ }
367+ }
368+
346369 pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
347370 self.touch();
348371 self.turn += 1;
@@ -442,6 +465,7 @@ impl Run {
442465 self.eq = (self.eq - HOLD_EQ).clamp(0, 100);
443466 }
444467 self.stamina = (self.stamina + 1).min(self.max_stamina());
468+ self.shed(FRICTION_HOLD);
445469 self.settle(was_below);
446470 Ok(Outcome::empty(StatusCode::NO_CONTENT))
447471 }
@@ -459,6 +483,7 @@ impl Run {
459483 }
460484 self.here.meditated = true;
461485 self.eq = (self.eq - MEDITATION_EQ).clamp(0, 100);
486+ self.shed(FRICTION_MEDITATE);
462487 self.touch();
463488 Ok(Outcome::json(
464489 StatusCode::OK,
@@ -520,6 +545,11 @@ impl Run {
520545 let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
521546
522547 let (length, girth) = self.scaled_gains(&profile, intensity);
548+ let burn = if self.conditions.friction_burn {
549+ BURN_RUPTURE
550+ } else {
551+ 0
552+ };
523553 let rupture_line = if self.items.ring {
524554 RING_RUPTURE_LINE
525555 } else {
@@ -529,10 +559,10 @@ impl Run {
529559 if natural == 1 {
530560 return Err(timed(self.critical_failure(training, &profile)));
531561 }
532- if natural == 2 && self.eq > DANGER_EQ {
562+ if natural == 2 && self.eq > DANGER_EQ - burn {
533563 return Err(timed(self.rupture()));
534564 }
535- if self.eq > rupture_line && total < dc {
565+ if self.eq > rupture_line - burn && total < dc {
536566 return Err(timed(self.rupture()));
537567 }
538568 if natural == 20 {
@@ -613,6 +643,10 @@ impl Run {
613643 if self.items.ring {
614644 girth *= 2.0;
615645 }
646+ if self.conditions.friction_burn {
647+ length *= 0.5;
648+ girth *= 0.5;
649+ }
616650 (length, girth)
617651 }
618652
Modified file: src/game/utility.rs+7 additions−4 deletions
@@ -63,10 +63,13 @@ impl Run {
6363 ));
6464 }
6565 self.eq = (self.eq - ICE_EQ).max(0);
66- Ok(Outcome::json(
67- StatusCode::OK,
68- json!({ "detail": "EQ has been reduced. The ice pack is gone." }),
69- ))
66+ let detail = if self.conditions.friction_burn {
67+ self.conditions.friction_burn = false;
68+ "The friction burn is treated. EQ has been reduced. The ice pack is gone."
69+ } else {
70+ "EQ has been reduced. The ice pack is gone."
71+ };
72+ Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
7073 }
7174
7275 pub(super) fn energy(&mut self) -> Result<Outcome, Problem> {
Modified file: src/headers.rs+1 additions
@@ -7,6 +7,7 @@ pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
77 pub const X_EQ: HeaderName = HeaderName::from_static("x-eq");
88 pub const X_FLOOR: HeaderName = HeaderName::from_static("x-floor");
99 pub const X_FORESKIN: HeaderName = HeaderName::from_static("x-foreskin");
10+pub const X_FRICTION: HeaderName = HeaderName::from_static("x-friction");
1011 pub const X_FORUM_NOTE: HeaderName = HeaderName::from_static("x-forum-note");
1112 pub const X_GIRTH: HeaderName = HeaderName::from_static("x-girth");
1213 pub const X_LENGTH: HeaderName = HeaderName::from_static("x-length");
Modified file: src/http/run.rs+6 additions−2 deletions
@@ -13,10 +13,10 @@ use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource,
1313 use crate::{
1414 game::{
1515 Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
16- balance::{BRANCH_TURN, KITCHEN_TELLINGS, SUSPICION_LIMIT},
16+ balance::{BRANCH_TURN, FRICTION_MAX, KITCHEN_TELLINGS, SUSPICION_LIMIT},
1717 },
1818 headers::{
19- X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
19+ X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_FRICTION, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
2020 X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,
2121 },
2222 locale::Locale,
@@ -213,6 +213,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
213213 X_FORESKIN,
214214 run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
215215 ),
216+ (X_FRICTION, format!("{}/{}", run.friction, FRICTION_MAX)),
216217 (X_EQ, run.eq.to_string()),
217218 (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())),
218219 (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)),
@@ -232,6 +233,9 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
232233 if run.conditions.pulled_muscle {
233234 stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));
234235 }
236+ if run.conditions.friction_burn {
237+ stats.push((header::WARNING, "199 - \"friction burn\"".into()));
238+ }
235239 for notice in run.notices.drain(..) {
236240 stats.push((header::WARNING, format!("199 - \"{notice}\"")));
237241 }
Modified file: src/tests.rs+146 additions
@@ -1292,3 +1292,149 @@ async fn the_working_supplement_resets_stamina() {
12921292 }
12931293 assert!(worked > 0 && bunk > 0, "one in five should work too well");
12941294 }
1295+
1296+#[tokio::test]
1297+async fn dry_jelqs_build_friction_and_real_lube_does_not() {
1298+ let app = seeded_with(Floor::Bedroom, |run| {
1299+ run.warmed_up = true;
1300+ });
1301+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1302+ assert_eq!(header(&dry, "x-friction"), "2/10");
1303+ let lube = [("content-type", "application/baby-oil")];
1304+ let wet = send(
1305+ &app,
1306+ "JELQ",
1307+ "/runs/cafebabe?technique=wet&intensity=5",
1308+ &lube,
1309+ "",
1310+ )
1311+ .await;
1312+ assert_eq!(header(&wet, "x-friction"), "2/10");
1313+ let spit = [("content-type", "text/plain; charset=spit")];
1314+ let glisten = send(
1315+ &app,
1316+ "JELQ",
1317+ "/runs/cafebabe?technique=wet&intensity=5",
1318+ &spit,
1319+ "",
1320+ )
1321+ .await;
1322+ assert_eq!(header(&glisten, "x-friction"), "3/10");
1323+}
1324+
1325+#[tokio::test]
1326+async fn switching_grips_makes_the_friction_cheaper() {
1327+ let app = seeded_with(Floor::Bedroom, |run| {
1328+ run.warmed_up = true;
1329+ });
1330+ let dry = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1331+ assert_eq!(header(&dry, "x-friction"), "2/10");
1332+ send(
1333+ &app,
1334+ "GET",
1335+ "/runs/cafebabe/grip",
1336+ &[("upgrade", "underhand"), ("connection", "upgrade")],
1337+ "",
1338+ )
1339+ .await;
1340+ let switched = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1341+ assert_eq!(header(&switched, "x-friction"), "3/10");
1342+ let settled = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
1343+ assert_eq!(header(&settled, "x-friction"), "5/10");
1344+}
1345+
1346+#[tokio::test]
1347+async fn holding_and_meditating_shed_friction() {
1348+ let app = seeded_with(Floor::Bedroom, |run| {
1349+ run.eq = 80;
1350+ run.friction = 5;
1351+ });
1352+ let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1353+ assert_eq!(held.status(), StatusCode::NO_CONTENT);
1354+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1355+ assert_eq!(header(&view, "x-friction"), "3/10");
1356+
1357+ let taught = seeded_with(Floor::Bedroom, |run| {
1358+ run.meditation_taught = true;
1359+ run.friction = 5;
1360+ });
1361+ let calmed = send(
1362+ &taught,
1363+ "JELQ",
1364+ "/runs/cafebabe?technique=meditate",
1365+ &[],
1366+ "",
1367+ )
1368+ .await;
1369+ assert_eq!(calmed.status(), StatusCode::OK);
1370+ let view = send(&taught, "GET", "/runs/cafebabe", &[], "").await;
1371+ assert_eq!(header(&view, "x-friction"), "3/10");
1372+}
1373+
1374+#[tokio::test]
1375+async fn a_friction_burn_halves_the_gains() {
1376+ let lube = [("content-type", "application/baby-oil")];
1377+ let path = "/runs/cafebabe?technique=wet&intensity=5";
1378+ for seed in 0..60u64 {
1379+ let steady = app_with(Floor::Bedroom, seed, |run| {
1380+ run.warmed_up = true;
1381+ run.eq = 60;
1382+ run.items.baby_oil = 9;
1383+ });
1384+ let hurt_app = app_with(Floor::Bedroom, seed, |run| {
1385+ run.warmed_up = true;
1386+ run.eq = 60;
1387+ run.items.baby_oil = 9;
1388+ run.conditions.friction_burn = true;
1389+ });
1390+ let calm = send(&steady, "JELQ", path, &lube, "").await;
1391+ let hurt = send(&hurt_app, "JELQ", path, &lube, "").await;
1392+ if calm.status() != StatusCode::OK || hurt.status() != StatusCode::OK {
1393+ continue;
1394+ }
1395+ let calm_gain = json(calm).await["length"].as_f64().unwrap();
1396+ let hurt_gain = json(hurt).await["length"].as_f64().unwrap();
1397+ assert!(
1398+ (hurt_gain * 2.0 - calm_gain).abs() < 1e-9,
1399+ "seed {seed} burned {hurt_gain} against {calm_gain}"
1400+ );
1401+ return;
1402+ }
1403+ panic!("no seed in 60 produced two clean rolls to compare");
1404+}
1405+
1406+#[tokio::test]
1407+async fn the_burn_warns_and_the_ice_treats_it() {
1408+ let app = seeded_with(Floor::Bedroom, |run| {
1409+ run.warmed_up = true;
1410+ run.eq = 60;
1411+ run.items.baby_oil = 9;
1412+ run.friction = 7;
1413+ run.conditions.friction_burn = true;
1414+ });
1415+ let lube = [("content-type", "application/baby-oil")];
1416+ let jelq = send(
1417+ &app,
1418+ "JELQ",
1419+ "/runs/cafebabe?technique=wet&intensity=5",
1420+ &lube,
1421+ "",
1422+ )
1423+ .await;
1424+ let warnings: Vec<_> = jelq
1425+ .headers()
1426+ .get_all("warning")
1427+ .iter()
1428+ .filter_map(|w| w.to_str().ok())
1429+ .collect();
1430+ assert!(warnings.contains(&"199 - \"friction burn\""));
1431+ send(&app, "JELQ", "/runs/cafebabe?technique=ice", &[], "").await;
1432+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1433+ let warnings: Vec<_> = view
1434+ .headers()
1435+ .get_all("warning")
1436+ .iter()
1437+ .filter_map(|w| w.to_str().ok())
1438+ .collect();
1439+ assert!(!warnings.contains(&"199 - \"friction burn\""));
1440+}
Modified file: src/ui.html+1 additions
@@ -65,6 +65,7 @@ const STATS = [
6565 ["x-self-reported-length", "Self-reported length"],
6666 ["x-girth", "Girth"],
6767 ["x-foreskin", "Foreskin"],
68+ ["x-friction", "Friction"],
6869 ["x-eq", "EQ (aim for 50 to 70)"],
6970 ["x-stamina", "Stamina"],
7071 ["x-suspicion", "Suspicion"],