atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 4347977b8192

tarball

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

8 files changed+213-22
game: HOLD paces the band and gains multiply at the top of the red

EQ above the band used to be pure risk, so no run had a reason to leave it. Gains now multiply by 1.5 over 70 EQ and by 2 over 85, and leaving the band in order carries a +1 bonus into the first jelq of the next floor. The new HOLD method is the deliberate way back down, trading a floor turn for -15 EQ and +1 stamina, with one HOLD in twelve becoming a moment of self-reflection that empties EQ entirely.
Modified file: src/game/balance.rs+5 additions
@@ -60,6 +60,11 @@ pub const RING_PRIAPISM: u32 = 10;
6060 pub const SUPPLEMENT_WORKS: u32 = 5;
6161 pub const MEDITATION_ODDS: u32 = 2;
6262 pub const MEDITATION_EQ: i32 = 10;
63+pub const HOLD_EQ: i32 = 15;
64+pub const REFLECTION: u32 = 12;
65+pub const ARRIVAL_BONUS: i32 = 1;
66+pub const GREED_WARM: f64 = 1.5;
67+pub const GREED_HOT: f64 = 2.0;
6368 pub const OBSERVED: u32 = 6;
6469 pub const CAT_TAKES_TOWEL: u32 = 5;
6570 pub const FRIEND: u32 = 10;
Modified file: src/game/floors.rs+13 additions−4 deletions
@@ -4,8 +4,8 @@ use serde_json::{Value, json};
44 use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
66 balance::{
7- FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT,
8- ZIP,
7+ EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ,
8+ SUSPICION_LIMIT, ZIP,
99 },
1010 round2,
1111 };
@@ -16,8 +16,12 @@ impl Run {
1616 let Some(next) = self.floor.next() else {
1717 return;
1818 };
19+ let warm = EQ_BAND.contains(&self.eq);
1920 self.floor = next;
20- self.here = FloorState::default();
21+ self.here = FloorState {
22+ warm_arrival: warm,
23+ ..FloorState::default()
24+ };
2125 self.warmed_up = false;
2226 self.eq = STARTING_EQ;
2327 self.suspicion = 0;
@@ -63,8 +67,13 @@ impl Run {
6367 let left = self.floor.name();
6468 let reverted = self.here.temp_length;
6569 self.next_floor(true);
70+ let warm = if self.here.warm_arrival {
71+ ", and the warmth came with you"
72+ } else {
73+ ""
74+ };
6675 let mut body = json!({
67- "detail": format!("You leave {left}. You have arrived at {}.", self.floor.name()),
76+ "detail": format!("You leave {left}. You have arrived at {}{warm}.", self.floor.name()),
6877 "note": self.floor.note(),
6978 });
7079 if reverted > 0.0 {
Modified file: src/game/state.rs+1 additions
@@ -139,6 +139,7 @@ pub struct FloorState {
139139 pub brother_told: bool,
140140 pub shopping: bool,
141141 pub meditated: bool,
142+ pub warm_arrival: bool,
142143 pub wifi_accepted: bool,
143144 }
144145
Modified file: src/game/turn.rs+68 additions−13 deletions
@@ -7,10 +7,10 @@ use serde_json::json;
77 use super::{
88 Floor, Grip, LUBRICANTS, Lube, Outcome, Payload, Run, Story, Technique,
99 balance::{
10- BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ, EQ_BAND,
11- EQ_WOBBLE, EXTENDER_GAIN, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS,
12- PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, RING_PRIAPISM, RING_RUPTURE_LINE, RUPTURE_LINE,
13- STORAGE_GIRTH, SUSPICION_LIMIT,
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,
1414 },
1515 not_extended, round3,
1616 state::{MumVisit, Observer, Pending},
@@ -350,8 +350,20 @@ impl Run {
350350 self.suspicion =
351351 u8::try_from((i32::from(self.suspicion) + suspicion).min(i32::from(SUSPICION_LIMIT)))
352352 .unwrap_or(SUSPICION_LIMIT);
353- self.maybe_summon_mum(was_below);
354353
354+ if intensity >= PULLED_MUSCLE_INTENSITY
355+ && !self.conditions.pulled_muscle
356+ && self.chance(PULLED_MUSCLE)
357+ {
358+ self.conditions.pulled_muscle = true;
359+ self.stamina = self.stamina.min(self.max_stamina());
360+ }
361+ self.settle(was_below);
362+ }
363+
364+ /// The part of a turn the house takes part in, whatever the action was.
365+ fn settle(&mut self, was_below: bool) {
366+ self.maybe_summon_mum(was_below);
355367 if let Some(observer) = self.floor.observer()
356368 && self.chance(OBSERVED)
357369 {
@@ -361,13 +373,6 @@ impl Run {
361373 self.here.towel_stolen = true;
362374 }
363375 }
364- if intensity >= PULLED_MUSCLE_INTENSITY
365- && !self.conditions.pulled_muscle
366- && self.chance(PULLED_MUSCLE)
367- {
368- self.conditions.pulled_muscle = true;
369- self.stamina = self.stamina.min(self.max_stamina());
370- }
371376 if self.items.ring && self.eq > HOT_EQ && self.chance(RING_PRIAPISM) {
372377 self.conditions.priapism_turns = PRIAPISM_TURNS;
373378 }
@@ -382,6 +387,41 @@ impl Run {
382387 }
383388 }
384389
390+ /// HOLD. A pause between strokes, offered when the band has been left or the stamina is gone.
391+ pub fn can_hold(&self) -> bool {
392+ self.eq > DANGER_EQ || self.stamina <= 2
393+ }
394+
395+ pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {
396+ self.check_refractory(now)?;
397+ if self.story == Story::Pending {
398+ return Ok(self.branch_offer());
399+ }
400+ if self.items.cage {
401+ return Err(Problem::new(
402+ StatusCode::LOCKED,
403+ "caged",
404+ "The cage is locked. The key is not in the box.",
405+ ));
406+ }
407+ self.touch();
408+ self.turn += 1;
409+ self.here.turns += 1;
410+ self.realise();
411+ let was_below = self.suspicion < SUSPICION_LIMIT;
412+ if self.chance(REFLECTION) {
413+ self.eq = 0;
414+ self.shame += 1;
415+ self.notices
416+ .push("the count reached ten, and then the reflection started");
417+ } else {
418+ self.eq = (self.eq - HOLD_EQ).clamp(0, 100);
419+ }
420+ self.stamina = (self.stamina + 1).min(self.max_stamina());
421+ self.settle(was_below);
422+ Ok(Outcome::empty(StatusCode::NO_CONTENT))
423+ }
424+
385425 /// The breathing technique from the forum's replies. It spends no stamina and the turn is
386426 /// not the floor's, but each floor allows it once.
387427 fn meditate(&mut self) -> Result<Outcome, Problem> {
@@ -443,7 +483,13 @@ impl Run {
443483 (Grip::Underhand, Training::Wet) => -1,
444484 _ => 0,
445485 };
446- let modifier = lube.map_or(0, Lube::modifier) + band + grip;
486+ let arrival = if self.here.warm_arrival {
487+ self.here.warm_arrival = false;
488+ ARRIVAL_BONUS
489+ } else {
490+ 0
491+ };
492+ let modifier = lube.map_or(0, Lube::modifier) + band + grip + arrival;
447493 let dc = profile.dc + intensity / 3;
448494 let total = natural + modifier;
449495 let timing = format!("roll;desc=\"d20{modifier:+} vs {dc}\";dur={natural}");
@@ -527,6 +573,15 @@ impl Run {
527573 fn scaled_gains(&self, profile: &Profile, intensity: i32) -> (f64, f64) {
528574 let scale = f64::from(intensity);
529575 let (mut length, mut girth) = (profile.length * scale, profile.girth * scale);
576+ let greed = if self.eq > HOT_EQ {
577+ GREED_HOT
578+ } else if self.eq > DANGER_EQ {
579+ GREED_WARM
580+ } else {
581+ 1.0
582+ };
583+ length *= greed;
584+ girth *= greed;
530585 if self.here.teapot {
531586 length *= 0.5;
532587 girth *= 1.5;
Modified file: src/http/resource.rs+2 additions−3 deletions
@@ -113,9 +113,8 @@ pub fn unsupported(method: &Method, allow: &str) -> Response {
113113 "The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.",
114114 )
115115 .into_response(),
116- "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ" => {
117- method_not_allowed(format!("{method} is not supported here."), allow)
118- }
116+ "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"
117+ | "HOLD" => method_not_allowed(format!("{method} is not supported here."), allow),
119118 _ if name.eq_ignore_ascii_case("jelq") => Problem::new(
120119 StatusCode::NOT_IMPLEMENTED,
121120 "not-implemented",
Modified file: src/http/runs.rs+8 additions
@@ -59,6 +59,10 @@ pub static RUN: RunResource = RunResource {
5959 Verb::new("GET", get_run),
6060 Verb::new("JELQ", jelq)
6161 .only_when(not_in_clinic, "JELQ is not performed in the clinic."),
62+ Verb::new("HOLD", hold_run).only_when(
63+ Run::can_hold,
64+ "HOLD is only available above 70 EQ or at 2 stamina or less.",
65+ ),
6266 Verb::new("PUT", put_run),
6367 Verb::new("PATCH", patch_run),
6468 Verb::new("DELETE", delete_run),
@@ -177,6 +181,10 @@ fn put_run(run: &mut Run, _: &Ctx) -> Response {
177181 run.put().into_response()
178182 }
179183
184+fn hold_run(run: &mut Run, ctx: &Ctx) -> Response {
185+ respond(run.hold(ctx.now))
186+}
187+
180188 fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {
181189 let is_patch = ctx
182190 .header(header::CONTENT_TYPE)
Modified file: src/tests.rs+115 additions−1 deletions
@@ -1,4 +1,7 @@
1-use std::{sync::Arc, time::Duration};
1+use std::{
2+ sync::Arc,
3+ time::{Duration, Instant},
4+};
25
36 use axum::{
47 Router,
@@ -1018,3 +1021,114 @@ async fn meditation_stays_hidden_until_the_forum_teaches_it() {
10181021 let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
10191022 assert_eq!(jelq.status(), StatusCode::FORBIDDEN);
10201023 }
1024+
1025+fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {
1026+ let state = Arc::new(AppState::default());
1027+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1028+ run.floor = floor;
1029+ setup(&mut run);
1030+ state.insert(run);
1031+ router(state)
1032+}
1033+
1034+#[tokio::test]
1035+async fn hold_is_offered_only_at_the_edges() {
1036+ let calm = seeded_with(Floor::Bedroom, |_| {});
1037+ let refused = send(&calm, "HOLD", "/runs/cafebabe", &[], "").await;
1038+ assert_eq!(refused.status(), StatusCode::METHOD_NOT_ALLOWED);
1039+ assert!(!header(&refused, "allow").contains("HOLD"));
1040+ let view = send(&calm, "GET", "/runs/cafebabe", &[], "").await;
1041+ assert!(!header(&view, "link").contains("HOLD"));
1042+ let options = send(&calm, "OPTIONS", "/runs/cafebabe", &[], "").await;
1043+ assert!(!header(&options, "allow").contains("HOLD"));
1044+
1045+ let winded = seeded_with(Floor::Bedroom, |run| run.stamina = 2);
1046+ let listed = send(&winded, "GET", "/runs/cafebabe", &[], "").await;
1047+ assert!(header(&listed, "link").contains("HOLD"));
1048+ let options = send(&winded, "OPTIONS", "/runs/cafebabe", &[], "").await;
1049+ assert!(header(&options, "allow").contains("HOLD"));
1050+
1051+ let hot = seeded_with(Floor::Bedroom, |run| {
1052+ run.eq = 75;
1053+ run.warmed_up = true;
1054+ });
1055+ let held = send(&hot, "HOLD", "/runs/cafebabe", &[], "").await;
1056+ assert_eq!(held.status(), StatusCode::NO_CONTENT);
1057+ assert_eq!(header(&held, "x-stamina"), "10/10");
1058+}
1059+
1060+#[tokio::test]
1061+async fn holding_calms_and_sometimes_reflects() {
1062+ let calmed = (0..60u64)
1063+ .filter(|seed| {
1064+ let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
1065+ run.eq = 75;
1066+ run.hold(Instant::now()).is_ok() && run.eq == 60
1067+ })
1068+ .count();
1069+ let reflected = (0..60u64)
1070+ .filter(|seed| {
1071+ let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
1072+ run.eq = 75;
1073+ run.hold(Instant::now()).is_ok() && run.eq == 0
1074+ })
1075+ .count();
1076+ assert!(
1077+ calmed > 40 && reflected > 0 && calmed + reflected == 60,
1078+ "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 60"
1079+ );
1080+}
1081+
1082+#[tokio::test]
1083+async fn leaving_the_band_warms_the_next_floor() {
1084+ let app = seeded_with(Floor::Bedroom, |run| {
1085+ run.eq = 60;
1086+ run.warmed_up = true;
1087+ run.here.turns = 6;
1088+ });
1089+ let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;
1090+ assert_eq!(left.status(), StatusCode::SEE_OTHER);
1091+ let detail = json(left).await["detail"].as_str().unwrap().to_owned();
1092+ assert!(detail.contains("the warmth came with you"));
1093+
1094+ let lube = [("content-type", "application/baby-oil")];
1095+ send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1096+ // The shower is a +35 warm-up, so EQ lands in the band and the band bonus rides along.
1097+ // The arrival bonus is the one point the second jelq loses.
1098+ let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1099+ assert!(
1100+ header(&first, "server-timing").contains("d20+4"),
1101+ "the first jelq of a warm arrival rolls with +1, got {}",
1102+ header(&first, "server-timing")
1103+ );
1104+ let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1105+ assert!(header(&second, "server-timing").contains("d20+3"));
1106+}
1107+
1108+#[tokio::test]
1109+async fn greed_multiplies_gains_above_the_band() {
1110+ let lube = [("content-type", "application/baby-oil")];
1111+ for seed in 0..60u64 {
1112+ let app = app_with(Floor::Bedroom, seed, |run| {
1113+ run.eq = 60;
1114+ run.warmed_up = true;
1115+ });
1116+ let in_band = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1117+ let app = app_with(Floor::Bedroom, seed, |run| {
1118+ run.eq = 75;
1119+ run.warmed_up = true;
1120+ });
1121+ let greedy = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;
1122+ if in_band.status() != StatusCode::OK || greedy.status() != StatusCode::OK {
1123+ continue;
1124+ }
1125+ let steady = json(in_band).await["length"].as_f64().unwrap();
1126+ let eager = json(greedy).await["length"].as_f64().unwrap();
1127+ assert!(
1128+ (eager - steady * 1.5).abs() < 1e-9,
1129+ "seed {seed} gained {eager} above the band against {steady} inside it"
1130+ );
1131+ return;
1132+ }
1133+ panic!("no seed in 60 produced two clean rolls to compare");
1134+}
Modified file: src/ui.html+1 additions−1 deletions
@@ -56,7 +56,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5656
5757 <script>
5858 const REL = "https://jelq.dev/rels/";
59-const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
59+const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
6060 const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
6161 const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "run-ended"];
6262 const STATS = [