an http based roguelike rpg about jelqing
Commit b85a0d4536c3
tarball11 files changed+190-103
@@ -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;@@ -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 {@@ -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_RUPTURE25
+ } else {26
+ 027
+ }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_BAND34
+ } else if self.eq <= HOT_EQ - self.burn() {35
+ RUPTURE_WARM36
+ } else {37
+ RUPTURE_HOT38
+ }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_RUPTURE33
- } else {34
- 035
- };36
- let odds = if self.eq > PREMATURE_EQ - burn {37
- None38
- } 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) {@@ -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);@@ -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(),@@ -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
}@@ -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.meditated310
- }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 is488478
/// 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_RUPTURE564
- } else {565
- 0566
- };551
+ let burn = self.burn();567552
let rupture_line = if self.items.ring {568553
RING_RUPTURE_LINE569554
} 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_RUPTURE681
- } else {682
- 0683
- };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 {@@ -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))@@ -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_TELLINGS110112
}),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_TURN139
- || 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()@@ -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::ALL433434
.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.",@@ -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_taught1001
- })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 == 601071
- })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 == 01078
- })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
+ gone1675
+ }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
+ gone1694
+ }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
+}