atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 3ac624339c98

tarball

atagen <boss@atagen.co>2026-10-13 12:25 UTC

18 files changed+214-44
game: put floors on a clock and let friction take skin

Nothing stopped a run from sitting on a floor indefinitely, so the floor enemies were optional. Floors now run out at 10 turns, warned on the two before, and the 10th either ends the run or moves you along unpaid and deflated. Friction capped at 10 with no further cost once a burn had landed, so every jelq at the limit now tears foreskin. The thread no longer warns on a miss, which gave away that reads were rolling.
Modified file: src/game/balance/floors.rs+4 additions
@@ -1,4 +1,8 @@
11 pub const FLOOR_TURNS: u32 = 6;
2+pub const FLOOR_LIMIT: u32 = 10;
3+pub const OVERSTAY_BUSTED: u32 = 2;
4+pub const OVERSTAY_EQ: i32 = 5;
5+pub const OVERSTAY_SHAME: u32 = 10;
26 pub const BRANCH_TURN: u32 = 10;
37 pub const ZIP: u32 = 200;
48 pub const RULER_IN_DRAWER: u32 = 2;
Modified file: src/game/balance/friction.rs+2 additions
@@ -2,6 +2,8 @@ pub const FRICTION_MAX: i32 = 10;
22 pub const FRICTION_DRY: i32 = 2;
33 pub const FRICTION_SPIT: i32 = 1;
44 pub const FRICTION_COLD: i32 = 2;
5+pub const FRICTION_TEAR: f64 = 0.1;
6+pub const RAW_DEGLOVING: u32 = 20;
57 pub const FRICTION_GRIP: i32 = 1;
68 pub const FRICTION_HOLD: i32 = 2;
79 pub const FRICTION_MEDITATE: i32 = 2;
Modified file: src/game/body.rs+2 additions
@@ -22,6 +22,7 @@ pub struct Body {
2222 pub(crate) conditions: Conditions,
2323 pub(crate) pending: Vec<Pending>,
2424 pub(crate) arrived: Option<Arrival>,
25+ pub(crate) torn: f64,
2526 }
2627
2728 impl Body {
@@ -40,6 +41,7 @@ impl Body {
4041 conditions: Conditions::default(),
4142 pending: Vec::new(),
4243 arrived: None,
44+ torn: 0.0,
4345 }
4446 }
4547 }
Modified file: src/game/cause.rs+13 additions−4 deletions
@@ -11,12 +11,13 @@ pub enum Cause {
1111 Degloved,
1212 Zip,
1313 Meloned,
14+ Overstayed,
1415 Abandoned,
1516 Lost,
1617 }
1718
1819 impl Cause {
19- pub const ALL: [Self; 10] = [
20+ pub const ALL: [Self; 11] = [
2021 Self::Ruptured { ice_unused: true },
2122 Self::Ruptured { ice_unused: false },
2223 Self::Caught,
@@ -25,6 +26,7 @@ impl Cause {
2526 Self::Degloved,
2627 Self::Zip,
2728 Self::Meloned,
29+ Self::Overstayed,
2830 Self::Abandoned,
2931 Self::Lost,
3032 ];
@@ -38,6 +40,7 @@ impl Cause {
3840 Self::Degloved => "degloved",
3941 Self::Zip => "zip",
4042 Self::Meloned => "watermelon",
43+ Self::Overstayed => "overstayed",
4144 Self::Abandoned => "abandoned",
4245 Self::Lost => "lost",
4346 }
@@ -67,6 +70,9 @@ impl Cause {
6770 Self::Meloned => {
6871 "You have been caught with the watermelon. Nobody will mention it at dinner. The run has ended."
6972 }
73+ Self::Overstayed => {
74+ "The floor ran long and somebody walked in. The run has ended. Nobody will mention it at dinner."
75+ }
7076 Self::Abandoned => "The run was abandoned.",
7177 Self::Lost => {
7278 "The server fell over mid-stroke and the run went with it. Nobody will mention it at dinner."
@@ -85,9 +91,12 @@ impl Cause {
8591 fn status(self) -> StatusCode {
8692 match self {
8793 Self::Ruptured { .. } | Self::Lost => StatusCode::INTERNAL_SERVER_ERROR,
88- Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned | Self::Meloned => {
89- StatusCode::GONE
90- }
94+ Self::Caught
95+ | Self::Opened
96+ | Self::Degloved
97+ | Self::Abandoned
98+ | Self::Meloned
99+ | Self::Overstayed => StatusCode::GONE,
91100 Self::Banned => StatusCode::FORBIDDEN,
92101 Self::Zip => StatusCode::CONFLICT,
93102 }
Modified file: src/game/dispatch.rs+2 additions−2 deletions
@@ -86,7 +86,7 @@ impl Run {
8686 self.take_lube(lube)?;
8787 let cold = self.cold();
8888 self.chafe(lube, intensity, cold);
89- self.commit(intensity, lube, 1);
89+ self.commit(intensity, lube, 1)?;
9090 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity, cold)? {
9191 return Ok(interrupted);
9292 }
@@ -120,7 +120,7 @@ impl Run {
120120 let cold = self.cold();
121121 self.chafe(lube, intensity, cold);
122122 self.chafe(lube, intensity, cold);
123- self.commit(intensity, lube, 2);
123+ self.commit(intensity, lube, 2)?;
124124 for training in [a, b] {
125125 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity, cold)? {
126126 return Ok(interrupted);
Modified file: src/game/drawer.rs+7 additions−7 deletions
@@ -22,7 +22,7 @@ impl Run {
2222 self.touch();
2323 self.end_kitchen_visit();
2424 if self.items.kit.cage {
25- return Ok(self.rummage_for_key());
25+ return self.rummage_for_key();
2626 }
2727 let first = !self.here.drawer.searched;
2828 self.here.drawer.searched = true;
@@ -126,14 +126,14 @@ impl Run {
126126
127127 /// Searching while caged is noisy, costs the floor turn, and the key only turns up some of
128128 /// the time. A jammed drawer takes a whole turn to work loose.
129- pub(super) fn rummage_for_key(&mut self) -> Outcome {
130- self.begin_turn();
129+ pub(super) fn rummage_for_key(&mut self) -> Result<Outcome, Problem> {
130+ self.begin_turn()?;
131131 self.add_suspicion(1);
132132 self.maybe_summon_mum();
133133 if self.here.drawer.jammed {
134134 self.here.drawer.jammed = false;
135135 self.notices.push(Notice::DrawerComesLoose);
136- return Outcome::empty(StatusCode::NO_CONTENT);
136+ return Ok(Outcome::empty(StatusCode::NO_CONTENT));
137137 }
138138 if !self.chance(
139139 self.decision("key", "Does the drawer finally give up the cage key?"),
@@ -146,13 +146,13 @@ impl Run {
146146 self.here.drawer.jammed = true;
147147 self.notices.push(Notice::DrawerJammed);
148148 }
149- return Outcome::empty(StatusCode::NO_CONTENT);
149+ return Ok(Outcome::empty(StatusCode::NO_CONTENT));
150150 }
151151 self.items.grant(Item::CageKey);
152- Outcome::json(
152+ Ok(Outcome::json(
153153 StatusCode::OK,
154154 json!({ "detail": "The key was in the drawer. The cage is off." }),
155- )
155+ ))
156156 }
157157
158158 pub fn drawer_worth_searching(&self) -> bool {
Modified file: src/game/forum.rs+5 additions−12 deletions
@@ -34,13 +34,6 @@ fn thread(detail: Option<&str>) -> Outcome {
3434 Outcome::json(StatusCode::OK, body).header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)
3535 }
3636
37-fn nothing_new() -> Outcome {
38- thread(None).header(
39- header::WARNING,
40- r#"199 - "Nothing new has been posted since 2009.""#,
41- )
42-}
43-
4437 pub fn forum_thread() -> Value {
4538 json!({
4639 "title": "The definitive jelqing FAQ (updated 2009)",
@@ -191,9 +184,6 @@ impl Run {
191184 /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,
192185 /// after which the forum is down and the browser serves what it kept.
193186 pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
194- if self.online.story == Story::Pending {
195- return Ok(self.branch_offer());
196- }
197187 if !self.thread_reachable() {
198188 return Err(Problem::new(
199189 StatusCode::NOT_FOUND,
@@ -201,6 +191,9 @@ impl Run {
201191 "There is no thread. Nobody has mentioned a forum yet.",
202192 ));
203193 }
194+ if self.online.story == Story::Pending {
195+ return Ok(thread(None));
196+ }
204197 self.end_kitchen_visit();
205198 if self.online.uli_taught {
206199 return Ok(thread(None));
@@ -217,7 +210,7 @@ impl Run {
217210 self.touch();
218211 let detail = if !self.online.meditation_taught {
219212 if !self.learn_meditation() {
220- return Ok(nothing_new());
213+ return Ok(thread(None));
221214 }
222215 "Between two arguments about pumps, a reply explains how to breathe. In through the nose, out through the mouth. You write it down."
223216 } else {
@@ -228,7 +221,7 @@ impl Run {
228221 ),
229222 ULI_ODDS,
230223 ) {
231- return Ok(nothing_new());
224+ return Ok(thread(None));
232225 }
233226 self.online.uli_taught = true;
234227 "Past the necroposting, a reply from TheElder lays out the technique. Uli. The full routine. You write it down before the tab can lose it."
Modified file: src/game/hazards.rs+39 additions−3 deletions
@@ -7,9 +7,9 @@ use super::{
77 Cause, Floor, Notice, Outcome, Run,
88 balance::{
99 BURN_RUPTURE, COLD_DEGLOVING, COLD_EQ, CURVE_MISSES, DANGER_EQ, DEGLOVING,
10- DEGLOVING_INTENSITY, EQ_BAND, FRIEND, HOT_EQ, LOST_IT_EQ, PREMATURE_EQ, RUPTURE_HOT,
11- RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM, SOFT_INTENSITY, SUSPICION_LIMIT,
12- WANDERER_VISIT, WINDOW,
10+ DEGLOVING_INTENSITY, EQ_BAND, FRICTION_MAX, FRICTION_TEAR, FRIEND, HOT_EQ, LOST_IT_EQ,
11+ PREMATURE_EQ, RAW_DEGLOVING, RUPTURE_HOT, RUPTURE_IN_BAND, RUPTURE_PREMATURE, RUPTURE_WARM,
12+ SOFT_INTENSITY, SUSPICION_LIMIT, WANDERER_VISIT, WINDOW,
1313 },
1414 measure::round3,
1515 techniques::Training,
@@ -62,6 +62,9 @@ impl Run {
6262 return Err(self.deglove());
6363 }
6464 }
65+ if self.body.friction >= FRICTION_MAX {
66+ self.tear()?;
67+ }
6568 if training == Training::Dry
6669 && intensity >= DEGLOVING_INTENSITY
6770 && self.chance(
@@ -74,6 +77,39 @@ impl Run {
7477 Ok(ControlFlow::Continue(()))
7578 }
7679
80+ fn tear(&mut self) -> Result<(), Problem> {
81+ if !self.notices.contains(&Notice::SkinGivingWay) {
82+ self.notices.push(Notice::SkinGivingWay);
83+ }
84+ match self.body.foreskin {
85+ Some(left) if left - FRICTION_TEAR > 1e-9 => {
86+ self.body.foreskin = Some(left - FRICTION_TEAR);
87+ self.body.torn += FRICTION_TEAR;
88+ Ok(())
89+ }
90+ Some(left) => {
91+ self.body.foreskin = None;
92+ self.standing.shame += 25;
93+ Err(Problem::new(
94+ StatusCode::GONE,
95+ "foreskin-lost",
96+ "The friction has removed the foreskin. It will not be returning.",
97+ )
98+ .with("foreskin", -round3(left))
99+ .header(header::CONTENT_LOCATION, self.path("/foreskin")))
100+ }
101+ None => {
102+ if self.chance(
103+ self.decision("raw-degloving", "Rubbed raw, does the skin tear?"),
104+ RAW_DEGLOVING,
105+ ) {
106+ return Err(self.deglove());
107+ }
108+ Ok(())
109+ }
110+ }
111+ }
112+
77113 fn deglove(&mut self) -> Problem {
78114 let lost = self.body.foreskin.take();
79115 let problem = self.end(Cause::Degloved);
Modified file: src/game/kitchen.rs+1 additions−1 deletions
@@ -26,7 +26,7 @@ impl Run {
2626 if self.online.story == Story::Pending {
2727 return Ok(self.branch_offer());
2828 }
29- self.begin_turn();
29+ self.begin_turn()?;
3030 if self.chance(
3131 self.decision("rupture", "Does this raid of the fridge rupture something?"),
3232 self.rupture_odds(),
Modified file: src/game/notice.rs+15 additions
@@ -1,3 +1,5 @@
1+use super::Floor;
2+
13 /// Something that happened alongside the turn, which rides on the response as a `Warning`.
24 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
35 pub enum Notice {
@@ -13,6 +15,9 @@ pub enum Notice {
1315 Reflection,
1416 MumSatisfied,
1517 ColdStart,
18+ RunningLong(Floor),
19+ LastTurn,
20+ SkinGivingWay,
1621 }
1722
1823 impl Notice {
@@ -30,6 +35,16 @@ impl Notice {
3035 Self::Reflection => "the count reached ten, and then the reflection started",
3136 Self::MumSatisfied => "Mum seems satisfied. She takes the washing with her.",
3237 Self::ColdStart => "Cold start. The server has noted this.",
38+ Self::RunningLong(floor) => match floor {
39+ Floor::Bedroom => "Mum is coming up the stairs.",
40+ Floor::Bathroom => "Your brother has gone to get Mum.",
41+ Floor::ChangingRoom => "The gym closes in ten minutes.",
42+ Floor::Nans => "Dinner is nearly ready. Somebody has been sent to find you.",
43+ Floor::Hotel => "Checkout was at eleven. Housekeeping has a master key.",
44+ Floor::Forum | Floor::Clinic => "The floor is running long.",
45+ },
46+ Self::LastTurn => "This is the last turn on this floor. Leave now or be found.",
47+ Self::SkinGivingWay => "the skin is giving way",
3348 }
3449 }
3550 }
Modified file: src/game/progress.rs+9 additions−2 deletions
@@ -8,6 +8,7 @@ use super::{
88 STROKE_EQ, STROKE_EQ_COST, SUSPICION_LIMIT, VIGOUR,
99 },
1010 };
11+use crate::problem::Problem;
1112
1213 impl Run {
1314 pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32, cold: bool) {
@@ -50,8 +51,13 @@ impl Run {
5051 }
5152 }
5253
53- pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {
54- self.begin_turn();
54+ pub(super) fn commit(
55+ &mut self,
56+ intensity: i32,
57+ lube: Option<Lube>,
58+ cost: i32,
59+ ) -> Result<(), Problem> {
60+ self.begin_turn()?;
5561 self.step_away();
5662 if self.items.kit.extender {
5763 self.body.length += EXTENDER_GAIN;
@@ -92,6 +98,7 @@ impl Run {
9298 self.body.stamina = self.body.stamina.min(self.max_stamina());
9399 }
94100 self.settle();
101+ Ok(())
95102 }
96103
97104 pub(super) fn make_noise(&mut self, noise: i32) {
Modified file: src/game/rest.rs+1 additions−1 deletions
@@ -25,7 +25,7 @@ impl Run {
2525 "The cage is locked. The key is not in the box.",
2626 ));
2727 }
28- self.begin_turn();
28+ self.begin_turn()?;
2929 if self.oracle.chance_not(
3030 self.decision("rupture", "The pause is uneventful"),
3131 RUPTURE_HOLD,
Modified file: src/game/run.rs+22 additions−5 deletions
@@ -10,7 +10,7 @@ use super::{
1010 Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,
1111 Online, RunStatus, Standing, Story,
1212 balance::{
13- BRANCH_TURN, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
13+ BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, SELF_REPORT_INFLATION,
1414 STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,
1515 },
1616 measure::round2,
@@ -148,12 +148,22 @@ impl Run {
148148 self.ident.version += 1;
149149 }
150150
151- pub(super) fn begin_turn(&mut self) {
151+ pub(super) fn begin_turn(&mut self) -> Result<(), Problem> {
152+ if self.floor.timed() && self.here.turns + 1 >= FLOOR_LIMIT {
153+ return Err(self.overstay());
154+ }
152155 self.touch();
153156 self.turn += 1;
154157 self.here.turns += 1;
158+ self.body.torn = 0.0;
155159 self.standing.at_turn_start = self.standing.suspicion;
156160 self.realise();
161+ if self.floor.timed() && self.here.turns + 2 == FLOOR_LIMIT {
162+ self.notices.push(Notice::RunningLong(self.floor));
163+ } else if self.floor.timed() && self.here.turns + 1 == FLOOR_LIMIT {
164+ self.notices.push(Notice::LastTurn);
165+ }
166+ Ok(())
157167 }
158168
159169 pub(super) fn shed(&mut self, friction: i32) {
@@ -365,12 +375,19 @@ impl Run {
365375 }
366376 }
367377
378+ /// The foreskin change it returns nets out whatever friction tore off this turn.
368379 pub(super) fn gain(&mut self, length: f64, girth: f64, foreskin: f64) -> Option<f64> {
369380 self.body.length += length;
370381 self.body.girth += girth;
371- let f = self.body.foreskin.as_mut().filter(|_| foreskin != 0.0)?;
372- *f += foreskin;
373- Some(foreskin)
382+ let torn = std::mem::take(&mut self.body.torn);
383+ let grown = match &mut self.body.foreskin {
384+ Some(f) => {
385+ *f += foreskin;
386+ foreskin
387+ }
388+ None => 0.0,
389+ };
390+ (grown != 0.0 || torn != 0.0).then_some(grown - torn)
374391 }
375392
376393 pub(super) fn rupture(&mut self) -> Problem {
Modified file: src/game/travel.rs+26 additions−1 deletions
@@ -2,7 +2,7 @@ use super::{
22 Cause, Elder, Floor, FloorState, Notice, Outcome, Run,
33 balance::{
44 EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, LOCKER_ROOM_BAR, LOCKER_ROOM_OTHERS,
5- LOCKER_ROOM_SHAME, STARTING_EQ, ZIP,
5+ LOCKER_ROOM_SHAME, OVERSTAY_BUSTED, OVERSTAY_EQ, OVERSTAY_SHAME, STARTING_EQ, ZIP,
66 },
77 measure::round2,
88 };
@@ -15,6 +15,7 @@ use serde_json::{Value, json};
1515 pub(super) enum Exit {
1616 Proper,
1717 Premature,
18+ Overstayed,
1819 }
1920
2021 impl Run {
@@ -42,6 +43,30 @@ impl Run {
4243 self.touch();
4344 }
4445
46+ /// A floor past its limit either ends the run or moves you along, shamed and deflated.
47+ pub(super) fn overstay(&mut self) -> Problem {
48+ self.touch();
49+ if self.chance(
50+ self.decision(
51+ "overstayed",
52+ "The floor has run long. Does somebody walk in?",
53+ ),
54+ OVERSTAY_BUSTED,
55+ ) {
56+ return self.end(Cause::Overstayed);
57+ }
58+ self.standing.shame += OVERSTAY_SHAME;
59+ self.next_floor(Exit::Overstayed);
60+ self.here.warm_arrival = false;
61+ self.reset_eq(OVERSTAY_EQ);
62+ Problem::new(
63+ StatusCode::REQUEST_TIMEOUT,
64+ "floor-timed-out",
65+ "The floor has timed out. You have been moved along.",
66+ )
67+ .header(header::CONNECTION, "close")
68+ }
69+
4570 pub fn advance(&mut self) -> Result<Outcome, Problem> {
4671 match self.floor {
4772 Floor::Clinic => {
Modified file: src/game/utility.rs+2 additions−2 deletions
@@ -147,7 +147,7 @@ impl Run {
147147
148148 pub(super) fn edge(&mut self) -> Result<Outcome, Problem> {
149149 self.chafe(None, 5, false);
150- self.commit(5, None, 1);
150+ self.commit(5, None, 1)?;
151151 if let ControlFlow::Break(interrupted) = self.hazards(5)? {
152152 return Ok(interrupted);
153153 }
@@ -170,7 +170,7 @@ impl Run {
170170 }
171171
172172 pub(super) fn pump(&mut self) -> Result<Outcome, Problem> {
173- self.commit(4, None, 1);
173+ self.commit(4, None, 1)?;
174174 if let ControlFlow::Break(interrupted) = self.hazards(4)? {
175175 return Ok(interrupted);
176176 }
Modified file: src/tests/floors.rs+1 additions−3 deletions
@@ -200,15 +200,13 @@ async fn reading_the_thread_teaches_uli() {
200200 for _ in 0..2 {
201201 let read = send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;
202202 assert_eq!(read.status(), StatusCode::OK, "seed {seed}");
203- let warned = warnings(&read).iter().any(|w| w.contains("2009"));
203+ assert!(warnings(&read).is_empty(), "a live read gives nothing away");
204204 let body = json(read).await;
205205 assert!(body["posts"].is_array(), "a read always shows the thread");
206206 if body["detail"].is_string() {
207- assert!(!warned);
208207 taught = true;
209208 break;
210209 }
211- assert!(warned);
212210 }
213211 let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
214212 assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
Modified file: src/tests/turns.rs+62 additions
@@ -280,3 +280,65 @@ async fn edging_climbs_out_of_the_cold() {
280280 "edging pays no cold tax"
281281 );
282282 }
283+
284+#[tokio::test]
285+async fn overstaying_a_floor_is_warned_then_punished() {
286+ let mut busted = 0;
287+ let mut moved = 0;
288+ for seed in 0..60u64 {
289+ let app = app_with(Floor::ChangingRoom, seed, |run| {
290+ run.body.eq = 80;
291+ run.body.conditions.priapism_turns = 9;
292+ run.here.turns = 7;
293+ });
294+ let eighth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
295+ assert!(
296+ warnings(&eighth)
297+ .iter()
298+ .any(|w| w.contains("The gym closes"))
299+ );
300+ let ninth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
301+ assert!(warnings(&ninth).iter().any(|w| w.contains("last turn")));
302+ let tenth = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
303+ match tenth.status() {
304+ StatusCode::GONE => busted += 1,
305+ StatusCode::REQUEST_TIMEOUT => {
306+ assert_eq!(header(&tenth, "x-floor"), "4");
307+ assert_eq!(
308+ header(&tenth, "x-eq"),
309+ "75",
310+ "priapism holds the deflation at 75"
311+ );
312+ moved += 1;
313+ }
314+ other => panic!("seed {seed} overstayed into {other}"),
315+ }
316+ }
317+ assert!(
318+ busted > 15 && moved > 15,
319+ "{busted} busted, {moved} moved along"
320+ );
321+}
322+
323+#[tokio::test]
324+async fn friction_at_the_limit_tears_the_foreskin() {
325+ let app = seeded_with(Floor::ChangingRoom, |run| {
326+ run.body.eq = 60;
327+ run.body.friction = 10;
328+ run.body.foreskin = Some(1.0);
329+ });
330+ let raw = send(&app, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
331+ assert!(warnings(&raw).iter().any(|w| w.contains("giving way")));
332+ let left: f64 = header(&raw, "x-foreskin").parse().unwrap();
333+ assert!(left < 1.0, "the foreskin is down to {left}");
334+
335+ let thin = seeded_with(Floor::ChangingRoom, |run| {
336+ run.body.eq = 60;
337+ run.body.friction = 10;
338+ run.body.foreskin = Some(0.05);
339+ });
340+ let gone = send(&thin, "JELQ", "/runs/cafebabe?technique=stretch", &[], "").await;
341+ assert_eq!(gone.status(), StatusCode::GONE);
342+ assert_eq!(header(&gone, "x-foreskin"), "gone");
343+ assert_eq!(json(gone).await["foreskin"], -0.05);
344+}
Modified file: src/ui.html+1 additions−1 deletions
@@ -52,7 +52,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5252 const REL = "https://jelq.dev/rels/";
5353 const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
5454 const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
55-const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "run-ended"];
55+const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];
5656 let stocked = null;
5757 let excuses = [];
5858 let nonce = null;