atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit a11497af08aa

tarball

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

11 files changed+72-52
game: fold the re-read into the run's thread and let a drink end exhaustion

Re-reading lived at its own POST because the thread was a global stateless GET, so the run linked a re-read before anything had been read. The run now has one thread, GET /runs/{id}/thread, whose live reads do the teaching. An energy drink was refused with the 429 like everything else, so the refractory HOLD was the only way out even with a drink in hand.
Modified file: src/game/checks.rs+4 additions−1 deletions
@@ -89,10 +89,13 @@ impl Run {
8989 }
9090
9191 pub fn techniques_now(&self) -> impl Iterator<Item = Technique> + '_ {
92- let blocked = !self.floor.timed() || self.items.kit.cage || self.body.exhausted;
92+ let blocked = !self.floor.timed() || self.items.kit.cage;
9393 Technique::ALL
9494 .into_iter()
9595 .filter(move |_| !blocked)
96+ .filter(|&technique| {
97+ !self.body.exhausted || technique == Technique::Utility(Utility::Energy)
98+ })
9699 .filter(|&technique| match technique {
97100 Technique::Utility(Utility::Warmup) => {
98101 !self.here.towel_warmed || self.here.kitchen.kettle_boiled
Modified file: src/game/dispatch.rs+4 additions−2 deletions
@@ -14,7 +14,9 @@ impl Run {
1414 intensity: i32,
1515 lube: Option<Lube>,
1616 ) -> Result<Outcome, Problem> {
17- self.check_exhausted()?;
17+ if techniques != [Technique::Utility(Utility::Energy)] {
18+ self.check_exhausted()?;
19+ }
1820 if self.online.story == Story::Pending {
1921 return Ok(self.branch_offer());
2022 }
@@ -23,7 +25,7 @@ impl Run {
2325 StatusCode::SEE_OTHER,
2426 json!({ "detail": "Refer to the forum." }),
2527 )
26- .header(header::LOCATION, super::FORUM_THREAD));
28+ .header(header::LOCATION, self.path("/thread")));
2729 }
2830 if self.items.kit.cage {
2931 return Err(Problem::new(
Modified file: src/game/forum.rs+14 additions−7 deletions
@@ -26,8 +26,16 @@ date,length,girth,technique,notes,honest?
2626 2009-06-14,7.50,5.50,wet,progress is slow but real,no
2727 ";
2828
29+fn thread(detail: Option<&str>) -> Outcome {
30+ let mut body = forum_thread();
31+ if let Some(detail) = detail {
32+ body["detail"] = json!(detail);
33+ }
34+ Outcome::json(StatusCode::OK, body).header(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)
35+}
36+
2937 fn nothing_new() -> Outcome {
30- Outcome::empty(StatusCode::NOT_MODIFIED).header(
38+ thread(None).header(
3139 header::WARNING,
3240 r#"199 - "Nothing new has been posted since 2009.""#,
3341 )
@@ -190,18 +198,18 @@ impl Run {
190198 return Err(Problem::new(
191199 StatusCode::NOT_FOUND,
192200 "no-thread",
193- "There is no thread to re-read. Nobody has mentioned a forum yet.",
201+ "There is no thread. Nobody has mentioned a forum yet.",
194202 ));
195203 }
196204 self.end_kitchen_visit();
197205 if self.online.uli_taught {
198- return Ok(Outcome::json(StatusCode::OK, forum_thread()));
206+ return Ok(thread(None));
199207 }
200208 if self.here.thread_rolls >= THREAD_READS {
201209 let age = SystemTime::now()
202210 .duration_since(*ELDER_POSTED)
203211 .map_or(0, |age| age.as_secs());
204- return Ok(Outcome::json(StatusCode::OK, forum_thread())
212+ return Ok(thread(None)
205213 .header(header::AGE, age.to_string())
206214 .header(header::WARNING, r#"112 - "Disconnected operation""#));
207215 }
@@ -216,7 +224,7 @@ impl Run {
216224 if !self.chance(
217225 self.decision(
218226 "teach-uli",
219- "Does this reread finally surface the 2009 reply that teaches uli?",
227+ "Does this read finally surface the 2009 reply that teaches uli?",
220228 ),
221229 ULI_ODDS,
222230 ) {
@@ -225,8 +233,7 @@ impl Run {
225233 self.online.uli_taught = true;
226234 "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."
227235 };
228- Ok(Outcome::json(StatusCode::OK, json!({ "detail": detail }))
229- .header(header::LOCATION, self.path("/techniques")))
236+ Ok(thread(Some(detail)))
230237 }
231238
232239 fn learn_meditation(&mut self) -> bool {
Modified file: src/game/travel.rs+1 additions−1 deletions
@@ -121,7 +121,7 @@ impl Run {
121121 }
122122 Floor::Hotel => view["portal"] = json!(self.path("/portal")),
123123 Floor::Forum => {
124- view["thread"] = json!(super::FORUM_THREAD);
124+ view["thread"] = json!(self.path("/thread"));
125125 view["elder"] = json!(self.path("/elder"));
126126 }
127127 Floor::Clinic => view["diagnosis"] = json!(self.path("/diagnosis")),
Modified file: src/game/utility.rs+1 additions
@@ -103,6 +103,7 @@ impl Run {
103103 }
104104 self.items.stores.energy_drinks -= 1;
105105 self.step_away();
106+ self.body.exhausted = false;
106107 self.body.stamina = (self.body.stamina + ENERGY_STAMINA).min(self.max_stamina());
107108 self.touch();
108109 Ok(Outcome::json(
Modified file: src/http/links.rs+3 additions−13 deletions
@@ -1,7 +1,7 @@
11 use std::fmt::Write as _;
22
3-use super::{meta, run::RunResource, runs};
4-use crate::game::{Branch, FORUM_THREAD, Run, SHOP};
3+use super::{run::RunResource, runs};
4+use crate::game::{Branch, Run, SHOP};
55
66 pub const REL_BASE: &str = "https://jelq.dev/rels/";
77
@@ -43,7 +43,7 @@ pub fn header(run: &Run) -> String {
4343 ("techniques", &runs::TECHNIQUES, true),
4444 ("drawer", &runs::DRAWER, reach.drawer),
4545 ("kitchen", &runs::KITCHEN, reach.kitchen),
46- ("re-read", &runs::REREAD, reach.thread),
46+ ("thread", &runs::THREAD, reach.thread),
4747 ("grip", &runs::GRIP, true),
4848 ("shop", &runs::SHOP, reach.shop),
4949 ("portal", &runs::PORTAL, reach.portal),
@@ -66,16 +66,6 @@ pub fn header(run: &Run) -> String {
6666 }
6767 .write(&mut out);
6868 }
69- if reach.thread {
70- Link {
71- href: FORUM_THREAD,
72- rel: "thread",
73- allow: &meta::THREAD.allow_any(),
74- title: None,
75- lubricant: None,
76- }
77- .write(&mut out);
78- }
7969 if reach.branch_pending {
8070 for (branch, title) in [
8171 (Branch::Forum, "Read the forum"),
Modified file: src/http/mod.rs+1 additions−1 deletions
@@ -146,7 +146,7 @@ fn api(state: Shared) -> Router {
146146 .route("/runs/{id}/branches/{choice}", scoped(&runs::BRANCH))
147147 .route("/runs/{id}/kitchen", scoped(&runs::KITCHEN))
148148 .route("/runs/{id}/kitchen/watermelon", scoped(&runs::MELON))
149- .route("/runs/{id}/re-read", scoped(&runs::REREAD))
149+ .route("/runs/{id}/thread", scoped(&runs::THREAD))
150150 .route("/runs/{id}/floor", scoped(&runs::FLOOR))
151151 .route("/runs/{id}/shop", scoped(&runs::SHOP))
152152 .route("/runs/{id}/shop/{item}", scoped(&runs::BUY))
Modified file: src/http/resource.rs−5 deletions
@@ -84,11 +84,6 @@ impl<T> Resource<T> {
8484 Self::listed(self.verbs.iter().filter(|verb| verb.available(target)))
8585 }
8686
87- /// The methods with every guard assumed open, for resources that have none.
88- pub fn allow_any(&self) -> String {
89- Self::listed(self.verbs.iter())
90- }
91-
9287 fn listed<'a>(verbs: impl Iterator<Item = &'a Verb<T>>) -> String {
9388 let mut methods = Vec::new();
9489 for verb in verbs {
Modified file: src/http/runs.rs+2 additions−2 deletions
@@ -71,7 +71,7 @@ pub static DRAWER: RunResource = home(&[Verb::new("POST", search_drawer)]);
7171 pub static BRANCH: RunResource = home(&[Verb::new("POST", choose_branch)]);
7272 pub static KITCHEN: RunResource = home(&[Verb::new("POST", raid_kitchen)]);
7373 pub static MELON: RunResource = home(&[Verb::new("POST", commit_melon)]);
74-pub static REREAD: RunResource = pockets(&[Verb::new("POST", re_read)]);
74+pub static THREAD: RunResource = pockets(&[Verb::new("GET", get_thread)]);
7575 pub static FLOOR: RunResource =
7676 home(&[Verb::new("GET", get_floor), Verb::new("POST", leave_floor)]);
7777 pub static SHOP: RunResource = home(&[Verb::new("GET", get_shop)]);
@@ -229,7 +229,7 @@ fn commit_melon(run: &mut Run, _: &Ctx) -> Response {
229229 respond(run.commit_melon())
230230 }
231231
232-fn re_read(run: &mut Run, _: &Ctx) -> Response {
232+fn get_thread(run: &mut Run, _: &Ctx) -> Response {
233233 respond(run.read_thread())
234234 }
235235
Modified file: src/tests/floors.rs+19 additions−20 deletions
@@ -169,7 +169,7 @@ async fn the_thread_announces_itself_at_the_branch() {
169169 )
170170 .await;
171171 let ninth = send(&app, "GET", "/runs/cafebabe", &[], "").await;
172- assert!(!header(&ninth, "link").contains("/forum/threads/2009"));
172+ assert!(!header(&ninth, "link").contains("/cafebabe/thread>"));
173173 let tenth = send(
174174 &app,
175175 "JELQ",
@@ -185,13 +185,13 @@ async fn the_thread_announces_itself_at_the_branch() {
185185 .iter()
186186 .any(|w| w.to_str().unwrap().contains("a forum thread from 2009"))
187187 );
188- assert!(header(&tenth, "link").contains("/forum/threads/2009"));
188+ assert!(header(&tenth, "link").contains("/cafebabe/thread>"));
189189 let announced = send(&app, "GET", "/runs/cafebabe", &[], "").await;
190- assert!(header(&announced, "link").contains("/forum/threads/2009"));
190+ assert!(header(&announced, "link").contains("/cafebabe/thread>"));
191191 }
192192
193193 #[tokio::test]
194-async fn rereading_the_thread_teaches_uli() {
194+async fn reading_the_thread_teaches_uli() {
195195 let mut taught_runs = 0;
196196 for seed in 0..100u64 {
197197 let app = app_with(Floor::Bedroom, seed, |run| {
@@ -202,18 +202,17 @@ async fn rereading_the_thread_teaches_uli() {
202202 });
203203 let mut taught = false;
204204 for _ in 0..2 {
205- let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
206- match read.status() {
207- StatusCode::NOT_MODIFIED => {
208- assert!(header(&read, "warning").contains("2009"));
209- }
210- StatusCode::OK => {
211- assert!(header(&read, "location").contains("/techniques"));
212- taught = true;
213- break;
214- }
215- other => panic!("seed {seed} read the thread into {other}"),
205+ let read = send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;
206+ assert_eq!(read.status(), StatusCode::OK, "seed {seed}");
207+ let warned = warnings(&read).iter().any(|w| w.contains("2009"));
208+ let body = json(read).await;
209+ assert!(body["posts"].is_array(), "a read always shows the thread");
210+ if body["detail"].is_string() {
211+ assert!(!warned);
212+ taught = true;
213+ break;
216214 }
215+ assert!(warned);
217216 }
218217 let state = send(&app, "GET", "/runs/cafebabe", &[], "").await;
219218 assert_eq!(header(&state, "x-stamina"), "10/10", "reading is free");
@@ -222,7 +221,7 @@ async fn rereading_the_thread_teaches_uli() {
222221 let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=uli", &[], "").await;
223222 assert_ne!(jelq.status(), StatusCode::FORBIDDEN);
224223 } else {
225- let cached = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
224+ let cached = send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;
226225 assert_eq!(cached.status(), StatusCode::OK);
227226 assert!(header(&cached, "warning").starts_with("112"));
228227 assert!(header(&cached, "age").parse::<u64>().unwrap() > 0);
@@ -235,12 +234,12 @@ async fn rereading_the_thread_teaches_uli() {
235234 }
236235
237236 #[tokio::test]
238-async fn the_thread_cannot_be_reread_before_anyone_mentions_it() {
237+async fn the_thread_cannot_be_read_before_anyone_mentions_it() {
239238 let app = seeded_with(Floor::Bedroom, |run| run.body.warmed_up = true);
240- let read = send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
239+ let read = send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;
241240 assert_eq!(read.status(), StatusCode::NOT_FOUND);
242241 let after = send(&app, "GET", "/runs/cafebabe", &[], "").await;
243- assert!(!header(&after, "link").contains("/re-read"));
242+ assert!(!header(&after, "link").contains("/thread"));
244243 }
245244
246245 #[tokio::test]
@@ -475,7 +474,7 @@ async fn the_forum_teaches_breathing_before_uli() {
475474 assert_eq!(meditate["locked"], true);
476475 send(&app, "POST", "/runs/cafebabe/branches/forum", &[], "").await;
477476 for _ in 0..2 {
478- send(&app, "POST", "/runs/cafebabe/re-read", &[], "").await;
477+ send(&app, "GET", "/runs/cafebabe/thread", &[], "").await;
479478 }
480479 let meditated = send(&app, "JELQ", "/runs/cafebabe?technique=meditate", &[], "").await;
481480 if meditated.status() == StatusCode::OK {
Modified file: src/tests/turns.rs+23 additions
@@ -251,3 +251,26 @@ async fn queued_gains_announce_their_arrival() {
251251 let later = send(&app, "GET", "/runs/cafebabe", &[], "").await;
252252 assert!(later.headers().get("x-gains-arrived").is_none());
253253 }
254+
255+#[tokio::test]
256+async fn an_energy_drink_ends_exhaustion_without_the_refractory_period() {
257+ let app = seeded_with(Floor::Bedroom, |run| {
258+ run.body.warmed_up = true;
259+ run.body.eq = 60;
260+ run.body.stamina = 0;
261+ run.body.exhausted = true;
262+ run.items.stores.energy_drinks = 1;
263+ });
264+ let tired = send(&app, "GET", "/runs/cafebabe", &[], "").await;
265+ assert_eq!(advertised(&tired), ["energy"]);
266+ let drink = send(&app, "JELQ", "/runs/cafebabe?technique=energy", &[], "").await;
267+ assert_eq!(drink.status(), StatusCode::OK);
268+ assert_eq!(header(&drink, "x-stamina"), "3/10");
269+ assert_eq!(
270+ header(&drink, "x-eq"),
271+ "60",
272+ "still warm, unlike the refractory HOLD"
273+ );
274+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
275+ assert_ne!(jelq.status(), StatusCode::TOO_MANY_REQUESTS);
276+}