an http based roguelike rpg about jelqing
Commit 0a0bfee8329e
tarball14 files changed+445-13
@@ -65,6 +65,15 @@ pub const REFLECTION: u32 = 12;
6565
pub const ARRIVAL_BONUS: i32 = 1;6666
pub const GREED_WARM: f64 = 1.5;6767
pub const GREED_HOT: f64 = 2.0;68
+pub const COCONUT_ODDS: u32 = 3;69
+pub const ENERGY_ODDS: u32 = 5;70
+pub const KITCHEN_ICE_ODDS: u32 = 8;71
+pub const KITCHEN_TOLD: u32 = 4;72
+pub const KITCHEN_TELLINGS: u8 = 2;73
+pub const WATERMELON: (u32, u32) = (2, 5);74
+pub const CAUGHT_MELONED: u32 = 3;75
+pub const MELON_SHAME: u32 = 5;76
+pub const ENERGY_STAMINA: i32 = 3;6877
pub const OBSERVED: u32 = 6;6978
pub const CAT_TAKES_TOWEL: u32 = 5;7079
pub const FRIEND: u32 = 10;@@ -17,9 +17,14 @@ pub enum Item {
1717
CageKey,1818
Spreadsheet,1919
TeapotSpout,20
+ BabyOil,21
+ CoconutOil,22
+ Lotion,23
+ Vaseline,24
+ EnergyDrink,2025
}2126
22
-pub const SHOP: [Item; 11] = [27
+pub const SHOP: [Item; 15] = [2328
Item::Pump,2429
Item::PumpPro,2530
Item::Extender,@@ -31,10 +36,14 @@ pub const SHOP: [Item; 11] = [
3136
Item::Ruler,3237
Item::IcePack,3338
Item::CageKey,39
+ Item::BabyOil,40
+ Item::CoconutOil,41
+ Item::Lotion,42
+ Item::Vaseline,3443
];3544
3645
impl Item {37
- const ALL: [Self; 15] = [46
+ const ALL: [Self; 20] = [3847
Self::WarmTowel,3948
Self::IcePack,4049
Self::Ruler,@@ -50,6 +59,11 @@ impl Item {
5059
Self::CageKey,5160
Self::Spreadsheet,5261
Self::TeapotSpout,62
+ Self::BabyOil,63
+ Self::CoconutOil,64
+ Self::Lotion,65
+ Self::Vaseline,66
+ Self::EnergyDrink,5367
];5468
5569
pub fn slug(self) -> &'static str {@@ -69,6 +83,11 @@ impl Item {
6983
Self::CageKey => "cage-key",7084
Self::Spreadsheet => "spreadsheet",7185
Self::TeapotSpout => "teapot-spout",86
+ Self::BabyOil => "baby-oil",87
+ Self::CoconutOil => "coconut-oil",88
+ Self::Lotion => "lotion",89
+ Self::Vaseline => "vaseline",90
+ Self::EnergyDrink => "energy-drink",7291
}7392
}7493
@@ -97,6 +116,11 @@ impl Item {
97116
Self::CageKey => "Pump-Bro does not ask.",98117
Self::Spreadsheet => "Charts your progress. Every line is flat.",99118
Self::TeapotSpout => "Does nothing. Cannot be dropped.",119
+ Self::BabyOil => "Smooth. +1. One unit per jelq.",120
+ Self::CoconutOil => "+3. Solid until it matters. Suspicious at Nan's.",121
+ Self::Lotion => "Handy. +0, and it chafes, so each jelq costs an extra stamina.",122
+ Self::Vaseline => "+2. Raises suspicion everywhere.",123
+ Self::EnergyDrink => "technique=energy. +3 stamina. Tastes like a battery.",100124
}101125
}102126
@@ -122,8 +146,13 @@ impl Item {
122146
Self::Supplement | Self::DoorLock | Self::DndSign | Self::Blinds | Self::Ruler => {123147
Some(5)124148
}149
+ Self::BabyOil | Self::CoconutOil | Self::Lotion | Self::Vaseline => Some(4),125150
Self::IcePack => Some(3),126
- Self::WarmTowel | Self::Cage | Self::Spreadsheet | Self::TeapotSpout => None,151
+ Self::WarmTowel152
+ | Self::Cage153
+ | Self::Spreadsheet154
+ | Self::TeapotSpout155
+ | Self::EnergyDrink => None,127156
}128157
}129158
}@@ -141,6 +170,11 @@ pub struct Items {
141170
pub cage: bool,142171
pub spreadsheet: bool,143172
pub teapot_spout: bool,173
+ pub baby_oil: u32,174
+ pub coconut_oil: u32,175
+ pub lotion: u32,176
+ pub vaseline: u32,177
+ pub energy_drinks: u32,144178
}145179
146180
impl Default for Items {@@ -158,6 +192,11 @@ impl Default for Items {
158192
cage: false,159193
spreadsheet: false,160194
teapot_spout: false,195
+ baby_oil: 1,196
+ coconut_oil: 0,197
+ lotion: 1,198
+ vaseline: 0,199
+ energy_drinks: 0,161200
}162201
}163202
}@@ -178,6 +217,11 @@ impl Items {
178217
Item::Cage => self.cage,179218
Item::Spreadsheet => self.spreadsheet,180219
Item::TeapotSpout => self.teapot_spout,220
+ Item::BabyOil => self.baby_oil > 0,221
+ Item::CoconutOil => self.coconut_oil > 0,222
+ Item::Lotion => self.lotion > 0,223
+ Item::Vaseline => self.vaseline > 0,224
+ Item::EnergyDrink => self.energy_drinks > 0,181225
Item::Pump | Item::CageKey => false,182226
}183227
}@@ -204,6 +248,11 @@ impl Items {
204248
Item::CageKey => self.cage = false,205249
Item::Spreadsheet => self.spreadsheet = true,206250
Item::TeapotSpout => self.teapot_spout = true,251
+ Item::BabyOil => self.baby_oil += 1,252
+ Item::CoconutOil => self.coconut_oil += 1,253
+ Item::Lotion => self.lotion += 1,254
+ Item::Vaseline => self.vaseline += 1,255
+ Item::EnergyDrink => self.energy_drinks += 1,207256
Item::WarmTowel | Item::Pump => {}208257
}209258
}@@ -0,0 +1,127 @@
1
+use axum::http::{StatusCode, header};2
+use serde_json::json;3
+4
+use super::{5
+ Cause, Floor, Item, Outcome, Run, Story,6
+ balance::{7
+ CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,8
+ KITCHEN_TOLD, MELON_SHAME, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,9
+ },10
+};11
+use crate::problem::Problem;12
+use rand::Rng;13
+14
+impl Run {15
+ /// Raid the fridge. It costs the floor turn like anything else, and the household keeps count.16
+ pub fn raid_kitchen(&mut self) -> Result<Outcome, Problem> {17
+ if !self.floor.has_kitchen() {18
+ return Err(Problem::new(19
+ StatusCode::NOT_FOUND,20
+ "no-kitchen",21
+ "There is no kitchen on this floor.",22
+ ));23
+ }24
+ if self.kitchen_tellings >= KITCHEN_TELLINGS {25
+ return Err(Problem::new(26
+ StatusCode::GONE,27
+ "kitchen-off-limits",28
+ "The kitchen is off limits. You have been told twice.",29
+ ));30
+ }31
+ if self.story == Story::Pending {32
+ return Ok(self.branch_offer());33
+ }34
+ self.touch();35
+ self.turn += 1;36
+ self.here.turns += 1;37
+ self.realise();38
+ let was_below = self.suspicion < SUSPICION_LIMIT;39
+ let outcome = if self.eq > RUPTURE_LINE && self.rng.random_ratio(WATERMELON.0, WATERMELON.1)40
+ {41
+ self.here.melon_offered = true;42
+ self.melon_offer()43
+ } else if self.chance(COCONUT_ODDS) {44
+ self.items.grant(Item::CoconutOil);45
+ Ok(Outcome::json(46
+ StatusCode::OK,47
+ json!({ "detail": "A jar of coconut oil, half of it already gone." }),48
+ ))49
+ } else if self.chance(ENERGY_ODDS) {50
+ self.items.grant(Item::EnergyDrink);51
+ Ok(Outcome::json(52
+ StatusCode::OK,53
+ json!({ "detail": "An energy drink, cold and faintly alarming." }),54
+ ))55
+ } else if self.chance(KITCHEN_ICE_ODDS) {56
+ self.items.grant(Item::IcePack);57
+ Ok(Outcome::json(58
+ StatusCode::OK,59
+ json!({ "detail": "An ice pack, buried under the peas." }),60
+ ))61
+ } else {62
+ Ok(Outcome::empty(StatusCode::NO_CONTENT))63
+ };64
+ self.settle(was_below);65
+ let outcome = outcome?;66
+ if self.chance(KITCHEN_TOLD) {67
+ self.kitchen_tellings += 1;68
+ if self.kitchen_tellings >= KITCHEN_TELLINGS {69
+ return Err(Problem::new(70
+ StatusCode::GONE,71
+ "kitchen-off-limits",72
+ "The kitchen is off limits. You have been told twice.",73
+ ));74
+ }75
+ self.notices.push(if self.floor == Floor::Nans {76
+ "Stop raiding the fridge."77
+ } else {78
+ "Stop raiding the kitchen."79
+ });80
+ }81
+ Ok(outcome)82
+ }83
+84
+ fn melon_offer(&self) -> Result<Outcome, Problem> {85
+ let commit = self.path("/kitchen/watermelon");86
+ Ok(Outcome::json(87
+ StatusCode::MULTIPLE_CHOICES,88
+ json!({89
+ "detail": "The fridge holds a watermelon. It is exactly the right shape.",90
+ "choices": [91
+ { "href": commit, "method": "POST", "title": "Commit to the watermelon" },92
+ { "href": self.path(""), "method": "GET", "title": "Close the fridge" },93
+ ],94
+ }),95
+ )96
+ .header(97
+ header::LINK,98
+ format!(99
+ "<{commit}>; rel=\"alternate\"; title=\"Commit to the watermelon\"; allow=\"POST\""100
+ ),101
+ ))102
+ }103
+104
+ /// The watermelon. It empties EQ, keeps the gains, costs shame, and the household may notice.105
+ pub fn commit_melon(&mut self) -> Result<Outcome, Problem> {106
+ if !self.here.melon_offered {107
+ return Err(Problem::new(108
+ StatusCode::CONFLICT,109
+ "no-melon-on-offer",110
+ "There is no watermelon on offer.",111
+ ));112
+ }113
+ self.here.melon_offered = false;114
+ self.eq = 0;115
+ self.shame += MELON_SHAME;116
+ self.touch();117
+ if self.chance(CAUGHT_MELONED) {118
+ return Err(self.end(Cause::Meloned));119
+ }120
+ Ok(Outcome::json(121
+ StatusCode::OK,122
+ json!({123
+ "detail": "The watermelon did not survive, and neither did the mood. EQ is gone, the gains are kept, and the shame is considerable.",124
+ }),125
+ ))126
+ }127
+}@@ -6,6 +6,7 @@ mod forum;
66
mod hazards;77
mod household;88
mod items;9
+mod kitchen;910
mod outcome;1011
mod shop;1112
mod state;@@ -64,6 +65,7 @@ pub struct Run {
6465
pub put_applied: bool,6566
pub story: Story,6667
pub meditation_taught: bool,68
+ pub kitchen_tellings: u8,6769
pub mum: Option<MumVisit>,6870
pub heard: Vec<String>,6971
pub elder: Elder,@@ -150,6 +152,7 @@ impl Run {
150152
put_applied: false,151153
story: Story::Unopened,152154
meditation_taught: false,155
+ kitchen_tellings: 0,153156
mum: None,154157
heard: Vec::new(),155158
notices: Vec::new(),@@ -82,6 +82,10 @@ impl Floor {
8282
matches!(self, Self::ChangingRoom | Self::Hotel)8383
}8484
85
+ pub fn has_kitchen(self) -> bool {86
+ matches!(self, Self::Bedroom | Self::Nans)87
+ }88
+8589
pub fn timed(self) -> bool {8690
!matches!(self, Self::Forum | Self::Clinic)8791
}@@ -139,6 +143,7 @@ pub struct FloorState {
139143
pub brother_told: bool,140144
pub shopping: bool,141145
pub meditated: bool,146
+ pub melon_offered: bool,142147
pub warm_arrival: bool,143148
pub wifi_accepted: bool,144149
}@@ -187,6 +192,7 @@ pub enum Cause {
187192
Banned,188193
Degloved,189194
Zip,195
+ Meloned,190196
Abandoned,191197
}192198
@@ -199,6 +205,7 @@ impl Cause {
199205
Self::Banned => "banned",200206
Self::Degloved => "degloved",201207
Self::Zip => "zip",208
+ Self::Meloned => "watermelon",202209
Self::Abandoned => "abandoned",203210
}204211
}@@ -224,6 +231,9 @@ impl Cause {
224231
Self::Zip => {225232
"The request conflicts with the current state of the zip. The run has ended."226233
}234
+ Self::Meloned => {235
+ "You have been caught with the watermelon. Nobody will mention it at dinner. The run has ended."236
+ }227237
Self::Abandoned => "The run was abandoned.",228238
}229239
}@@ -232,6 +242,7 @@ impl Cause {
232242
match self {233243
Self::Ruptured { .. } => StatusCode::INTERNAL_SERVER_ERROR,234244
Self::Caught | Self::Opened | Self::Degloved | Self::Abandoned => StatusCode::GONE,245
+ Self::Meloned => StatusCode::GONE,235246
Self::Banned => StatusCode::FORBIDDEN,236247
Self::Zip => StatusCode::CONFLICT,237248
}@@ -78,6 +78,7 @@ pub enum Utility {
7878
Pump,7979
Supplement,8080
Meditate,81
+ Energy,8182
}8283
8384
#[derive(Clone, Copy, PartialEq, Eq, Debug)]@@ -170,6 +171,7 @@ impl Utility {
170171
Self::Pump => "pump",171172
Self::Supplement => "supplement",172173
Self::Meditate => "meditate",174
+ Self::Energy => "energy",173175
}174176
}175177
@@ -181,18 +183,20 @@ impl Utility {
181183
Self::Pump => "Large gains, all temporary. Requires a pump.",182184
Self::Supplement => "Consumes a supplement.",183185
Self::Meditate => "EQ -10. Taught by the forum. Once per floor, no stamina.",186
+ Self::Energy => "+3 stamina. Consumes an energy drink. Tastes like a battery.",184187
}185188
}186189
}187190
188191
impl Technique {189
- pub const ALL: [Self; 10] = [192
+ pub const ALL: [Self; 11] = [190193
Self::Utility(Utility::Warmup),191194
Self::Utility(Utility::Ice),192195
Self::Utility(Utility::Edge),193196
Self::Utility(Utility::Pump),194197
Self::Utility(Utility::Supplement),195198
Self::Utility(Utility::Meditate),199
+ Self::Utility(Utility::Energy),196200
Self::Training(Training::Wet),197201
Self::Training(Training::Dry),198202
Self::Training(Training::Stretch),@@ -75,6 +75,7 @@ impl Run {
7575
Utility::Ice => return self.ice(),7676
Utility::Supplement => return self.supplement(),7777
Utility::Meditate => return self.meditate(),78
+ Utility::Energy => return self.energy(),7879
Utility::Edge | Utility::Pump => {}7980
}8081
if utility == Utility::Pump {@@ -98,6 +99,7 @@ impl Run {
9899
) -> Result<Outcome, Problem> {99100
self.here.edges = 0;100101
self.ready(Technique::Training(training), lube)?;102
+ self.take_lube(lube)?;101103
self.check_storage()?;102104
self.floor_enemy()?;103105
self.commit(intensity, lube, now, 1);@@ -129,6 +131,7 @@ impl Run {
129131
self.here.edges = 0;130132
self.ready(Technique::Training(a), lube)?;131133
self.ready(Technique::Training(b), lube)?;134
+ self.take_lube(lube)?;132135
self.check_storage()?;133136
self.floor_enemy()?;134137
self.commit(intensity, lube, now, 2);@@ -250,6 +253,26 @@ impl Run {
250253
}251254
}252255
256
+ /// One unit of whatever was declared. Spit is free and the tap is always on.257
+ fn take_lube(&mut self, lube: Option<Lube>) -> Result<(), Problem> {258
+ let (held, name) = match lube {259
+ None | Some(Lube::Spit) => return Ok(()),260
+ Some(Lube::BabyOil) => (&mut self.items.baby_oil, "baby-oil"),261
+ Some(Lube::CoconutOil) => (&mut self.items.coconut_oil, "coconut-oil"),262
+ Some(Lube::Lotion) => (&mut self.items.lotion, "lotion"),263
+ Some(Lube::Vaseline) => (&mut self.items.vaseline, "vaseline"),264
+ };265
+ if *held == 0 {266
+ return Err(Problem::new(267
+ StatusCode::PRECONDITION_FAILED,268
+ "lubricant-exhausted",269
+ format!("{name} was declared, but the bottle is empty."),270
+ ));271
+ }272
+ *held -= 1;273
+ Ok(())274
+ }275
+253276
/// The techniques a JELQ could use right now without being refused before the roll.254277
pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {255278
let blocked = !self.floor.timed()@@ -267,6 +290,7 @@ impl Run {
267290
Technique::Utility(Utility::Meditate) => {268291
self.meditation_taught && !self.here.meditated269292
}293
+ Technique::Utility(Utility::Energy) => self.items.energy_drinks > 0,270294
_ => self.access(technique).is_ok() && self.check_storage().is_ok(),271295
})272296
.collect()@@ -362,7 +386,7 @@ impl Run {
362386
}363387
364388
/// The part of a turn the house takes part in, whatever the action was.365
- fn settle(&mut self, was_below: bool) {389
+ pub(super) fn settle(&mut self, was_below: bool) {366390
self.maybe_summon_mum(was_below);367391
if let Some(observer) = self.floor.observer()368392
&& self.chance(OBSERVED)@@ -9,8 +9,8 @@ use serde_json::json;
99
use super::{1010
Floor, Outcome, Run,1111
balance::{12
- EDGE_EQ, EDGE_GIRTH, EDGE_LOOP, HOTEL_STRESS, ICE_EQ, PRIAPISM_EQ, PRIAPISM_TURNS,13
- PUMP_GAIN, SHOWER_EQ, SUPPLEMENT_WORKS, TOWELLESS_EQ, WARMUP_EQ,12
+ EDGE_EQ, EDGE_GIRTH, EDGE_LOOP, ENERGY_STAMINA, HOTEL_STRESS, ICE_EQ, PRIAPISM_EQ,13
+ PRIAPISM_TURNS, PUMP_GAIN, SHOWER_EQ, SUPPLEMENT_WORKS, TOWELLESS_EQ, WARMUP_EQ,1414
},1515
};1616
use crate::problem::Problem;@@ -69,6 +69,23 @@ impl Run {
6969
))7070
}7171
72
+ pub(super) fn energy(&mut self) -> Result<Outcome, Problem> {73
+ if self.items.energy_drinks == 0 {74
+ return Err(Problem::new(75
+ StatusCode::FAILED_DEPENDENCY,76
+ "no-energy-drink",77
+ "The energy technique depends on an energy drink.",78
+ ));79
+ }80
+ self.items.energy_drinks -= 1;81
+ self.stamina = (self.stamina + ENERGY_STAMINA).min(self.max_stamina());82
+ self.touch();83
+ Ok(Outcome::json(84
+ StatusCode::OK,85
+ json!({ "detail": "The energy arrives before the regret. Stamina +3." }),86
+ ))87
+ }88
+7289
pub(super) fn supplement(&mut self) -> Result<Outcome, Problem> {7390
if self.items.supplements == 0 {7491
return Err(Problem::new(@@ -80,11 +97,12 @@ impl Run {
8097
self.items.supplements -= 1;8198
self.touch();8299
if self.chance(SUPPLEMENT_WORKS) {100
+ self.stamina = self.max_stamina();83101
self.conditions.priapism_turns = PRIAPISM_TURNS;84102
self.eq = self.eq.max(PRIAPISM_EQ);85103
return Ok(Outcome::json(86104
StatusCode::OK,87
- json!({ "detail": "The upstream returned a valid response. It is still returning it." }),105
+ json!({ "detail": "The upstream returned a valid response. It is still returning it. Stamina is fully restored." }),88106
));89107
}90108
Err(Problem::new(@@ -99,6 +99,8 @@ fn api(state: Shared) -> Router {
9999
.route("/runs/{id}/techniques", scoped(&runs::TECHNIQUES))100100
.route("/runs/{id}/drawer", scoped(&runs::DRAWER))101101
.route("/runs/{id}/branches/{choice}", scoped(&runs::BRANCH))102
+ .route("/runs/{id}/kitchen", scoped(&runs::KITCHEN))103
+ .route("/runs/{id}/kitchen/watermelon", scoped(&runs::MELON))102104
.route("/runs/{id}/floor", scoped(&runs::FLOOR))103105
.route("/runs/{id}/shop", scoped(&runs::SHOP))104106
.route("/runs/{id}/shop/{item}", scoped(&runs::BUY))@@ -13,7 +13,7 @@ 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, SUSPICION_LIMIT},16
+ balance::{BRANCH_TURN, KITCHEN_TELLINGS, SUSPICION_LIMIT},1717
},1818
headers::{1919
X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,@@ -97,7 +97,7 @@ 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); 13] = [100
+ let linked: [(&str, &RunResource, bool); 14] = [101101
("", &runs::RUN, true),102102
("floor", &runs::FLOOR, true),103103
("length", &runs::LENGTH, true),@@ -105,6 +105,9 @@ fn links(run: &Run, now: Instant) -> String {
105105
("inventory", &runs::INVENTORY, true),106106
("techniques", &runs::TECHNIQUES, true),107107
("drawer", &runs::DRAWER, run.drawer_worth_searching()),108
+ ("kitchen", &runs::KITCHEN, {109
+ run.floor.has_kitchen() && run.kitchen_tellings < KITCHEN_TELLINGS110
+ }),108111
("grip", &runs::GRIP, true),109112
("shop", &runs::SHOP, run.floor.shop_open()),110113
(@@ -82,6 +82,8 @@ pub static ITEM: RunResource =
8282
pub static TECHNIQUES: RunResource = home(&[Verb::new("GET", get_techniques)]);8383
pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);8484
pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);85
+pub static KITCHEN: RunResource = home(&[Verb::new("POST", raid_kitchen)]);86
+pub static MELON: RunResource = home(&[Verb::new("POST", commit_melon)]);8587
pub static FLOOR: RunResource =8688
home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);8789
pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);@@ -469,6 +471,14 @@ fn buy(run: &mut Run, ctx: &Ctx) -> Response {
469471
respond(run.buy(ctx.param("item")))470472
}471473
474
+fn raid_kitchen(run: &mut Run, _: &Ctx) -> Response {475
+ respond(run.raid_kitchen())476
+}477
+478
+fn commit_melon(run: &mut Run, _: &Ctx) -> Response {479
+ respond(run.commit_melon())480
+}481
+472482
fn get_portal(run: &mut Run, _: &Ctx) -> Response {473483
Json(json!({474484
"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.",@@ -12,6 +12,7 @@ pub enum Locale {
1212
1313
/// Ordered so that longer phrases are rewritten before the words inside them.1414
const EN_US: &[(&str, &str)] = &[15
+ ("Stop raiding the kitchen.", "Stop raiding the fridge."),1516
(1617
"Nan's house at Christmas",1718
"Grandma's house for Thanksgiving",@@ -955,6 +955,7 @@ async fn the_thread_announces_itself_at_the_branch() {
955955
let app = seeded_with(Floor::Bathroom, |run| {956956
run.turn = 8;957957
run.warmed_up = true;958
+ run.items.baby_oil = 9;958959
});959960
send(960961
&app,@@ -1085,6 +1086,7 @@ async fn leaving_the_band_warms_the_next_floor() {
10851086
run.eq = 60;10861087
run.warmed_up = true;10871088
run.here.turns = 6;1089
+ run.items.baby_oil = 9;10881090
});10891091
let left = send(&app, "POST", "/runs/cafebabe/floor", &[], "").await;10901092
assert_eq!(left.status(), StatusCode::SEE_OTHER);@@ -1132,3 +1134,161 @@ async fn greed_multiplies_gains_above_the_band() {
11321134
}11331135
panic!("no seed in 60 produced two clean rolls to compare");11341136
}1137
+1138
+#[tokio::test]1139
+async fn the_kitchen_is_only_on_the_home_floors() {1140
+ let absent = seeded_with(Floor::Bathroom, |_| {});1141
+ let no_kitchen = send(&absent, "POST", "/runs/cafebabe/kitchen", &[], "").await;1142
+ assert_eq!(no_kitchen.status(), StatusCode::NOT_FOUND);1143
+ let view = send(&absent, "GET", "/runs/cafebabe", &[], "").await;1144
+ assert!(!header(&view, "link").contains("/kitchen"));1145
+1146
+ let app = seeded_with(Floor::Bedroom, |_| {});1147
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1148
+ assert!(header(&view, "link").contains("/kitchen"));1149
+ let raid = send(&app, "POST", "/runs/cafebabe/kitchen", &[], "").await;1150
+ assert_ne!(raid.status(), StatusCode::NOT_FOUND);1151
+}1152
+1153
+#[tokio::test]1154
+async fn raids_cost_the_turn_and_the_fridge_pays_out_sometimes() {1155
+ let mut bounties = std::collections::BTreeSet::new();1156
+ let mut told = 0;1157
+ for seed in 0..80u64 {1158
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);1159
+ let Ok(outcome) = run.raid_kitchen() else {1160
+ panic!("the first raid should never fail");1161
+ };1162
+ assert_eq!(run.turn, 1, "a raid costs the floor turn");1163
+ assert!(!run.here.melon_offered, "no melon at starting EQ");1164
+ if outcome.status == StatusCode::NO_CONTENT {1165
+ bounties.insert("empty");1166
+ } else {1167
+ bounties.insert("loot");1168
+ }1169
+ told += u32::from(run.kitchen_tellings);1170
+ }1171
+ assert!(bounties.contains("loot") && bounties.contains("empty"));1172
+ assert!(1173
+ (0..80).any(|seed| {1174
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);1175
+ run.raid_kitchen().is_ok() && run.kitchen_tellings == 01176
+ }),1177
+ "most raids go unremarked"1178
+ );1179
+ assert!(told > 0, "someone should have been told across 80 raids");1180
+}1181
+1182
+#[tokio::test]1183
+async fn the_watermelon_offers_itself_above_ninety() {1184
+ let mut offered = 0;1185
+ let mut caught = 0;1186
+ let mut survived = 0;1187
+ for seed in 0..120u64 {1188
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);1189
+ run.eq = 95;1190
+ let Ok(outcome) = run.raid_kitchen() else {1191
+ continue;1192
+ };1193
+ if outcome.status != StatusCode::MULTIPLE_CHOICES {1194
+ continue;1195
+ }1196
+ offered += 1;1197
+ let gains = run.length;1198
+ let disgrace = run.shame;1199
+ if run.commit_melon().is_err() {1200
+ caught += 1;1201
+ assert_eq!(run.ended.as_ref().unwrap().cause.slug(), "watermelon");1202
+ } else {1203
+ survived += 1;1204
+ assert_eq!(run.eq, 0);1205
+ assert_eq!(run.shame, disgrace + 5);1206
+ assert_eq!(run.length, gains, "the gains are kept");1207
+ }1208
+ }1209
+ assert!(1210
+ offered > 20,1211
+ "the melon should appear in two fifths of raids"1212
+ );1213
+ assert!(caught > 0 && survived > 0, "caught is one in three");1214
+}1215
+1216
+#[tokio::test]1217
+async fn the_kitchen_closes_after_two_tellings() {1218
+ let mut closed = None;1219
+ for seed in 0..300u64 {1220
+ let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);1221
+ let first = run.raid_kitchen();1222
+ let second = run.raid_kitchen();1223
+ if second.is_err() && first.is_ok() {1224
+ assert_eq!(run.kitchen_tellings, 2);1225
+ let Err(third) = run.raid_kitchen() else {1226
+ panic!("the kitchen should be gone");1227
+ };1228
+ assert_eq!(third.status(), StatusCode::GONE);1229
+ closed = Some(seed);1230
+ break;1231
+ }1232
+ }1233
+ assert!(1234
+ closed.is_some(),1235
+ "two tellings should occur within 300 seeds"1236
+ );1237
+}1238
+1239
+#[tokio::test]1240
+async fn lubes_run_out_but_spit_never_does() {1241
+ let app = seeded_with(Floor::Bedroom, |run| {1242
+ run.warmed_up = true;1243
+ run.items.baby_oil = 1;1244
+ });1245
+ let lube = [("content-type", "application/baby-oil")];1246
+ let first = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1247
+ assert_ne!(first.status(), StatusCode::PRECONDITION_FAILED);1248
+ let second = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &lube, "").await;1249
+ assert_eq!(second.status(), StatusCode::PRECONDITION_FAILED);1250
+1251
+ let spit = [("content-type", "text/plain; charset=spit")];1252
+ for _ in 0..3 {1253
+ let wet = send(&app, "JELQ", "/runs/cafebabe?technique=wet", &spit, "").await;1254
+ assert_ne!(wet.status(), StatusCode::PRECONDITION_FAILED);1255
+ }1256
+}1257
+1258
+#[tokio::test]1259
+async fn energy_drinks_add_three_stamina() {1260
+ let app = seeded_with(Floor::Bedroom, |run| {1261
+ run.stamina = 2;1262
+ run.items.energy_drinks = 1;1263
+ });1264
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1265
+ assert!(header(&view, "link").contains("technique=energy"));1266
+ let drink = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;1267
+ assert_eq!(drink.status(), StatusCode::OK);1268
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1269
+ assert_eq!(header(&view, "x-stamina"), "5/10");1270
+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;1271
+ assert_eq!(again.status(), StatusCode::FAILED_DEPENDENCY);1272
+}1273
+1274
+#[tokio::test]1275
+async fn the_working_supplement_resets_stamina() {1276
+ let mut worked = 0;1277
+ let mut bunk = 0;1278
+ for seed in 0..60u64 {1279
+ let app = app_with(Floor::Bedroom, seed, |run| {1280
+ run.stamina = 1;1281
+ run.items.supplements = 1;1282
+ });1283
+ let taken = send(&app, "JELQ", "/runs/cafebabe?technique=supplement", &[], "").await;1284
+ if taken.status() == StatusCode::OK {1285
+ worked += 1;1286
+ let view = send(&app, "GET", "/runs/cafebabe", &[], "").await;1287
+ assert_eq!(header(&view, "x-stamina"), "10/10");1288
+ } else {1289
+ assert_eq!(taken.status(), StatusCode::BAD_GATEWAY);1290
+ bunk += 1;1291
+ }1292
+ }1293
+ assert!(worked > 0 && bunk > 0, "one in five should work too well");1294
+}@@ -57,8 +57,9 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5757
<script>5858
const REL = "https://jelq.dev/rels/";5959
const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];60
-const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];61
-const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "run-ended"];60
+const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];61
+const DEATHS = ["vascular-event", "caught", "opened", "banned", "degloved", "zip", "watermelon", "run-ended"];62
+let stocked = null;6263
const STATS = [6364
["x-floor", "Floor"],6465
["x-self-reported-length", "Self-reported length"],@@ -159,7 +160,10 @@ function options(name) {
159160
const partners = techniques.filter(t => t.lubricant && t.name !== first.name && !clashes(first, t));160161
return [""].concat(partners.map(t => t.name));161162
}162
- if (name === "lube") return lubeRule() === "required" ? LUBES.filter(Boolean) : LUBES;163
+ if (name === "lube") {164
+ const lubes = stocked === null ? ALL_LUBES : ALL_LUBES.filter(l => l === "" || l.includes("/") && !l.startsWith("application/") || stocked.includes(l.replace("application/", "")));165
+ return lubeRule() === "required" ? lubes.filter(Boolean) : lubes;166
+ }163167
return FIELDS[name].options || null;164168
}165169
@@ -389,6 +393,13 @@ function absorb(r, sentMethod, sentPath) {
389393
challenged = r.status === 401 || "via" in headers;390394
}391395
if (headers.link) [linked, techniques] = parseLinks(headers.link);396
+ if (run) {397
+ fetch("/ui/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method: "GET", path: run + "/inventory", headers: [], body: "" }) })398
+ .then(r => r.json())399
+ .then(r => {400
+ try { stocked = (JSON.parse(r.body).items || []).map(i => i.name); render(); } catch (e) {}401
+ });402
+ }392403
offered = body ? hrefs(body, []) : [];393404
if (headers.location && !started) {394405
offered.unshift({ key: "offered", group: "said", name: short(headers.location).split("/").pop() + " (Location)", path: headers.location, methods: ["GET"] });