atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 0a0bfee8329e

tarball

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

14 files changed+445-13
game: the kitchen feeds the run until you are told twice

Raids cost the floor turn and pass through the household settle, so the fridge is never a free action. Lube units are spent only after the technique rules pass, so a refused jelq stays free. The watermelon is a 300 choice above the rupture line, and one supplement bottle in five works too well, resetting stamina into a priapism.
Modified file: src/game/balance.rs+9 additions
@@ -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;
Modified file: src/game/items.rs+52 additions−3 deletions
@@ -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::WarmTowel
152+ | Self::Cage
153+ | Self::Spreadsheet
154+ | Self::TeapotSpout
155+ | 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 }
Added file: src/game/kitchen.rs+127 additions
@@ -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+}
Modified file: src/game/mod.rs+3 additions
@@ -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(),
Modified file: src/game/state.rs+11 additions
@@ -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 }
Modified file: src/game/techniques.rs+5 additions−1 deletions
@@ -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),
Modified file: src/game/turn.rs+25 additions−1 deletions
@@ -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.meditated
269292 }
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)
Modified file: src/game/utility.rs+21 additions−3 deletions
@@ -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(
Modified file: src/http/mod.rs+2 additions
@@ -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))
Modified file: src/http/run.rs+5 additions−2 deletions
@@ -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_TELLINGS
110+ }),
108111 ("grip", &runs::GRIP, true),
109112 ("shop", &runs::SHOP, run.floor.shop_open()),
110113 (
Modified file: src/http/runs.rs+10 additions
@@ -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.",
Modified file: src/locale.rs+1 additions
@@ -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",
Modified file: src/tests.rs+160 additions
@@ -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 == 0
1176+ }),
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+}
Modified file: src/ui.html+14 additions−3 deletions
@@ -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"] });