atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit b85a0d4536c3

tarball

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

11 files changed+190-103
game: every turn rolls, and the thread teaches uli on the grind

The kitchen raid rolls the hazard table and HOLD rolls a flat 1 in 250 whatever the EQ, so a turn never passes without the vessels being consulted and the pacing tool stays 99.6% safe per use. Meditation needs no teaching now, which frees the turn-10 branch to be about shame rather than knowledge. uli is taught by POST /runs/{id}/re-read, a turn-costing grind that answers 304 Not Modified until TheElder's reply surfaces at 1 in 4, so a run that closed the tab is no longer locked out of it.
Modified file: src/game/balance.rs+2 additions−1 deletions
@@ -58,7 +58,8 @@ pub const PULLED_MUSCLE: u32 = 12;
5858 pub const PULLED_MUSCLE_INTENSITY: i32 = 9;
5959 pub const RING_PRIAPISM: u32 = 10;
6060 pub const SUPPLEMENT_WORKS: u32 = 5;
61-pub const MEDITATION_ODDS: u32 = 2;
61+pub const RUPTURE_HOLD: u32 = 250;
62+pub const ULI_ODDS: u32 = 4;
6263 pub const MEDITATION_EQ: i32 = 10;
6364 pub const HOLD_EQ: i32 = 15;
6465 pub const REFLECTION: u32 = 12;
Modified file: src/game/forum.rs+31 additions−8 deletions
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
33
44 use super::{
55 Cause, Elder, Floor, Outcome, Run, Story,
6- balance::{EXAGGERATION, MEDITATION_ODDS, PATIENCE},
6+ balance::{EXAGGERATION, PATIENCE, SUSPICION_LIMIT, ULI_ODDS},
77 body::exaggeration,
88 honest, parse_credentials,
99 };
@@ -81,13 +81,8 @@ impl Run {
8181 let (story, location, detail) = match choice {
8282 "forum" => {
8383 self.shame += 3;
84- let taught = self.chance(MEDITATION_ODDS);
85- self.meditation_taught |= taught;
86- let detail = if taught {
87- "The thread is from 2009. A technique called uli is discussed at length. Among the replies, a post teaches a way of breathing that lowers EQ."
88- } else {
89- "The thread is from 2009. A technique called uli is discussed at length."
90- };
84+ let detail =
85+ "The thread is from 2009. A technique called uli is discussed at length.";
9186 (Story::ReadForum, self.path("/techniques"), detail)
9287 }
9388 "close-tab" => {
@@ -114,6 +109,34 @@ impl Run {
114109 )
115110 }
116111
112+ /// Opening the thread again costs the turn, and each read may finally surface the reply.
113+ pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
114+ if self.story == Story::Pending {
115+ return Ok(self.branch_offer());
116+ }
117+ self.touch();
118+ self.turn += 1;
119+ self.here.turns += 1;
120+ self.realise();
121+ if self.chance(self.rupture_odds()) {
122+ return Err(self.rupture());
123+ }
124+ if self.chance(ULI_ODDS) {
125+ self.uli_taught = true;
126+ return Ok(Outcome::json(
127+ StatusCode::OK,
128+ json!({ "detail": "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." }),
129+ )
130+ .header(header::LOCATION, self.path("/techniques")));
131+ }
132+ let was_below = self.suspicion < SUSPICION_LIMIT;
133+ self.settle(was_below);
134+ Ok(Outcome::empty(StatusCode::NOT_MODIFIED).header(
135+ header::WARNING,
136+ r#"199 - "Nothing new has been posted since 2009.""#,
137+ ))
138+ }
139+
117140 fn elder_strike(&mut self, problem: Problem) -> Problem {
118141 self.touch();
119142 let Elder::Unverified { strikes } = &mut self.elder else {
Modified file: src/game/hazards.rs+22 additions−15 deletions
@@ -18,6 +18,26 @@ use crate::problem::Problem;
1818 type Interruption = Result<ControlFlow<Outcome>, Problem>;
1919
2020 impl Run {
21+ /// How far the friction burn drags the rupture tiers down.
22+ pub(super) fn burn(&self) -> i32 {
23+ if self.conditions.friction_burn {
24+ BURN_RUPTURE
25+ } else {
26+ 0
27+ }
28+ }
29+
30+ /// The rupture tier for any turn that is not a jelq, so never the premature band's 1 in 3.
31+ pub(super) fn rupture_odds(&self) -> u32 {
32+ if self.eq <= DANGER_EQ - self.burn() {
33+ RUPTURE_IN_BAND
34+ } else if self.eq <= HOT_EQ - self.burn() {
35+ RUPTURE_WARM
36+ } else {
37+ RUPTURE_HOT
38+ }
39+ }
40+
2141 /// Accidents tied to a specific technique.
2242 pub(super) fn mishaps(&mut self, training: Training, intensity: i32) -> Interruption {
2343 if training == Training::Dry && intensity >= DEGLOVING_INTENSITY && self.chance(DEGLOVING) {
@@ -28,21 +48,8 @@ impl Run {
2848
2949 /// Everything that can go wrong on a committed turn before the roll.
3050 pub(super) fn hazards(&mut self, intensity: i32) -> Interruption {
31- let burn = if self.conditions.friction_burn {
32- BURN_RUPTURE
33- } else {
34- 0
35- };
36- let odds = if self.eq > PREMATURE_EQ - burn {
37- None
38- } else if self.eq <= DANGER_EQ - burn {
39- Some(RUPTURE_IN_BAND)
40- } else if self.eq <= HOT_EQ - burn {
41- Some(RUPTURE_WARM)
42- } else {
43- Some(RUPTURE_HOT)
44- };
45- if odds.is_some_and(|odds| self.chance(odds)) {
51+ let burn = self.burn();
52+ if self.eq <= PREMATURE_EQ - burn && self.chance(self.rupture_odds()) {
4653 return Err(self.rupture());
4754 }
4855 if self.floor.has_window() && !self.items.blinds && self.chance(WINDOW) {
Modified file: src/game/kitchen.rs+3 additions
@@ -35,6 +35,9 @@ impl Run {
3535 self.turn += 1;
3636 self.here.turns += 1;
3737 self.realise();
38+ if self.chance(self.rupture_odds()) {
39+ return Err(self.rupture());
40+ }
3841 let was_below = self.suspicion < SUSPICION_LIMIT;
3942 // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5 and 1 in 8.
4043 let draw = self.rng.random_range(0..120);
Modified file: src/game/mod.rs+2 additions−2 deletions
@@ -66,7 +66,7 @@ pub struct Run {
6666 pub warmed_up: bool,
6767 pub put_applied: bool,
6868 pub story: Story,
69- pub meditation_taught: bool,
69+ pub uli_taught: bool,
7070 pub kitchen_tellings: u8,
7171 pub mum: Option<MumVisit>,
7272 pub heard: Vec<String>,
@@ -155,7 +155,7 @@ impl Run {
155155 warmed_up: false,
156156 put_applied: false,
157157 story: Story::Unopened,
158- meditation_taught: false,
158+ uli_taught: false,
159159 kitchen_tellings: 0,
160160 mum: None,
161161 heard: Vec::new(),
Modified file: src/game/techniques.rs+2 additions−2 deletions
@@ -221,7 +221,7 @@ impl Technique {
221221 }
222222 }
223223
224- pub fn describe(self, forum_read: bool) -> Value {
224+ pub fn describe(self, uli_taught: bool) -> Value {
225225 let training = match self {
226226 Self::Utility(utility) => {
227227 return json!({ "name": utility.name(), "effect": utility.effect() });
@@ -235,7 +235,7 @@ impl Technique {
235235 "dc": profile.dc,
236236 "lubricant": lubricant,
237237 "foreskin": profile.foreskin.map(|(min, max)| json!({ "min": min, "max": max })),
238- "locked": training == Training::Uli && !forum_read,
238+ "locked": training == Training::Uli && !uli_taught,
239239 })
240240 }
241241 }
Modified file: src/game/turn.rs+14 additions−34 deletions
@@ -7,12 +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, 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,
10+ ARRIVAL_BONUS, BAND_BONUS, BRANCH_TURN, CAT_TAKES_TOWEL, CHANGING_ROOM_CHILL, DANGER_EQ,
11+ EQ_BAND, EQ_WOBBLE, EXTENDER_GAIN, FRICTION_BURN, FRICTION_BURN_AT, FRICTION_DRY,
12+ FRICTION_GRIP, FRICTION_HOLD, FRICTION_MAX, FRICTION_MEDITATE, FRICTION_SPIT, GREED_HOT,
13+ GREED_WARM, HOLD_EQ, HOT_EQ, MEDITATION_EQ, OBSERVED, PRIAPISM_EQ, PRIAPISM_TURNS,
14+ PULLED_MUSCLE, PULLED_MUSCLE_INTENSITY, REFLECTION, RING_PRIAPISM, RING_RUPTURE_LINE,
15+ RUPTURE_HOLD, RUPTURE_LINE, STORAGE_GIRTH, SUSPICION_LIMIT,
1616 },
1717 not_extended, round3,
1818 state::{MumVisit, Observer, Pending},
@@ -196,24 +196,13 @@ impl Run {
196196
197197 /// Whether the run could attempt the technique now, ignoring the lubricant.
198198 fn access(&self, technique: Technique) -> Result<(), Problem> {
199- if technique == Technique::Training(Training::Uli) && !self.forum_read() {
199+ if technique == Technique::Training(Training::Uli) && !self.uli_taught {
200200 return Err(Problem::new(
201201 StatusCode::FORBIDDEN,
202202 "technique-locked",
203- "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not been to the forum.",
203+ "uli is discussed on the forum, in a thread from 2009, by a man who never posted again. You have not found the reply.",
204204 ));
205205 }
206- if technique == Technique::Utility(Utility::Meditate) {
207- return if self.meditation_taught {
208- Ok(())
209- } else {
210- Err(Problem::new(
211- StatusCode::FORBIDDEN,
212- "technique-locked",
213- "The breathing technique is described in one of the forum's replies. You have not found it.",
214- ))
215- };
216- }
217206 if !self.warmed_up {
218207 return Err(Problem::new(
219208 StatusCode::PRECONDITION_FAILED,
@@ -305,9 +294,7 @@ impl Run {
305294 Technique::Utility(Utility::Warmup) => true,
306295 Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
307296 Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
308- Technique::Utility(Utility::Meditate) => {
309- self.meditation_taught && !self.here.meditated
310- }
297+ Technique::Utility(Utility::Meditate) => !self.here.meditated,
311298 Technique::Utility(Utility::Energy) => self.items.energy_drinks > 0,
312299 _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
313300 })
@@ -465,6 +452,9 @@ impl Run {
465452 "The cage is locked. The key is not in the box.",
466453 ));
467454 }
455+ if self.chance(RUPTURE_HOLD) {
456+ return Err(self.rupture());
457+ }
468458 self.touch();
469459 self.turn += 1;
470460 self.here.turns += 1;
@@ -487,7 +477,6 @@ impl Run {
487477 /// The breathing technique from the forum's replies. It spends no stamina and the turn is
488478 /// not the floor's, but each floor allows it once.
489479 fn meditate(&mut self) -> Result<Outcome, Problem> {
490- self.access(Technique::Utility(Utility::Meditate))?;
491480 if self.here.meditated {
492481 return Err(Problem::new(
493482 StatusCode::CONFLICT,
@@ -559,11 +548,7 @@ impl Run {
559548 let timed = |p: Problem| p.header(SERVER_TIMING, &timing);
560549
561550 let (length, girth) = self.scaled_gains(&profile, intensity);
562- let burn = if self.conditions.friction_burn {
563- BURN_RUPTURE
564- } else {
565- 0
566- };
551+ let burn = self.burn();
567552 let rupture_line = if self.items.ring {
568553 RING_RUPTURE_LINE
569554 } else {
@@ -676,12 +661,7 @@ impl Run {
676661 )
677662 .header(header::CONTENT_LOCATION, self.path("/foreskin"));
678663 }
679- let burn = if self.conditions.friction_burn {
680- BURN_RUPTURE
681- } else {
682- 0
683- };
684- if self.eq > DANGER_EQ - burn {
664+ if self.eq > DANGER_EQ - self.burn() {
685665 return self.rupture();
686666 }
687667 if training == Training::Stretch && !self.here.teapot {
Modified file: src/http/mod.rs+1 additions
@@ -101,6 +101,7 @@ fn api(state: Shared) -> Router {
101101 .route("/runs/{id}/branches/{choice}", scoped(&runs::BRANCH))
102102 .route("/runs/{id}/kitchen", scoped(&runs::KITCHEN))
103103 .route("/runs/{id}/kitchen/watermelon", scoped(&runs::MELON))
104+ .route("/runs/{id}/re-read", scoped(&runs::REREAD))
104105 .route("/runs/{id}/floor", scoped(&runs::FLOOR))
105106 .route("/runs/{id}/shop", scoped(&runs::SHOP))
106107 .route("/runs/{id}/shop/{item}", scoped(&runs::BUY))
Modified file: src/http/run.rs+5 additions−5 deletions
@@ -97,7 +97,9 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9797 /// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
9898 /// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
9999 fn links(run: &Run, now: Instant) -> String {
100- let linked: [(&str, &RunResource, bool); 14] = [
100+ let thread_reachable =
101+ run.turn >= BRANCH_TURN || run.forum_read() || run.floor.number() >= Floor::Forum.number();
102+ let linked: [(&str, &RunResource, bool); 15] = [
101103 ("", &runs::RUN, true),
102104 ("floor", &runs::FLOOR, true),
103105 ("length", &runs::LENGTH, true),
@@ -108,6 +110,7 @@ fn links(run: &Run, now: Instant) -> String {
108110 ("kitchen", &runs::KITCHEN, {
109111 run.floor.has_kitchen() && run.kitchen_tellings < KITCHEN_TELLINGS
110112 }),
113+ ("re-read", &runs::REREAD, thread_reachable),
111114 ("grip", &runs::GRIP, true),
112115 ("shop", &runs::SHOP, run.floor.shop_open()),
113116 (
@@ -135,10 +138,7 @@ fn links(run: &Run, now: Instant) -> String {
135138 let allow = target.resource.allow(run);
136139 format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
137140 })
138- .chain((run.turn >= BRANCH_TURN
139- || run.forum_read()
140- || run.floor.number() >= Floor::Forum.number())
141- .then(|| {
141+ .chain(thread_reachable.then(|| {
142142 format!(
143143 "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
144144 meta::THREAD.allow_any()
Modified file: src/http/runs.rs+6 additions−1 deletions
@@ -84,6 +84,7 @@ pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);
8484 pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);
8585 pub static KITCHEN: RunResource = home(&[Verb::new("POST", raid_kitchen)]);
8686 pub static MELON: RunResource = home(&[Verb::new("POST", commit_melon)]);
87+pub static REREAD: RunResource = pockets(&[Verb::new("POST", re_read)]);
8788 pub static FLOOR: RunResource =
8889 home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);
8990 pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);
@@ -431,7 +432,7 @@ fn delete_item(run: &mut Run, ctx: &Ctx) -> Response {
431432 fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
432433 let listed: Vec<Value> = Technique::ALL
433434 .into_iter()
434- .map(|t| t.describe(run.forum_read()))
435+ .map(|t| t.describe(run.uli_taught))
435436 .collect();
436437 Json(json!({
437438 "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
@@ -479,6 +480,10 @@ fn commit_melon(run: &mut Run, _: &Ctx) -> Response {
479480 respond(run.commit_melon())
480481 }
481482
483+fn re_read(run: &mut Run, _: &Ctx) -> Response {
484+ respond(run.read_thread())
485+}
486+
482487 fn get_portal(run: &mut Run, _: &Ctx) -> Response {
483488 Json(json!({
484489 "terms": "By connecting you agree that the hotel is not responsible for anything that happens in this room, and that it does not want to know.",
Modified file: src/tests.rs+102 additions−35 deletions
@@ -16,7 +16,7 @@ use crate::{
1616 AppState, Config,
1717 game::{
1818 Floor, MumVisit, Run, Story,
19- balance::{BRANCH_TURN, STORAGE_GIRTH, SUSPICION_LIMIT},
19+ balance::{STORAGE_GIRTH, SUSPICION_LIMIT},
2020 },
2121 router,
2222 };
@@ -779,7 +779,7 @@ fn advertised(response: &Response) -> Vec<String> {
779779 async fn links_offer_only_usable_techniques() {
780780 let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
781781 let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
782- assert_eq!(advertised(&cold), ["warmup", "ice"]);
782+ assert_eq!(advertised(&cold), ["warmup", "ice", "meditate"]);
783783 let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
784784 let usable = advertised(&warm);
785785 assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
@@ -991,24 +991,49 @@ async fn the_thread_announces_itself_at_the_branch() {
991991 }
992992
993993 #[tokio::test]
994-async fn reading_the_forum_may_teach_meditation() {
995- let taught = (0..50u64)
996- .filter(|seed| {
997- let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
998- run.story = Story::Pending;
999- run.turn = BRANCH_TURN;
1000- run.choose("forum").is_ok() && run.meditation_taught
1001- })
1002- .count();
994+async fn rereading_the_thread_teaches_uli() {
995+ let mut taught_runs = 0;
996+ let mut ruptured = 0;
997+ for seed in 0..100u64 {
998+ let app = app_with(Floor::Bedroom, seed, |run| {
999+ run.warmed_up = true;
1000+ run.story = Story::ClosedTab;
1001+ });
1002+ let mut taught = false;
1003+ for _ in 0..40 {
1004+ let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1005+ match read.status() {
1006+ StatusCode::NOT_MODIFIED => {
1007+ assert!(header(&read, "warning").contains("2009"));
1008+ }
1009+ StatusCode::MULTIPLE_CHOICES => {
1010+ send(&app, "POST", "/runs/cafebabe/branches/close-tab", &[], "").await;
1011+ }
1012+ StatusCode::OK => {
1013+ assert!(header(&read, "location").contains("/techniques"));
1014+ taught = true;
1015+ break;
1016+ }
1017+ StatusCode::INTERNAL_SERVER_ERROR => break,
1018+ other => panic!("seed {seed} read the thread into {other}"),
1019+ }
1020+ }
1021+ if taught {
1022+ taught_runs += 1;
1023+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
1024+ assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
1025+ }
1026+ ruptured += u32::from(!taught);
1027+ }
10031028 assert!(
1004- taught > 0 && taught < 50,
1005- "the teach roll should be a coin, it landed {taught}/50"
1029+ taught_runs >= 85,
1030+ "{taught_runs}/100 learned uli, {ruptured} ruptured first"
10061031 );
10071032 }
10081033
10091034 #[tokio::test]
10101035 async fn meditation_is_free_but_only_once_per_floor() {
1011- let app = seeded_with(Floor::Bedroom, |run| run.meditation_taught = true);
1036+ let app = seeded_with(Floor::Bedroom, |_| {});
10121037 let response = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
10131038 assert_eq!(response.status(), StatusCode::OK);
10141039 assert_eq!(header(&response, "x-eq"), "10");
@@ -1018,12 +1043,12 @@ async fn meditation_is_free_but_only_once_per_floor() {
10181043 }
10191044
10201045 #[tokio::test]
1021-async fn meditation_stays_hidden_until_the_forum_teaches_it() {
1046+async fn meditation_is_listed_from_the_start() {
10221047 let app = seeded_with(Floor::Bedroom, |_| {});
10231048 let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1024- assert!(!header(&view, "link").contains("technique=meditate"));
1049+ assert!(header(&view, "link").contains("technique=meditate"));
10251050 let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
1026- assert_eq!(jelq.status(), StatusCode::FORBIDDEN);
1051+ assert_eq!(jelq.status(), StatusCode::OK);
10271052 }
10281053
10291054 fn app_with(floor: Floor, seed: u64, setup: impl FnOnce(&mut Run)) -> Router {
@@ -1063,23 +1088,25 @@ async fn hold_is_offered_only_at_the_edges() {
10631088
10641089 #[tokio::test]
10651090 async fn holding_calms_and_sometimes_reflects() {
1066- let calmed = (0..60u64)
1067- .filter(|seed| {
1068- let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
1069- run.eq = 75;
1070- run.hold(Instant::now()).is_ok() && run.eq == 60
1071- })
1072- .count();
1073- let reflected = (0..60u64)
1074- .filter(|seed| {
1075- let mut run = Run::new("cafebabe".into(), "cafe".into(), *seed);
1076- run.eq = 75;
1077- run.hold(Instant::now()).is_ok() && run.eq == 0
1078- })
1079- .count();
1091+ let mut calmed = 0;
1092+ let mut reflected = 0;
1093+ let mut ruptured = 0;
1094+ for seed in 0..120u64 {
1095+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
1096+ run.eq = 75;
1097+ match run.hold(Instant::now()) {
1098+ Ok(_) if run.eq == 0 => reflected += 1,
1099+ Ok(_) => calmed += 1,
1100+ Err(_) => ruptured += 1,
1101+ }
1102+ }
10801103 assert!(
1081- calmed > 40 && reflected > 0 && calmed + reflected == 60,
1082- "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 60"
1104+ calmed > 100 && reflected > 0,
1105+ "one HOLD in twelve should reflect, got {calmed} calm and {reflected} reflective of 120"
1106+ );
1107+ assert!(
1108+ calmed + reflected + ruptured == 120 && ruptured <= 4,
1109+ "the flat 1 in 250 ruptured {ruptured} of 120 holds"
10831110 );
10841111 }
10851112
@@ -1358,7 +1385,6 @@ async fn holding_and_meditating_shed_friction() {
13581385 assert_eq!(header(&view, "x-friction"), "3/10");
13591386
13601387 let taught = seeded_with(Floor::Bedroom, |run| {
1361- run.meditation_taught = true;
13621388 run.friction = 5;
13631389 });
13641390 let calmed = send(
@@ -1479,7 +1505,6 @@ async fn the_burn_shifts_the_rupture_tiers() {
14791505 #[tokio::test]
14801506 async fn meditation_respects_the_priapism_floor() {
14811507 let app = seeded_with(Floor::Bedroom, |run| {
1482- run.meditation_taught = true;
14831508 run.eq = 80;
14841509 run.conditions.priapism_turns = 3;
14851510 });
@@ -1630,3 +1655,45 @@ async fn priapism_holds_the_floor_through_every_reset() {
16301655 let view = send(&melon, "GET", "/runs/cafebabe", &[], "").await;
16311656 assert_eq!(header(&view, "x-eq"), "75");
16321657 }
1658+
1659+#[tokio::test]
1660+async fn holding_rolls_a_flat_rupture_chance() {
1661+ async fn ruptures(eq: i32, stamina: i32) -> u32 {
1662+ let mut gone = 0;
1663+ for seed in 0..600u64 {
1664+ let app = app_with(Floor::Bedroom, seed, |run| {
1665+ run.warmed_up = true;
1666+ run.eq = eq;
1667+ run.stamina = stamina;
1668+ });
1669+ let held = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1670+ if held.status() == StatusCode::INTERNAL_SERVER_ERROR {
1671+ gone += 1;
1672+ }
1673+ }
1674+ gone
1675+ }
1676+ let high = ruptures(90, 10).await;
1677+ let low = ruptures(30, 2).await;
1678+ assert!((0..=9).contains(&high), "high holds ruptured {high}/600");
1679+ assert!((0..=9).contains(&low), "low holds ruptured {low}/600");
1680+}
1681+
1682+#[tokio::test]
1683+async fn the_kitchen_rolls_the_normal_tiers() {
1684+ async fn ruptures(eq: i32) -> u32 {
1685+ let mut gone = 0;
1686+ for seed in 0..600u64 {
1687+ let app = app_with(Floor::Bedroom, seed, |run| run.eq = eq);
1688+ let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;
1689+ if raid.status() == StatusCode::INTERNAL_SERVER_ERROR {
1690+ gone += 1;
1691+ }
1692+ }
1693+ gone
1694+ }
1695+ let hot = ruptures(96).await;
1696+ let cool = ruptures(40).await;
1697+ assert!((40..=80).contains(&hot), "hot kitchen ruptured {hot}/600");
1698+ assert!((0..=9).contains(&cool), "cool kitchen ruptured {cool}/600");
1699+}