atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit a6caa2f99786

tarball

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

14 files changed+239-135
game: make every action pay for itself in its own currency

The wall-clock refractory period never bit, since any later request
lifted it and the bots ran it at zero, so exhaustion is now cleared by the
next HOLD at the cost of a turn and the warmth. Re-reading the thread no
longer ticks the floor clock, which made it the cheapest way off any
floor, and is capped at two live rolls per floor before the cached copy.
The towel warms once per cold spell unless the kettle has boiled, edging
chafes like a dry stroke and loops on the third edge of a floor, and
friction now climbs with intensity so the slicker lubes earn their price.

Caged rummaging costs the turn instead, so a caged floor cannot stall,
and the hotel and bathroom drawers give the lotion a home.
Modified file: src/config.rs−5 deletions
@@ -5,7 +5,6 @@ use crate::secret::Secret;
55
66 pub struct Config {
77 pub addr: String,
8- pub refractory: Duration,
98 pub secret: Secret,
109 pub max_runs: usize,
1110 pub idle_ttl: Duration,
@@ -16,7 +15,6 @@ impl Default for Config {
1615 fn default() -> Self {
1716 Self {
1817 addr: "127.0.0.1:6969".into(),
19- refractory: Duration::from_secs(30),
2018 secret: Secret::random(),
2119 max_runs: 10_000,
2220 idle_ttl: Duration::from_secs(6 * 60 * 60),
@@ -31,9 +29,6 @@ impl Config {
3129 let defaults = Self::default();
3230 Self {
3331 addr: var("JELQD_ADDR").unwrap_or(defaults.addr),
34- refractory: var("JELQD_REFRACTORY_SECS")
35- .and_then(|s| s.parse().ok())
36- .map_or(defaults.refractory, Duration::from_secs),
3732 secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),
3833 oracle: match var("JELQD_ORACLE").as_deref() {
3934 Some("clm") => OracleTemplate::Clm {
Modified file: src/game/balance.rs+3 additions
@@ -70,6 +70,9 @@ pub const GREED_HOT: f64 = 2.0;
7070 pub const COCONUT_ODDS: u32 = 3;
7171 pub const ENERGY_ODDS: u32 = 5;
7272 pub const KITCHEN_ICE_ODDS: u32 = 8;
73+pub const KETTLE_ODDS: u32 = 6;
74+pub const ULI_READS: u8 = 2;
75+pub const DRAWER_JAMS: u32 = 4;
7376 pub const KITCHEN_TOLD: u32 = 4;
7477 pub const KITCHEN_TELLINGS: u8 = 2;
7578 pub const WATERMELON: (u32, u32) = (2, 5);
Modified file: src/game/floors.rs+39 additions−9 deletions
@@ -4,8 +4,8 @@ use serde_json::{Value, json};
44 use super::{
55 Cause, Elder, Floor, FloorState, Item, Outcome, Run,
66 balance::{
7- EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER, KEY_IN_DRAWER,
8- RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,
7+ DRAWER_JAMS, EQ_BAND, FLOOR_ALLOWANCE, FLOOR_TURNS, FRICTION_FLOOR, GREGS_LOCKER,
8+ KEY_IN_DRAWER, RULER_IN_DRAWER, STARTING_EQ, SUSPICION_LIMIT, ZIP,
99 },
1010 round2,
1111 };
@@ -142,6 +142,15 @@ impl Run {
142142 self.decision("ruler", "Is there a ruler hiding in the drawer?"),
143143 RULER_IN_DRAWER,
144144 );
145+ let vaseline_here = self.floor == Floor::Bathroom
146+ && first
147+ && self.chance(
148+ self.decision(
149+ "bathroom-vaseline",
150+ "Is it the vaseline behind the door lock, rather than the lotion?",
151+ ),
152+ 2,
153+ );
145154 let locker_open = self.floor == Floor::ChangingRoom
146155 && first
147156 && self.chance(
@@ -159,11 +168,14 @@ impl Run {
159168 }
160169 Floor::Bathroom if first => {
161170 self.items.grant(Item::DoorLock);
162- self.items.grant(Item::Vaseline);
163- Ok(self.found(
164- Item::DoorLock,
165- "A door lock, which works once, and a tub of vaseline behind it.",
166- ))
171+ let detail = if vaseline_here {
172+ self.items.grant(Item::Vaseline);
173+ "A door lock, which works once, and a tub of vaseline behind it."
174+ } else {
175+ self.items.grant(Item::Lotion);
176+ "A door lock, which works once, and a bottle of lotion behind it."
177+ };
178+ Ok(self.found(Item::DoorLock, detail))
167179 }
168180 Floor::ChangingRoom if locker_open => {
169181 self.items.grant(Item::BabyOil);
@@ -192,9 +204,10 @@ impl Run {
192204 self.items.grant(Item::DndSign);
193205 self.items.grant(Item::IcePack);
194206 self.items.grant(Item::IcePack);
207+ self.items.grant(Item::Lotion);
195208 Ok(self.found(
196209 Item::DndSign,
197- "A Do Not Disturb sign, and two ice packs from the machine down the hall.",
210+ "A Do Not Disturb sign, two ice packs from the machine down the hall, and a complimentary lotion.",
198211 ))
199212 }
200213 Floor::Forum if first => {
@@ -230,15 +243,32 @@ impl Run {
230243 .header(header::LOCATION, self.path("")))
231244 }
232245
233- /// Searching while caged is noisy, and the key only turns up some of the time.
246+ /// Searching while caged is noisy, costs the floor turn, and the key only turns up some of
247+ /// the time. A jammed drawer takes a whole turn to work loose.
234248 fn rummage_for_key(&mut self) -> Outcome {
249+ self.turn += 1;
250+ self.here.turns += 1;
251+ self.realise();
235252 let was_below = self.suspicion < SUSPICION_LIMIT;
236253 self.suspicion = (self.suspicion + 1).min(SUSPICION_LIMIT);
237254 self.maybe_summon_mum(was_below);
255+ if self.here.drawer_jammed {
256+ self.here.drawer_jammed = false;
257+ self.notices
258+ .push("The drawer comes loose. That took a while.");
259+ return Outcome::empty(StatusCode::NO_CONTENT);
260+ }
238261 if !self.chance(
239262 self.decision("key", "Does the drawer finally give up the cage key?"),
240263 KEY_IN_DRAWER,
241264 ) {
265+ if self.chance(
266+ self.decision("drawer-jams", "Has the drawer jammed on its runners?"),
267+ DRAWER_JAMS,
268+ ) {
269+ self.here.drawer_jammed = true;
270+ self.notices.push("The drawer has jammed.");
271+ }
242272 return Outcome::empty(StatusCode::NO_CONTENT);
243273 }
244274 self.items.grant(Item::CageKey);
Modified file: src/game/forum.rs+26 additions−20 deletions
@@ -1,9 +1,11 @@
1+use std::time::SystemTime;
2+
13 use axum::http::{StatusCode, header};
24 use serde_json::{Value, json};
35
46 use super::{
57 Cause, Elder, Floor, Outcome, Run, Story,
6- balance::{EXAGGERATION, PATIENCE, SUSPICION_LIMIT, ULI_ODDS},
8+ balance::{EXAGGERATION, PATIENCE, ULI_ODDS, ULI_READS},
79 body::exaggeration,
810 honest, parse_credentials,
911 };
@@ -21,6 +23,13 @@ date,length,girth,technique,notes,honest?
2123 2009-06-14,7.50,5.50,wet,progress is slow but real,no
2224 ";
2325
26+fn nothing_new() -> Outcome {
27+ Outcome::empty(StatusCode::NOT_MODIFIED).header(
28+ header::WARNING,
29+ r#"199 - "Nothing new has been posted since 2009.""#,
30+ )
31+}
32+
2433 pub fn forum_thread() -> Value {
2534 json!({
2635 "title": "The definitive jelqing FAQ (updated 2009)",
@@ -109,7 +118,8 @@ impl Run {
109118 )
110119 }
111120
112- /// Opening the thread again costs the turn, and each read may finally surface the reply.
121+ /// Re-reading is free. Each floor gets a couple of live reads that may surface the reply,
122+ /// after which the forum is down and the browser serves what it kept.
113123 pub fn read_thread(&mut self) -> Result<Outcome, Problem> {
114124 if self.story == Story::Pending {
115125 return Ok(self.branch_offer());
@@ -121,20 +131,21 @@ impl Run {
121131 "There is no thread to re-read. Nobody has mentioned a forum yet.",
122132 ));
123133 }
124- self.touch();
125- self.turn += 1;
126- self.here.turns += 1;
127134 self.end_kitchen_visit();
128- self.realise();
129- if self.chance(
130- self.decision(
131- "rupture",
132- "Does the third read of the same thread rupture something?",
133- ),
134- self.rupture_odds(),
135- ) {
136- return Err(self.rupture());
135+ if self.uli_taught {
136+ return Ok(Outcome::json(StatusCode::OK, forum_thread()));
137137 }
138+ if self.here.thread_rolls >= ULI_READS {
139+ let age = httpdate::parse_http_date(ELDER_LAST_MODIFIED)
140+ .ok()
141+ .and_then(|posted| SystemTime::now().duration_since(posted).ok())
142+ .map_or(0, |age| age.as_secs());
143+ return Ok(Outcome::json(StatusCode::OK, forum_thread())
144+ .header(header::AGE, age.to_string())
145+ .header(header::WARNING, r#"112 - "Disconnected operation""#));
146+ }
147+ self.here.thread_rolls += 1;
148+ self.touch();
138149 if self.chance(
139150 self.decision(
140151 "teach-uli",
@@ -149,12 +160,7 @@ impl Run {
149160 )
150161 .header(header::LOCATION, self.path("/techniques")));
151162 }
152- let was_below = self.suspicion < SUSPICION_LIMIT;
153- self.settle(was_below);
154- Ok(Outcome::empty(StatusCode::NOT_MODIFIED).header(
155- header::WARNING,
156- r#"199 - "Nothing new has been posted since 2009.""#,
157- ))
163+ Ok(nothing_new())
158164 }
159165
160166 fn elder_strike(&mut self, problem: Problem) -> Problem {
Modified file: src/game/kitchen.rs+14 additions−3 deletions
@@ -4,7 +4,7 @@ use serde_json::json;
44 use super::{
55 Cause, Floor, Item, Outcome, Run, Story,
66 balance::{
7- CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,
7+ CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TELLINGS,
88 KITCHEN_TOLD, MELON_SHAME, RUPTURE_LINE, SUSPICION_LIMIT, WATERMELON,
99 },
1010 };
@@ -41,19 +41,24 @@ impl Run {
4141 return Err(self.rupture());
4242 }
4343 let was_below = self.suspicion < SUSPICION_LIMIT;
44- // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5 and 1 in 8.
44+ // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.
4545 let loot = self.oracle.pick(
4646 self.decision("loot", "What does the fridge give up on this raid?"),
4747 &[
4848 120 / COCONUT_ODDS,
4949 120 / ENERGY_ODDS,
5050 120 / KITCHEN_ICE_ODDS,
51- 120 - (120 / COCONUT_ODDS + 120 / ENERGY_ODDS + 120 / KITCHEN_ICE_ODDS),
51+ 120 / KETTLE_ODDS,
52+ 120 - (120 / COCONUT_ODDS
53+ + 120 / ENERGY_ODDS
54+ + 120 / KITCHEN_ICE_ODDS
55+ + 120 / KETTLE_ODDS),
5256 ],
5357 &[
5458 "A jar of coconut oil, half of it already gone.",
5559 "An energy drink, cold and faintly alarming.",
5660 "An ice pack, buried under the peas.",
61+ "The kettle has just boiled.",
5762 "Nothing. The fridge is ordinary.",
5863 ],
5964 );
@@ -86,6 +91,12 @@ impl Run {
8691 StatusCode::OK,
8792 json!({ "detail": "An ice pack, buried under the peas." }),
8893 ))
94+ } else if loot == 3 {
95+ self.here.kettle_boiled = true;
96+ Ok(Outcome::json(
97+ StatusCode::OK,
98+ json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),
99+ ))
89100 } else {
90101 Ok(Outcome::empty(StatusCode::NO_CONTENT))
91102 };
Modified file: src/game/mod.rs+17 additions−25 deletions
@@ -26,10 +26,10 @@ pub use techniques::{LUBRICANTS, Lube, Technique};
2626
2727 use std::{
2828 hash::{DefaultHasher, Hash, Hasher},
29- time::{Duration, Instant, SystemTime},
29+ time::{Instant, SystemTime},
3030 };
3131
32-use axum::http::{StatusCode, header};
32+use axum::http::StatusCode;
3333 use serde_json::{Value, json};
3434
3535 use crate::problem::Problem;
@@ -76,8 +76,7 @@ pub struct Run {
7676 pub seeded: bool,
7777 pub filmed_at: Option<SystemTime>,
7878 pub observed: Option<Observer>,
79- pub refractory: Duration,
80- pub refractory_until: Option<Instant>,
79+ pub exhausted: bool,
8180 pub pending: Vec<Pending>,
8281 pub notices: Vec<&'static str>,
8382 pub ended: Option<Ending>,
@@ -188,8 +187,7 @@ impl Run {
188187 seeded: false,
189188 filmed_at: None,
190189 observed: None,
191- refractory: Duration::from_secs(30),
192- refractory_until: None,
190+ exhausted: false,
193191 pending: Vec::new(),
194192 ended: None,
195193 last_seen: Instant::now(),
@@ -394,34 +392,28 @@ impl Run {
394392 cause.problem().with("turn", self.turn)
395393 }
396394
397- /// Ends the refractory period once it has run out, whatever the request.
398- pub fn recover(&mut self, now: Instant) {
399- if self.refractory_until.is_some_and(|until| now >= until) {
400- self.refractory_until = None;
401- self.stamina = self.max_stamina();
402- self.reset_eq(STARTING_EQ);
403- self.warmed_up = false;
404- self.touch();
405- }
395+ /// The refractory period is the next HOLD. It comes back with full stamina and nothing else.
396+ fn recover(&mut self) {
397+ self.exhausted = false;
398+ self.stamina = self.max_stamina();
399+ self.reset_eq(STARTING_EQ);
400+ self.warmed_up = false;
406401 }
407402
408- fn check_refractory(&mut self, now: Instant) -> Result<(), Problem> {
409- self.recover(now);
410- let Some(until) = self.refractory_until else {
403+ fn check_exhausted(&self) -> Result<(), Problem> {
404+ if !self.exhausted {
411405 return Ok(());
412- };
413- let seconds = until.saturating_duration_since(now).as_secs() + 1;
406+ }
414407 Err(Problem::new(
415408 StatusCode::TOO_MANY_REQUESTS,
416409 "overtrained",
417- "Stamina is exhausted. The refractory period is in effect.",
418- )
419- .header(header::RETRY_AFTER, seconds.to_string()))
410+ "Stamina is exhausted. HOLD for the refractory period.",
411+ ))
420412 }
421413
422- fn exhaust(&mut self, now: Instant) {
414+ fn exhaust(&mut self) {
423415 self.stamina = 0;
424- self.refractory_until = Some(now + self.refractory);
416+ self.exhausted = true;
425417 }
426418
427419 fn realise(&mut self) {
Modified file: src/game/state.rs+3 additions
@@ -136,6 +136,9 @@ impl Grip {
136136 pub struct FloorState {
137137 pub turns: u32,
138138 pub drawer_searched: bool,
139+ pub drawer_jammed: bool,
140+ pub kettle_boiled: bool,
141+ pub thread_rolls: u8,
139142 pub edges: u8,
140143 pub temp_length: f64,
141144 pub teapot: bool,
Modified file: src/game/turn.rs+39 additions−40 deletions
@@ -1,4 +1,4 @@
1-use std::{fmt::Write as _, ops::ControlFlow, time::Instant};
1+use std::{fmt::Write as _, ops::ControlFlow};
22
33 use axum::http::{StatusCode, header};
44 use serde_json::json;
@@ -25,9 +25,8 @@ impl Run {
2525 techniques: &[Technique],
2626 intensity: i32,
2727 lube: Option<Lube>,
28- now: Instant,
2928 ) -> Result<Outcome, Problem> {
30- self.check_refractory(now)?;
29+ self.check_exhausted()?;
3130 if self.story == Story::Pending {
3231 return Ok(self.branch_offer());
3332 }
@@ -46,11 +45,9 @@ impl Run {
4645 ));
4746 }
4847 match *techniques {
49- [Technique::Utility(utility)] => self.utility(utility, lube, now),
50- [Technique::Training(training)] => self.single(training, intensity, lube, now),
51- [Technique::Training(a), Technique::Training(b)] => {
52- self.combo(a, b, intensity, lube, now)
53- }
48+ [Technique::Utility(utility)] => self.utility(utility, lube),
49+ [Technique::Training(training)] => self.single(training, intensity, lube),
50+ [Technique::Training(a), Technique::Training(b)] => self.combo(a, b, intensity, lube),
5451 [_, _] => Err(Problem::new(
5552 StatusCode::BAD_REQUEST,
5653 "combo-invalid",
@@ -64,12 +61,7 @@ impl Run {
6461 }
6562 }
6663
67- fn utility(
68- &mut self,
69- utility: Utility,
70- lube: Option<Lube>,
71- now: Instant,
72- ) -> Result<Outcome, Problem> {
64+ fn utility(&mut self, utility: Utility, lube: Option<Lube>) -> Result<Outcome, Problem> {
7365 match utility {
7466 Utility::Warmup => return self.warm_up(),
7567 Utility::Ice => return self.ice(),
@@ -81,12 +73,9 @@ impl Run {
8173 self.ready(Technique::Utility(utility), lube)?;
8274 self.check_storage()?;
8375 self.floor_enemy()?;
84- if utility == Utility::Pump {
85- self.here.edges = 0;
86- }
8776 match utility {
88- Utility::Edge => self.edge(now),
89- _ => self.pump(now),
77+ Utility::Edge => self.edge(),
78+ _ => self.pump(),
9079 }
9180 }
9281
@@ -95,16 +84,14 @@ impl Run {
9584 training: Training,
9685 intensity: i32,
9786 lube: Option<Lube>,
98- now: Instant,
9987 ) -> Result<Outcome, Problem> {
10088 self.ready(Technique::Training(training), lube)?;
10189 self.check_storage()?;
10290 self.lube_available(lube)?;
10391 self.floor_enemy()?;
104- self.here.edges = 0;
10592 self.take_lube(lube)?;
106- self.chafe(lube);
107- self.commit(intensity, lube, now, 1);
93+ self.chafe(lube, intensity);
94+ self.commit(intensity, lube, 1);
10895 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
10996 return Ok(interrupted);
11097 }
@@ -121,7 +108,6 @@ impl Run {
121108 b: Training,
122109 intensity: i32,
123110 lube: Option<Lube>,
124- now: Instant,
125111 ) -> Result<Outcome, Problem> {
126112 if a == b {
127113 return Err(Problem::new(
@@ -135,10 +121,9 @@ impl Run {
135121 self.check_storage()?;
136122 self.lube_available(lube)?;
137123 self.floor_enemy()?;
138- self.here.edges = 0;
139124 self.take_lube(lube)?;
140- self.chafe(lube);
141- self.commit(intensity, lube, now, 2);
125+ self.chafe(lube, intensity);
126+ self.commit(intensity, lube, 2);
142127 for training in [a, b] {
143128 if let ControlFlow::Break(interrupted) = self.mishaps(training, intensity)? {
144129 return Ok(interrupted);
@@ -279,10 +264,8 @@ impl Run {
279264 }
280265
281266 /// The techniques a JELQ could use right now without being refused before the roll.
282- pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {
283- let blocked = !self.floor.timed()
284- || self.items.cage
285- || self.refractory_until.is_some_and(|until| now < until);
267+ pub fn techniques_now(&self) -> Vec<Technique> {
268+ let blocked = !self.floor.timed() || self.items.cage || self.exhausted;
286269 if blocked {
287270 return Vec::new();
288271 }
@@ -346,12 +329,20 @@ impl Run {
346329 Ok(())
347330 }
348331
349- fn chafe(&mut self, lube: Option<Lube>) {
350- let mut friction = match lube {
351- None => FRICTION_DRY,
352- Some(Lube::Spit) => FRICTION_SPIT,
353- Some(_) => 0,
332+ pub(super) fn chafe(&mut self, lube: Option<Lube>, intensity: i32) {
333+ let (base, slip) = match lube {
334+ None => (FRICTION_DRY, 0),
335+ Some(Lube::Spit) => (FRICTION_SPIT, 0),
336+ Some(Lube::Lotion) => (0, 0),
337+ Some(Lube::BabyOil | Lube::CoconutOil) => (0, 1),
338+ Some(Lube::Vaseline) => (0, 2),
354339 };
340+ let vigour = match intensity {
341+ ..=5 => 0,
342+ 6..=8 => 1,
343+ _ => 2,
344+ };
345+ let mut friction = (base + vigour - slip).max(0);
355346 if friction > 0 && self.jelq_grip.is_some_and(|grip| grip != self.grip) {
356347 friction -= FRICTION_GRIP;
357348 }
@@ -371,7 +362,7 @@ impl Run {
371362 }
372363 }
373364
374- pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, now: Instant, cost: i32) {
365+ pub(super) fn commit(&mut self, intensity: i32, lube: Option<Lube>, cost: i32) {
375366 self.touch();
376367 self.turn += 1;
377368 self.here.turns += 1;
@@ -382,7 +373,7 @@ impl Run {
382373 }
383374 self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));
384375 if self.stamina <= 0 {
385- self.exhaust(now);
376+ self.exhaust();
386377 }
387378 let chill = if self.floor == Floor::ChangingRoom {
388379 CHANGING_ROOM_CHILL
@@ -472,8 +463,7 @@ impl Run {
472463 self.eq > DANGER_EQ || self.stamina <= 2
473464 }
474465
475- pub fn hold(&mut self, now: Instant) -> Result<Outcome, Problem> {
476- self.check_refractory(now)?;
466+ pub fn hold(&mut self) -> Result<Outcome, Problem> {
477467 if self.story == Story::Pending {
478468 return Ok(self.branch_offer());
479469 }
@@ -496,6 +486,15 @@ impl Run {
496486 return Err(self.rupture());
497487 }
498488 let was_below = self.suspicion < SUSPICION_LIMIT;
489+ if self.exhausted {
490+ self.recover();
491+ self.shed(FRICTION_HOLD);
492+ self.settle(was_below);
493+ return Ok(Outcome::json(
494+ StatusCode::OK,
495+ json!({ "detail": "The count went well past ten. Stamina is back. Everything else has gone quiet, and you are cold again." }),
496+ ));
497+ }
499498 if self.chance(
500499 self.decision(
501500 "reflection",
Modified file: src/game/utility.rs+17 additions−6 deletions
@@ -1,6 +1,6 @@
11 use std::{
22 ops::ControlFlow,
3- time::{Duration, Instant, SystemTime},
3+ time::{Duration, SystemTime},
44 };
55
66 use axum::http::{StatusCode, header};
@@ -19,6 +19,13 @@ const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
1919
2020 impl Run {
2121 pub(super) fn warm_up(&mut self) -> Result<Outcome, Problem> {
22+ if self.warmed_up && !self.here.kettle_boiled {
23+ return Err(Problem::new(
24+ StatusCode::CONFLICT,
25+ "towel-cold",
26+ "The towel has gone cold. It warms once per floor, or once after the refractory period, unless someone boils the kettle.",
27+ ));
28+ }
2229 if self.floor == Floor::Hotel
2330 && self.chance(
2431 self.decision(
@@ -50,6 +57,9 @@ impl Run {
5057 };
5158 self.touch();
5259 self.step_away();
60+ if self.warmed_up {
61+ self.here.kettle_boiled = false;
62+ }
5363 self.warmed_up = true;
5464 self.eq = (self.eq + boost).min(100);
5565 Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail })))
@@ -134,8 +144,9 @@ impl Run {
134144 .with("upstream", "Pump-Bro"))
135145 }
136146
137- pub(super) fn edge(&mut self, now: Instant) -> Result<Outcome, Problem> {
138- self.commit(5, None, now, 1);
147+ pub(super) fn edge(&mut self) -> Result<Outcome, Problem> {
148+ self.chafe(None, 5);
149+ self.commit(5, None, 1);
139150 if let ControlFlow::Break(interrupted) = self.hazards(5)? {
140151 return Ok(interrupted);
141152 }
@@ -144,7 +155,7 @@ impl Run {
144155 self.girth += EDGE_GIRTH;
145156 if self.here.edges >= EDGE_LOOP {
146157 self.here.edges = 0;
147- self.exhaust(now);
158+ self.exhaust();
148159 return Err(Problem::new(
149160 StatusCode::LOOP_DETECTED,
150161 "edging-loop",
@@ -157,8 +168,8 @@ impl Run {
157168 ))
158169 }
159170
160- pub(super) fn pump(&mut self, now: Instant) -> Result<Outcome, Problem> {
161- self.commit(4, None, now, 1);
171+ pub(super) fn pump(&mut self) -> Result<Outcome, Problem> {
172+ self.commit(4, None, 1);
162173 if let ControlFlow::Break(interrupted) = self.hazards(4)? {
163174 return Ok(interrupted);
164175 }
Modified file: src/http/meta.rs−1 deletions
@@ -105,7 +105,6 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
105105 &config.oracle,
106106 );
107107 run.seeded = seeded;
108- run.refractory = config.refractory;
109108 run.last_seen = ctx.now;
110109 let response = (
111110 StatusCode::CREATED,
Modified file: src/http/run.rs+3 additions−6 deletions
@@ -1,7 +1,5 @@
11 //! Plumbing shared by every resource under `/runs/{id}`.
22
3-use std::time::Instant;
4-
53 use axum::{
64 http::{HeaderName, HeaderValue, StatusCode, header},
75 response::{IntoResponse, Response},
@@ -60,7 +58,6 @@ pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
6058 .into_response();
6159 }
6260 run.last_seen = ctx.now;
63- run.recover(ctx.now);
6461 run.observed = None;
6562 let response =
6663 gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
@@ -96,7 +93,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9693
9794 /// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
9895 /// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
99-fn links(run: &Run, now: Instant) -> String {
96+fn links(run: &Run) -> String {
10097 let thread_reachable = run.thread_reachable();
10198 let linked: [(&str, &RunResource, bool); 15] = [
10299 ("", &runs::RUN, true),
@@ -152,7 +149,7 @@ fn links(run: &Run, now: Instant) -> String {
152149 };
153150 [choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ")
154151 }))
155- .chain(run.techniques_now(now).into_iter().map(|technique| {
152+ .chain(run.techniques_now().into_iter().map(|technique| {
156153 let name = technique.name();
157154 let lubricant = technique
158155 .lubricant()
@@ -219,7 +216,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
219216 (X_SHAME, shame.to_string()),
220217 (X_CASH, run.cash.to_string()),
221218 (X_FLOOR, run.floor.number().to_string()),
222- (header::LINK, links(run, ctx.now)),
219+ (header::LINK, links(run)),
223220 ];
224221 if let Some(observer) = run.observed {
225222 stats.push((X_OBSERVED_BY, observer.name().into()));
Modified file: src/http/runs.rs+3 additions−5 deletions
@@ -173,9 +173,7 @@ fn jelq(run: &mut Run, ctx: &Ctx) -> Response {
173173 Ok((techniques, intensity, lube))
174174 });
175175 match parsed {
176- Ok((techniques, intensity, lube)) => {
177- respond(run.jelq(&techniques, intensity, lube, ctx.now))
178- }
176+ Ok((techniques, intensity, lube)) => respond(run.jelq(&techniques, intensity, lube)),
179177 Err(problem) => problem.into_response(),
180178 }
181179 }
@@ -184,8 +182,8 @@ fn put_run(run: &mut Run, _: &Ctx) -> Response {
184182 run.put().into_response()
185183 }
186184
187-fn hold_run(run: &mut Run, ctx: &Ctx) -> Response {
188- respond(run.hold(ctx.now))
185+fn hold_run(run: &mut Run, _: &Ctx) -> Response {
186+ respond(run.hold())
189187 }
190188
191189 fn patch_run(run: &mut Run, ctx: &Ctx) -> Response {
Modified file: src/tests.rs+74 additions−14 deletions
@@ -1,7 +1,4 @@
1-use std::{
2- sync::Arc,
3- time::{Duration, Instant},
4-};
1+use std::{sync::Arc, time::Duration};
52
63 use axum::{
74 Router,
@@ -994,7 +991,6 @@ async fn the_thread_announces_itself_at_the_branch() {
994991 #[tokio::test]
995992 async fn rereading_the_thread_teaches_uli() {
996993 let mut taught_runs = 0;
997- let mut ruptured = 0;
998994 for seed in 0..100u64 {
999995 let app = app_with(Floor::Bedroom, seed, |run| {
1000996 run.turn = BRANCH_TURN;
@@ -1002,34 +998,36 @@ async fn rereading_the_thread_teaches_uli() {
1002998 run.story = Story::ClosedTab;
1003999 });
10041000 let mut taught = false;
1005- for _ in 0..40 {
1001+ for _ in 0..2 {
10061002 let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
10071003 match read.status() {
10081004 StatusCode::NOT_MODIFIED => {
10091005 assert!(header(&read, "warning").contains("2009"));
10101006 }
1011- StatusCode::MULTIPLE_CHOICES => {
1012- send(&app, "POST", "/runs/cafebabe/branches/close-tab", &[], "").await;
1013- }
10141007 StatusCode::OK => {
10151008 assert!(header(&read, "location").contains("/techniques"));
10161009 taught = true;
10171010 break;
10181011 }
1019- StatusCode::INTERNAL_SERVER_ERROR => break,
10201012 other => panic!("seed {seed} read the thread into {other}"),
10211013 }
10221014 }
1015+ let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
1016+ assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
10231017 if taught {
10241018 taught_runs += 1;
10251019 let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
10261020 assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
1021+ } else {
1022+ let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
1023+ assert_eq!(cached.status(), StatusCode::OK);
1024+ assert!(header(&cached, "warning").starts_with("112"));
1025+ assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);
10271026 }
1028- ruptured += u32::from(!taught);
10291027 }
10301028 assert!(
1031- taught_runs >= 85,
1032- "{taught_runs}/100 learned uli, {ruptured} ruptured first"
1029+ (30..=58).contains(&taught_runs),
1030+ "{taught_runs}/100 learned uli in two reads"
10331031 );
10341032 }
10351033
@@ -1096,7 +1094,7 @@ async fn holding_calms_and_sometimes_reflects() {
10961094 for seed in 0..120u64 {
10971095 let mut run = Run::new("cafebabe".into(), "cafe".into(), seed);
10981096 run.eq = 75;
1099- match run.hold(Instant::now()) {
1097+ match run.hold() {
11001098 Ok(_) if run.eq == 0 => reflected += 1,
11011099 Ok(_) => calmed += 1,
11021100 Err(_) => ruptured += 1,
@@ -1893,3 +1891,65 @@ async fn a_full_server_buries_the_dead_to_make_room() {
18931891 let second = send(&app, "POST", "/runs", &[], "").await;
18941892 assert_eq!(second.status(), StatusCode::CREATED);
18951893 }
1894+
1895+#[tokio::test]
1896+async fn the_towel_warms_once_until_the_kettle_boils() {
1897+ let app = seeded_with(Floor::Bedroom, |_| {});
1898+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1899+ assert_eq!(warm.status(), StatusCode::OK);
1900+ let again = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1901+ assert_eq!(again.status(), StatusCode::CONFLICT);
1902+
1903+ let app = seeded_with(Floor::Bedroom, |run| {
1904+ run.warmed_up = true;
1905+ run.here.kettle_boiled = true;
1906+ });
1907+ let reheated = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1908+ assert_eq!(reheated.status(), StatusCode::OK);
1909+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
1910+ assert_eq!(cold.status(), StatusCode::CONFLICT);
1911+}
1912+
1913+#[tokio::test]
1914+async fn the_refractory_period_is_the_next_hold() {
1915+ let app = seeded_with(Floor::Hotel, |run| {
1916+ run.here.wifi_accepted = true;
1917+ run.warmed_up = true;
1918+ run.eq = 60;
1919+ run.stamina = 0;
1920+ run.exhausted = true;
1921+ });
1922+ let tired = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1923+ assert_eq!(tired.status(), StatusCode::TOO_MANY_REQUESTS);
1924+ let hold = send(&app, "HOLD", "/runs/cafebabe", &[], "").await;
1925+ assert_eq!(hold.status(), StatusCode::OK);
1926+ assert_eq!(header(&hold, "x-stamina"), "10/10");
1927+ let cold = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1928+ assert_eq!(cold.status(), StatusCode::PRECONDITION_FAILED);
1929+}
1930+
1931+#[tokio::test]
1932+async fn friction_rises_with_vigour_and_edging_chafes() {
1933+ let spit = [("content-type", "text/plain; charset=spit")];
1934+ for (intensity, friction) in [(5, "1/10"), (7, "2/10"), (10, "3/10")] {
1935+ let app = seeded_with(Floor::Hotel, |run| {
1936+ run.here.wifi_accepted = true;
1937+ run.warmed_up = true;
1938+ run.eq = 55;
1939+ });
1940+ let uri = format!("/runs/cafebabe?technique=wet&intensity={intensity}");
1941+ let jelq = send(&app, "JELQ", &uri, &spit, "").await;
1942+ assert_eq!(
1943+ header(&jelq, "x-friction"),
1944+ friction,
1945+ "intensity {intensity}"
1946+ );
1947+ }
1948+ let app = seeded_with(Floor::Hotel, |run| {
1949+ run.here.wifi_accepted = true;
1950+ run.warmed_up = true;
1951+ run.eq = 55;
1952+ });
1953+ let edge = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
1954+ assert_eq!(header(&edge, "x-friction"), "2/10");
1955+}
Modified file: src/ui.html+1 additions−1 deletions
@@ -238,7 +238,7 @@ function renderAlerts(list) {
238238 }
239239 }
240240 const stamina = (list || []).find(([name]) => name === "x-stamina");
241- if (stamina && stamina[1].startsWith("0/")) alerts.push("Exhausted. Wait out the refractory period, then GET the run.");
241+ if (stamina && stamina[1].startsWith("0/")) alerts.push("Exhausted. HOLD for the refractory period.");
242242 for (const alert of alerts) box.append(el("h2", { textContent: alert }));
243243 }