atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit d46f9d0b953a

tarball

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

8 files changed+149-15
http: keep story choices reachable and handle inch ranges

Story::Pending hid the JELQ action that exposed the turn 10 choices, leaving the UI without a way forward. Malformed inch ranges must be ignored under RFC 9110, while valid ranges beyond the available length require 416. Door locks and the Do Not Disturb sign previously consumed encounters without telling the player.
Modified file: src/game/forum.rs+1 additions−1 deletions
@@ -62,7 +62,7 @@ impl Run {
6262 .header(
6363 header::LINK,
6464 format!(
65- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\", <{close}>; rel=\"alternate\"; title=\"Close the tab\""
65+ "<{forum}>; rel=\"alternate\"; title=\"Read the forum\"; allow=\"POST\", <{close}>; rel=\"alternate\"; title=\"Close the tab\"; allow=\"POST\""
6666 ),
6767 )
6868 }
Modified file: src/game/hazards.rs+2 additions
@@ -109,6 +109,7 @@ impl Run {
109109 self.touch();
110110 if self.items.door_locks > 0 {
111111 self.items.door_locks -= 1;
112+ self.notices.push("the flatmate tried the door");
112113 return Ok(());
113114 }
114115 self.here.flatmate_told = true;
@@ -132,6 +133,7 @@ impl Run {
132133 self.suspicion = 0;
133134 self.touch();
134135 if self.items.dnd_sign {
136+ self.notices.push("housekeeping read the sign");
135137 return Ok(());
136138 }
137139 self.shame += 3;
Modified file: src/game/mod.rs+2 additions
@@ -73,6 +73,7 @@ pub struct Run {
7373 pub refractory: Duration,
7474 pub refractory_until: Option<Instant>,
7575 pub pending: Vec<Pending>,
76+ pub notices: Vec<&'static str>,
7677 pub ended: Option<Ending>,
7778 pub last_seen: Instant,
7879 rng: StdRng,
@@ -149,6 +150,7 @@ impl Run {
149150 story: Story::Unopened,
150151 mum: None,
151152 heard: Vec::new(),
153+ notices: Vec::new(),
152154 elder: Elder::Unverified { strikes: 0 },
153155 clinic: Clinic::default(),
154156 seeded: false,
Modified file: src/game/turn.rs−1 deletions
@@ -241,7 +241,6 @@ impl Run {
241241 pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {
242242 let blocked = !self.floor.timed()
243243 || self.items.cage
244- || self.story == Story::Pending
245244 || self.refractory_until.is_some_and(|until| now < until);
246245 if blocked {
247246 return Vec::new();
Modified file: src/http/run.rs+14 additions−2 deletions
@@ -11,7 +11,7 @@ use tokio::io::AsyncWriteExt;
1111
1212 use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs};
1313 use crate::{
14- game::{Elder, FORUM_THREAD, Floor, Run, balance::SUSPICION_LIMIT},
14+ game::{Elder, FORUM_THREAD, Floor, Run, Story, balance::SUSPICION_LIMIT},
1515 headers::{
1616 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_LENGTH, X_OBSERVED_BY,
1717 X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA, X_SUSPICION,
@@ -137,6 +137,15 @@ fn links(run: &Run, now: Instant) -> String {
137137 )
138138 }),
139139 )
140+ .chain((run.story == Story::Pending).then(|| {
141+ let choice = |slug: &str, title: &str| {
142+ format!(
143+ "<{}>; rel=\"{REL_BASE}branch\"; title=\"{title}\"; allow=\"POST\"",
144+ run.path(&format!("/branches/{slug}"))
145+ )
146+ };
147+ [choice("forum", "Read the forum"), choice("close-tab", "Close the tab")].join(", ")
148+ }))
140149 .chain(run.techniques_now(now).into_iter().map(|technique| {
141150 let name = technique.name();
142151 let lubricant = technique
@@ -172,7 +181,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
172181 let shame = settle_shame(run, ctx, secret);
173182 let mut stats: Vec<(HeaderName, String)> = vec![
174183 if run.items.ruler {
175- (X_LENGTH, format!("{:.2}", run.length))
184+ (X_LENGTH, format!("{:.2}", run.current_length()))
176185 } else {
177186 (
178187 X_SELF_REPORTED_LENGTH,
@@ -203,6 +212,9 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
203212 if run.conditions.pulled_muscle {
204213 stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into()));
205214 }
215+ for notice in run.notices.drain(..) {
216+ stats.push((header::WARNING, format!("199 - \"{notice}\"")));
217+ }
206218 if run.eq > 70 && run.ended.is_none() {
207219 stats.push((header::WARNING, "199 - \"it is turning purple\"".into()));
208220 }
Modified file: src/http/runs.rs+32 additions−9 deletions
@@ -229,14 +229,36 @@ fn millimetres(inches: f64) -> usize {
229229 (inches * MM_PER_INCH).round() as usize
230230 }
231231
232-fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {
232+enum InchRange {
233+ Satisfiable(f64, f64),
234+ Unsatisfiable,
235+}
236+
237+/// `None` means the header is not a valid range, which RFC 9110 §14.2 says to ignore rather than refuse.
238+fn parse_inch_range(range: &str, total: f64) -> Option<InchRange> {
233239 let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
234- let start: f64 = start.trim().parse().ok()?;
235- let end: f64 = match end.trim() {
236- "" => total,
237- end => end.parse().ok()?,
240+ let number = |text: &str| {
241+ text.parse::<f64>()
242+ .ok()
243+ .filter(|n| n.is_finite() && *n >= 0.0)
238244 };
239- (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))
245+ let (start, end) = match (start.trim(), end.trim()) {
246+ ("", "") => return None,
247+ ("", suffix) => ((total - number(suffix)?).max(0.0), total),
248+ (start, "") => (number(start)?, total),
249+ (start, end) => {
250+ let (start, end) = (number(start)?, number(end)?);
251+ if start > end {
252+ return None;
253+ }
254+ (start, end)
255+ }
256+ };
257+ Some(if start < total {
258+ InchRange::Satisfiable(start, end.min(total))
259+ } else {
260+ InchRange::Unsatisfiable
261+ })
240262 }
241263
242264 fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
@@ -252,10 +274,11 @@ fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
252274 );
253275 let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
254276
255- if let Some(range) = ctx.header(header::RANGE)
256- && range.starts_with("inches=")
277+ if let Some(range) = ctx
278+ .header(header::RANGE)
279+ .and_then(|range| parse_inch_range(range, inches))
257280 {
258- let Some((start, end)) = parse_inch_range(range, inches) else {
281+ let InchRange::Satisfiable(start, end) = range else {
259282 return Problem::new(
260283 StatusCode::RANGE_NOT_SATISFIABLE,
261284 "range-not-satisfiable",
Modified file: src/tests.rs+97 additions−1 deletions
@@ -11,7 +11,7 @@ use tower::ServiceExt;
1111
1212 use crate::{
1313 AppState, Config,
14- game::{Floor, MumVisit, Run},
14+ game::{Floor, MumVisit, Run, Story, balance::SUSPICION_LIMIT},
1515 router,
1616 };
1717
@@ -248,6 +248,43 @@ async fn length_supports_inch_ranges() {
248248 )
249249 .await;
250250 assert_eq!(absurd.status(), StatusCode::RANGE_NOT_SATISFIABLE);
251+ let malformed = send(
252+ &app,
253+ "GET",
254+ "/runs/cafebabe/length",
255+ &[("range", "inches=6.17")],
256+ "",
257+ )
258+ .await;
259+ assert_eq!(malformed.status(), StatusCode::OK);
260+ let suffix = send(
261+ &app,
262+ "GET",
263+ "/runs/cafebabe/length",
264+ &[("range", "inches=-1")],
265+ "",
266+ )
267+ .await;
268+ assert_eq!(suffix.status(), StatusCode::PARTIAL_CONTENT);
269+ assert!(text(suffix).await.ends_with('D'));
270+ for (range, status) in [
271+ ("inches=1-", StatusCode::PARTIAL_CONTENT),
272+ ("inches=40-", StatusCode::RANGE_NOT_SATISFIABLE),
273+ ("inches=-0", StatusCode::RANGE_NOT_SATISFIABLE),
274+ ("inches=2-1", StatusCode::OK),
275+ ("inches=NaN-2", StatusCode::OK),
276+ ("inches=0-inf", StatusCode::OK),
277+ ] {
278+ let response = send(
279+ &app,
280+ "GET",
281+ "/runs/cafebabe/length",
282+ &[("range", range)],
283+ "",
284+ )
285+ .await;
286+ assert_eq!(response.status(), status, "{range}");
287+ }
251288 }
252289
253290 #[tokio::test]
@@ -837,3 +874,62 @@ async fn the_ruler_replaces_the_self_report() {
837874 assert!(!after.headers().contains_key("x-self-reported-length"));
838875 assert!(after.headers().contains_key("x-length"));
839876 }
877+
878+#[tokio::test]
879+async fn an_open_tab_is_linked_and_jelq_still_answers_with_choices() {
880+ let app = seeded_with(Floor::Bathroom, |run| {
881+ run.story = Story::Pending;
882+ run.warmed_up = true;
883+ });
884+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
885+ let links = header(&response, "link");
886+ assert!(links.contains("/branches/forum>; rel=\"https://jelq.dev/rels/branch\""));
887+ assert!(links.contains("/branches/close-tab>; rel=\"https://jelq.dev/rels/branch\""));
888+ assert!(links.contains("?technique=wet>"));
889+ let jelq = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
890+ assert_eq!(jelq.status(), StatusCode::MULTIPLE_CHOICES);
891+}
892+
893+#[tokio::test]
894+async fn either_story_choice_is_reachable_during_cooldown() {
895+ for choice in ["forum", "close-tab"] {
896+ let app = seeded_with(Floor::Bathroom, |run| {
897+ run.turn = 9;
898+ run.warmed_up = true;
899+ run.stamina = 1;
900+ run.items.door_locks = 1;
901+ });
902+ let tenth = send(&app, "JELQ", "/runs/cafebabe?technique=edge", &[], "").await;
903+ assert_eq!(tenth.status(), StatusCode::OK);
904+ let response = send(&app, "GET", "/runs/cafebabe", &[], "").await;
905+ assert!(!header(&response, "link").contains("?technique="));
906+ let path = format!("/runs/cafebabe/branches/{choice}");
907+ assert!(
908+ header(&response, "link")
909+ .contains(&format!("<{path}>; rel=\"https://jelq.dev/rels/branch\""))
910+ );
911+ let chosen = send(&app, "POST", &path, &[], "").await;
912+ assert_eq!(chosen.status(), StatusCode::SEE_OTHER);
913+ assert!(!header(&chosen, "link").contains("/branches/"));
914+ let repeated = send(&app, "POST", &path, &[], "").await;
915+ assert_eq!(repeated.status(), StatusCode::CONFLICT);
916+ }
917+}
918+
919+#[tokio::test]
920+async fn a_door_lock_says_when_it_was_used() {
921+ let app = seeded_with(Floor::Bathroom, |run| {
922+ run.items.door_locks = 1;
923+ run.suspicion = SUSPICION_LIMIT;
924+ run.warmed_up = true;
925+ });
926+ let response = send(&app, "JELQ", "/runs/cafebabe?technique=dry", &[], "").await;
927+ assert_ne!(response.status(), StatusCode::CONFLICT);
928+ assert!(
929+ response
930+ .headers()
931+ .get_all("warning")
932+ .iter()
933+ .any(|w| w.to_str().unwrap().contains("the flatmate tried the door"))
934+ );
935+}
Modified file: src/ui.html+1 additions−1 deletions
@@ -314,7 +314,7 @@ function parseLinks(header) {
314314 continue;
315315 }
316316 const methods = (params.allow || "GET").split(",").map(s => s.trim());
317- resources.push({ key: name, group: GROUP_OF[name] || "play", name: NAMES[name] || name, path: match[1], methods });
317+ resources.push({ key: name, group: GROUP_OF[name] || "play", name: NAMES[name] || params.title || name, path: match[1], methods });
318318 }
319319 return [resources, moves];
320320 }