an http based roguelike rpg about jelqing
Commit d46f9d0b953a
tarball8 files changed+149-15
@@ -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
}@@ -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;@@ -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,@@ -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.cage244
- || self.story == Story::Pending245244
|| self.refractory_until.is_some_and(|until| now < until);246245
if blocked {247246
return Vec::new();@@ -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
}@@ -229,14 +229,36 @@ fn millimetres(inches: f64) -> usize {
229229
(inches * MM_PER_INCH).round() as usize230230
}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::Unsatisfiable261
+ })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) = ctx278
+ .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",@@ -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
+ response930
+ .headers()931
+ .get_all("warning")932
+ .iter()933
+ .any(|w| w.to_str().unwrap().contains("the flatmate tried the door"))934
+ );935
+}@@ -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
}