atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 4f54028cba69

tarball

atagen <boss@atagen.co>2026-10-14 11:51 UTC

23 files changed+380-276
game: give repeated text and tables a single home on the types they describe

Branch titles were spelled out three times, every 300 hand-wrote its Link header, the kitchen kept its loot text twice by index, and Link headers were formatted by hand in four places beside the one writer. The page's DEATHS list had already drifted from Cause and missed the abandoned and lost endings, so it now trusts X-Run-Over instead.
Modified file: src/game/checks.rs+17 additions−2 deletions
@@ -1,5 +1,5 @@
11 use super::{
2- LUBRICANTS, Lube, Run, Technique,
2+ Lube, Run, Technique,
33 balance::STORAGE_BULK,
44 lube::LubeRule,
55 techniques::{Training, Utility},
@@ -8,7 +8,22 @@ use crate::problem::Problem;
88 use axum::http::{StatusCode, header};
99 use serde_json::json;
1010
11+pub(super) fn caged() -> Problem {
12+ Problem::new(
13+ StatusCode::LOCKED,
14+ "caged",
15+ "The cage is locked. The key is not in the box.",
16+ )
17+}
18+
1119 impl Run {
20+ pub(super) fn check_cage(&self) -> Result<(), Problem> {
21+ if self.items.kit.cage {
22+ return Err(caged());
23+ }
24+ Ok(())
25+ }
26+
1227 pub(super) fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
1328 self.access(technique)?;
1429 match technique {
@@ -52,7 +67,7 @@ impl Run {
5267 training.name()
5368 ),
5469 )
55- .with("lubricants", json!(LUBRICANTS))),
70+ .with("lubricants", json!(Lube::content_types()))),
5671 (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
5772 StatusCode::UNSUPPORTED_MEDIA_TYPE,
5873 "lubricant-declared",
Modified file: src/game/dispatch.rs+1 additions−7 deletions
@@ -27,13 +27,7 @@ impl Run {
2727 )
2828 .header(header::LOCATION, self.path("/thread")));
2929 }
30- if self.items.kit.cage {
31- return Err(Problem::new(
32- StatusCode::LOCKED,
33- "caged",
34- "The cage is locked. The key is not in the box.",
35- ));
36- }
30+ self.check_cage()?;
3731 match *techniques {
3832 [Technique::Utility(utility)] => self.utility(utility, lube),
3933 [Technique::Training(training)] => self.single(training, intensity, lube),
Modified file: src/game/drawer.rs+2 additions−4 deletions
@@ -14,10 +14,8 @@ fn nothing() -> Result<Outcome, Problem> {
1414
1515 impl Run {
1616 pub(super) fn found(&self, item: Item, detail: &str) -> Outcome {
17- Outcome::json(StatusCode::FOUND, json!({ "detail": detail })).header(
18- header::LOCATION,
19- self.path(&format!("/inventory/{}", item.slug())),
20- )
17+ Outcome::json(StatusCode::FOUND, json!({ "detail": detail }))
18+ .header(header::LOCATION, self.item_path(item))
2119 }
2220
2321 pub fn search_drawer(&mut self) -> Result<Outcome, Problem> {
Modified file: src/game/forum.rs+19 additions−19 deletions
@@ -4,7 +4,7 @@ use axum::http::{StatusCode, header};
44 use serde_json::{Value, json};
55
66 use super::{
7- Cause, Floor, Item, Outcome, Run,
7+ Cause, Choice, Floor, Item, Outcome, Run,
88 balance::{
99 EXAGGERATION, MEDITATION_ODDS, PATIENCE, PRINTABLE_RULER, RULER_SHAME, THREAD_READS,
1010 ULI_ODDS,
@@ -82,19 +82,28 @@ pub enum Branch {
8282 }
8383
8484 impl Branch {
85+ pub const ALL: [Self; 2] = [Self::Forum, Self::CloseTab];
86+
8587 pub fn slug(self) -> &'static str {
8688 match self {
8789 Self::Forum => "forum",
8890 Self::CloseTab => "close-tab",
8991 }
9092 }
93+
94+ pub fn title(self) -> &'static str {
95+ match self {
96+ Self::Forum => "read the forum",
97+ Self::CloseTab => "close the tab",
98+ }
99+ }
91100 }
92101
93102 impl std::str::FromStr for Branch {
94103 type Err = ();
95104
96105 fn from_str(slug: &str) -> Result<Self, ()> {
97- [Self::Forum, Self::CloseTab]
106+ Self::ALL
98107 .into_iter()
99108 .find(|branch| branch.slug() == slug)
100109 .ok_or(())
@@ -131,23 +140,14 @@ impl Default for Online {
131140
132141 impl Run {
133142 pub(super) fn branch_offer(&self) -> Outcome {
134- let forum = self.path(&format!("/branches/{}", Branch::Forum.slug()));
135- let close = self.path(&format!("/branches/{}", Branch::CloseTab.slug()));
136- Outcome::json(
137- StatusCode::MULTIPLE_CHOICES,
138- json!({
139- "detail": "A browser tab is open. It has been open for some time.",
140- "choices": [
141- { "href": forum, "method": "POST", "title": "Read the forum" },
142- { "href": close, "method": "POST", "title": "Close the tab" },
143- ],
144- }),
145- )
146- .header(
147- header::LINK,
148- format!(
149- "<{forum}>; rel=\"alternate\"; title=\"Read the forum\"; allow=\"POST\", <{close}>; rel=\"alternate\"; title=\"Close the tab\"; allow=\"POST\""
150- ),
143+ let choices = Branch::ALL.map(|branch| Choice {
144+ href: self.branch_path(branch),
145+ method: "POST",
146+ title: branch.title(),
147+ });
148+ Outcome::choices(
149+ "A browser tab is open. It has been open for some time.",
150+ &choices,
151151 )
152152 }
153153
Modified file: src/game/gauges.rs+1 additions−1 deletions
@@ -178,7 +178,7 @@ impl Run {
178178 .map(|item| {
179179 json!({
180180 "name": item.slug(),
181- "href": self.path(&format!("/inventory/{}", item.slug())),
181+ "href": self.item_path(item),
182182 })
183183 })
184184 .collect();
Modified file: src/game/kitchen.rs+69 additions−53 deletions
@@ -1,8 +1,8 @@
1-use axum::http::{StatusCode, header};
1+use axum::http::StatusCode;
22 use serde_json::json;
33
44 use super::{
5- Cause, Floor, Item, Notice, Outcome, Run, Story,
5+ Cause, Choice, Floor, Item, Notice, Outcome, Run, Story,
66 balance::{
77 CAUGHT_MELONED, COCONUT_ODDS, ENERGY_ODDS, KETTLE_ODDS, KITCHEN_ICE_ODDS, KITCHEN_TOLD,
88 MELON_SHAME, RAID_NOISE, RUPTURE_LINE, WATERMELON,
@@ -10,6 +10,46 @@ use super::{
1010 };
1111 use crate::problem::Problem;
1212
13+#[derive(Clone, Copy)]
14+enum Loot {
15+ CoconutOil,
16+ EnergyDrink,
17+ IcePack,
18+ Kettle,
19+ Nothing,
20+}
21+
22+impl Loot {
23+ const ALL: [Self; 5] = [
24+ Self::CoconutOil,
25+ Self::EnergyDrink,
26+ Self::IcePack,
27+ Self::Kettle,
28+ Self::Nothing,
29+ ];
30+
31+ /// Out of 120, so one draw keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.
32+ fn weight(self) -> u32 {
33+ match self {
34+ Self::CoconutOil => 120 / COCONUT_ODDS,
35+ Self::EnergyDrink => 120 / ENERGY_ODDS,
36+ Self::IcePack => 120 / KITCHEN_ICE_ODDS,
37+ Self::Kettle => 120 / KETTLE_ODDS,
38+ Self::Nothing => 120 - Self::ALL[..4].iter().map(|l| l.weight()).sum::<u32>(),
39+ }
40+ }
41+
42+ fn sight(self) -> &'static str {
43+ match self {
44+ Self::CoconutOil => "A jar of coconut oil, half of it already gone.",
45+ Self::EnergyDrink => "An energy drink, cold and faintly alarming.",
46+ Self::IcePack => "An ice pack, buried under the peas.",
47+ Self::Kettle => "The kettle has just boiled.",
48+ Self::Nothing => "Nothing. The fridge is ordinary.",
49+ }
50+ }
51+}
52+
1353 impl Run {
1454 /// The household keeps count, so every raid makes noise and the noise makes the next telling likelier.
1555 pub fn raid_kitchen(&mut self) -> Result<Outcome, Problem> {
@@ -34,27 +74,11 @@ impl Run {
3474 return Err(self.rupture());
3575 }
3676 self.make_noise(RAID_NOISE);
37- // One draw of 120 keeps the marginals at the documented 1 in 3, 1 in 5, 1 in 8 and 1 in 6.
38- let loot = self.oracle.pick(
77+ let loot = Loot::ALL[self.oracle.pick(
3978 self.decision("loot", "What does the fridge give up on this raid?"),
40- &[
41- 120 / COCONUT_ODDS,
42- 120 / ENERGY_ODDS,
43- 120 / KITCHEN_ICE_ODDS,
44- 120 / KETTLE_ODDS,
45- 120 - (120 / COCONUT_ODDS
46- + 120 / ENERGY_ODDS
47- + 120 / KITCHEN_ICE_ODDS
48- + 120 / KETTLE_ODDS),
49- ],
50- &[
51- "A jar of coconut oil, half of it already gone.",
52- "An energy drink, cold and faintly alarming.",
53- "An ice pack, buried under the peas.",
54- "The kettle has just boiled.",
55- "Nothing. The fridge is ordinary.",
56- ],
57- );
79+ &Loot::ALL.map(Loot::weight),
80+ &Loot::ALL.map(Loot::sight),
81+ )];
5882 let melon = self.body.eq > RUPTURE_LINE
5983 && self.oracle.ratio(
6084 self.decision(
@@ -97,47 +121,39 @@ impl Run {
97121 )
98122 }
99123
100- fn unpack(&mut self, loot: usize) -> Outcome {
101- let (item, detail) = match loot {
102- 0 => (
103- Item::CoconutOil,
104- "A jar of coconut oil, half of it already gone.",
105- ),
106- 1 => (
107- Item::EnergyDrink,
108- "An energy drink, cold and faintly alarming.",
109- ),
110- 2 => (Item::IcePack, "An ice pack, buried under the peas."),
111- 3 => {
124+ fn unpack(&mut self, loot: Loot) -> Outcome {
125+ let item = match loot {
126+ Loot::CoconutOil => Item::CoconutOil,
127+ Loot::EnergyDrink => Item::EnergyDrink,
128+ Loot::IcePack => Item::IcePack,
129+ Loot::Kettle => {
112130 self.here.kitchen.kettle_boiled = true;
113131 return Outcome::json(
114132 StatusCode::OK,
115- json!({ "detail": "The kettle has just boiled. The towel can be warmed once more." }),
133+ json!({ "detail": format!("{} The towel can be warmed once more.", loot.sight()) }),
116134 );
117135 }
118- _ => return Outcome::empty(StatusCode::NO_CONTENT),
136+ Loot::Nothing => return Outcome::empty(StatusCode::NO_CONTENT),
119137 };
120138 self.items.grant(item);
121- Outcome::json(StatusCode::OK, json!({ "detail": detail }))
139+ Outcome::json(StatusCode::OK, json!({ "detail": loot.sight() }))
122140 }
123141
124142 fn melon_offer(&self) -> Outcome {
125- let commit = self.path("/kitchen/watermelon");
126- Outcome::json(
127- StatusCode::MULTIPLE_CHOICES,
128- json!({
129- "detail": "The fridge holds a watermelon. It is exactly the right shape.",
130- "choices": [
131- { "href": commit, "method": "POST", "title": "Commit to the watermelon" },
132- { "href": self.path(""), "method": "GET", "title": "Close the fridge" },
133- ],
134- }),
135- )
136- .header(
137- header::LINK,
138- format!(
139- "<{commit}>; rel=\"alternate\"; title=\"Commit to the watermelon\"; allow=\"POST\""
140- ),
143+ Outcome::choices(
144+ "The fridge holds a watermelon. It is exactly the right shape.",
145+ &[
146+ Choice {
147+ href: self.path("/kitchen/watermelon"),
148+ method: "POST",
149+ title: "Commit to the watermelon",
150+ },
151+ Choice {
152+ href: self.path(""),
153+ method: "GET",
154+ title: "Close the fridge",
155+ },
156+ ],
141157 )
142158 }
143159
Modified file: src/game/lube.rs+26 additions−13 deletions
@@ -4,14 +4,6 @@ use serde_json::json;
44 use super::Item;
55 use crate::problem::Problem;
66
7-pub const LUBRICANTS: [&str; 5] = [
8- "application/baby-oil",
9- "application/coconut-oil",
10- "application/lotion",
11- "application/vaseline",
12- "text/plain; charset=spit",
13-];
14-
157 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
168 pub enum Lube {
179 BabyOil,
@@ -22,6 +14,28 @@ pub enum Lube {
2214 }
2315
2416 impl Lube {
17+ const ALL: [Self; 5] = [
18+ Self::BabyOil,
19+ Self::CoconutOil,
20+ Self::Lotion,
21+ Self::Vaseline,
22+ Self::Spit,
23+ ];
24+
25+ pub fn content_types() -> [&'static str; 5] {
26+ Self::ALL.map(Self::content_type)
27+ }
28+
29+ fn content_type(self) -> &'static str {
30+ match self {
31+ Self::BabyOil => "application/baby-oil",
32+ Self::CoconutOil => "application/coconut-oil",
33+ Self::Lotion => "application/lotion",
34+ Self::Vaseline => "application/vaseline",
35+ Self::Spit => "text/plain; charset=spit",
36+ }
37+ }
38+
2539 pub fn from_content_type(content_type: Option<&str>) -> Result<Option<Self>, Problem> {
2640 let Some(raw) = content_type else {
2741 return Ok(None);
@@ -30,11 +44,10 @@ impl Lube {
3044 let mut parts = lowered.split(';').map(str::trim);
3145 let essence = parts.next().unwrap_or_default();
3246 let spit = parts.any(|p| p.replace(' ', "") == "charset=spit");
47+ if let Some(lube) = Self::ALL.into_iter().find(|l| l.content_type() == essence) {
48+ return Ok(Some(lube));
49+ }
3350 match essence {
34- "application/baby-oil" => Ok(Some(Self::BabyOil)),
35- "application/coconut-oil" => Ok(Some(Self::CoconutOil)),
36- "application/lotion" => Ok(Some(Self::Lotion)),
37- "application/vaseline" => Ok(Some(Self::Vaseline)),
3851 "text/plain" if spit => Ok(Some(Self::Spit)),
3952 e if e.starts_with("image/") || e.starts_with("video/") => Err(Problem::new(
4053 StatusCode::UNSUPPORTED_MEDIA_TYPE,
@@ -46,7 +59,7 @@ impl Lube {
4659 "unsupported-lubricant",
4760 format!("{essence} is not a lubricant."),
4861 )
49- .with("lubricants", json!(LUBRICANTS))),
62+ .with("lubricants", json!(Self::content_types()))),
5063 }
5164 }
5265
Modified file: src/game/mod.rs+2 additions−2 deletions
@@ -46,10 +46,10 @@ pub use household::HEARD_A_NOISE;
4646 pub use household::{Excuse, Household, MumVisit, vocabulary as excuse_vocabulary};
4747 pub use inventory::Items;
4848 pub use items::{Item, SHOP};
49-pub use lube::{LUBRICANTS, Lube};
49+pub use lube::Lube;
5050 pub use measure::{shaft, volume};
5151 pub use notice::Notice;
52-pub use outcome::{Outcome, Payload};
52+pub use outcome::{Choice, Outcome, Payload};
5353 pub use run::Run;
5454 pub use standing::Standing;
5555 pub use techniques::Technique;
Modified file: src/game/outcome.rs+28 additions−2 deletions
@@ -1,5 +1,7 @@
1-use axum::http::{HeaderName, StatusCode};
2-use serde_json::Value;
1+use axum::http::{HeaderName, StatusCode, header};
2+use serde_json::{Value, json};
3+
4+use crate::link::{self, Link};
35
46 pub enum Payload {
57 Empty,
@@ -7,6 +9,13 @@ pub enum Payload {
79 Raw(&'static str, Vec<u8>),
810 }
911
12+/// One way out of a 300, offered in the body and in `Link` alike.
13+pub struct Choice {
14+ pub(crate) href: String,
15+ pub(crate) method: &'static str,
16+ pub(crate) title: &'static str,
17+}
18+
1019 /// A successful result of a game action, before it becomes an HTTP response.
1120 pub struct Outcome {
1221 status: StatusCode,
@@ -37,6 +46,23 @@ impl Outcome {
3746 }
3847 }
3948
49+ pub fn choices(detail: &str, choices: &[Choice]) -> Self {
50+ let listed: Vec<Value> = choices
51+ .iter()
52+ .map(|c| json!({ "href": c.href, "method": c.method, "title": c.title }))
53+ .collect();
54+ let links = link::join(choices.iter().map(|c| {
55+ Link::registered(&c.href, "alternate")
56+ .title(c.title)
57+ .allow(c.method)
58+ }));
59+ Self::json(
60+ StatusCode::MULTIPLE_CHOICES,
61+ json!({ "detail": detail, "choices": listed }),
62+ )
63+ .header(header::LINK, links)
64+ }
65+
4066 pub fn header(mut self, name: HeaderName, value: impl Into<String>) -> Self {
4167 self.headers.push((name, value.into()));
4268 self
Modified file: src/game/rest.rs+1 additions−7 deletions
@@ -18,13 +18,7 @@ impl Run {
1818 if self.online.story == Story::Pending {
1919 return Ok(self.branch_offer());
2020 }
21- if self.items.kit.cage {
22- return Err(Problem::new(
23- StatusCode::LOCKED,
24- "caged",
25- "The cage is locked. The key is not in the box.",
26- ));
27- }
21+ self.check_cage()?;
2822 self.begin_turn()?;
2923 if self.oracle.chance_not(
3024 self.decision("rupture", "The pause is uneventful"),
Modified file: src/game/run.rs+14 additions−2 deletions
@@ -7,8 +7,8 @@ use axum::http::StatusCode;
77 use serde_json::{Value, json};
88
99 use super::{
10- Body, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice, Observer,
11- Online, RunStatus, Standing, Story,
10+ Body, Branch, Cause, Clinic, Ending, Floor, FloorState, Household, Item, Items, Notice,
11+ Observer, Online, RunStatus, Standing, Story,
1212 balance::{
1313 BRANCH_TURN, FLOOR_LIMIT, MAX_STAMINA, PRIAPISM_EQ, PULLED_STAMINA, REFRACTORY_STAMINA,
1414 SECOND_WIND, SELF_REPORT_INFLATION, STARTING_BABY_OIL, STARTING_EQ, STARTING_LUBE,
@@ -258,6 +258,18 @@ impl Run {
258258 format!("/runs/{}{sub}", self.ident.id)
259259 }
260260
261+ pub fn item_path(&self, item: Item) -> String {
262+ self.path(&format!("/inventory/{}", item.slug()))
263+ }
264+
265+ pub fn shop_path(&self, item: Item) -> String {
266+ self.path(&format!("/shop/{}", item.slug()))
267+ }
268+
269+ pub fn branch_path(&self, branch: Branch) -> String {
270+ self.path(&format!("/branches/{}", branch.slug()))
271+ }
272+
261273 pub fn current_length(&self) -> f64 {
262274 self.body.length + self.here.temp_length
263275 }
Modified file: src/game/shop.rs+5 additions−11 deletions
@@ -3,7 +3,7 @@ use std::fmt::Write as _;
33 use axum::http::{StatusCode, header};
44 use serde_json::{Value, json};
55
6-use super::{Item, Outcome, Run, balance::THIN_MARKUP, items::SHOP};
6+use super::{Item, Outcome, Run, balance::THIN_MARKUP, checks::caged, items::SHOP};
77 use crate::problem::Problem;
88
99 fn pump_bro_absent() -> Problem {
@@ -42,7 +42,7 @@ impl Run {
4242 "item": item.slug(),
4343 "price": self.price(*item)?,
4444 "description": item.pitch(),
45- "href": self.path(&format!("/shop/{}", item.slug())),
45+ "href": self.shop_path(*item),
4646 "method": "POST",
4747 }))
4848 })
@@ -109,10 +109,8 @@ impl Run {
109109 );
110110 }
111111 Ok(
112- Outcome::json(StatusCode::CREATED, json!({ "detail": detail })).header(
113- header::LOCATION,
114- self.path(&format!("/inventory/{}", item.slug())),
115- ),
112+ Outcome::json(StatusCode::CREATED, json!({ "detail": detail }))
113+ .header(header::LOCATION, self.item_path(item)),
116114 )
117115 }
118116
@@ -121,11 +119,7 @@ impl Run {
121119 Item::Extender => self.items.kit.extender = false,
122120 Item::Ring => self.items.kit.ring = false,
123121 Item::Cage => {
124- return Err(Problem::new(
125- StatusCode::LOCKED,
126- "caged",
127- "The cage is locked.",
128- ));
122+ return Err(caged());
129123 }
130124 _ => {
131125 return Err(Problem::new(
Modified file: src/http/leaderboard.rs+10 additions−4 deletions
@@ -7,9 +7,15 @@ use axum::{
77 };
88 use serde_json::{Value, json};
99
10-use super::{Shared, ctx::Ctx, run::records_unavailable};
10+use super::{
11+ Shared,
12+ ctx::Ctx,
13+ meta::{GRAVEYARD_PATH, LEADERBOARD_PATH},
14+ run::records_unavailable,
15+};
1116 use crate::{
1217 game::{balance::MM_PER_INCH, volume},
18+ link::Link,
1319 problem::{PROBLEM_BASE, Problem},
1420 records::{Ranking, Record},
1521 };
@@ -32,7 +38,7 @@ fn page(ctx: &Ctx) -> Result<usize, Problem> {
3238 fn paged(body: Value, path: &str, query: &str, page: usize, full: bool) -> Response {
3339 let link = |page: usize, rel: &str| {
3440 let separator = if query.is_empty() { "" } else { "&" };
35- format!("<{path}?{query}{separator}page={page}>; rel=\"{rel}\"")
41+ Link::registered(&format!("{path}?{query}{separator}page={page}"), rel).to_string()
3642 };
3743 let mut links = Vec::new();
3844 if page > 1 {
@@ -73,7 +79,7 @@ pub fn graveyard(state: &mut Shared, ctx: &Ctx) -> Response {
7379 }))
7480 })
7581 .collect();
76- paged(json!({ "runs": runs }), "/graveyard", "", page, full)
82+ paged(json!({ "runs": runs }), GRAVEYARD_PATH, "", page, full)
7783 }
7884
7985 fn ranking(state: &Shared, ctx: &Ctx) -> Result<Ranking, Problem> {
@@ -175,7 +181,7 @@ pub fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
175181 }
176182 paged(
177183 json!({ "entries": listed }),
178- "/leaderboard",
184+ LEADERBOARD_PATH,
179185 &query.join("&"),
180186 page,
181187 full,
Modified file: src/http/links.rs+33 additions−77 deletions
@@ -1,43 +1,12 @@
1-use std::fmt::Write as _;
2-
31 use super::{run::RunResource, runs};
4-use crate::game::{Branch, Run, SHOP};
5-
6-pub const REL_BASE: &str = "https://jelq.dev/rels/";
7-
8-/// One RFC 8288 link. `allow`, `lubricant` and `intensity` are extension attributes (§3), so a client never
9-/// has to guess what a target accepts.
10-struct Link<'a> {
11- href: &'a str,
12- rel: &'a str,
13- allow: &'a str,
14- title: Option<&'a str>,
15- lubricant: Option<&'a str>,
16- intensity: Option<&'a str>,
17-}
18-
19-impl Link<'_> {
20- fn write(&self, out: &mut String) {
21- if !out.is_empty() {
22- out.push_str(", ");
23- }
24- let _ = write!(out, "<{}>; rel=\"{REL_BASE}{}\"", self.href, self.rel);
25- if let Some(title) = self.title {
26- let _ = write!(out, "; title=\"{title}\"");
27- }
28- let _ = write!(out, "; allow=\"{}\"", self.allow);
29- if let Some(rule) = self.lubricant {
30- let _ = write!(out, "; lubricant=\"{rule}\"");
31- }
32- if let Some(range) = self.intensity {
33- let _ = write!(out, "; intensity=\"{range}\"");
34- }
35- }
36-}
2+use crate::{
3+ game::{Branch, Run, SHOP},
4+ link::Link,
5+};
376
387 pub fn header(run: &Run) -> String {
398 let reach = run.reach();
40- let mut out = String::new();
9+ let mut links = Vec::new();
4110 let resources: [(&str, &RunResource, bool); 15] = [
4211 ("", &runs::RUN, true),
4312 ("floor", &runs::FLOOR, true),
@@ -61,60 +30,47 @@ pub fn header(run: &Run) -> String {
6130 } else {
6231 run.path(&format!("/{rel}"))
6332 };
64- Link {
65- href: &href,
66- rel: if rel.is_empty() { "run" } else { rel },
67- allow: &target.resource.allow(run),
68- title: None,
69- lubricant: None,
70- intensity: None,
71- }
72- .write(&mut out);
33+ let rel = if rel.is_empty() { "run" } else { rel };
34+ links.push(
35+ Link::jelq(&href, rel)
36+ .allow(&target.resource.allow(run))
37+ .to_string(),
38+ );
7339 }
7440 if reach.branch_pending {
75- for (branch, title) in [
76- (Branch::Forum, "Read the forum"),
77- (Branch::CloseTab, "Close the tab"),
78- ] {
79- Link {
80- href: &run.path(&format!("/branches/{}", branch.slug())),
81- rel: "branch",
82- allow: "POST",
83- title: Some(title),
84- lubricant: None,
85- intensity: None,
86- }
87- .write(&mut out);
41+ for branch in Branch::ALL {
42+ links.push(
43+ Link::jelq(&run.branch_path(branch), "branch")
44+ .title(branch.title())
45+ .allow("POST")
46+ .to_string(),
47+ );
8848 }
8949 }
9050 let run_path = run.path("");
9151 for technique in run.techniques_now() {
9252 let name = technique.name();
93- Link {
94- href: &format!("{run_path}?technique={name}"),
95- rel: "technique",
96- allow: "JELQ",
97- title: Some(name),
98- lubricant: technique.lubricant(),
99- intensity: technique.intensity(),
100- }
101- .write(&mut out);
53+ links.push(
54+ Link::jelq(&format!("{run_path}?technique={name}"), "technique")
55+ .title(name)
56+ .allow("JELQ")
57+ .lubricant(technique.lubricant())
58+ .intensity(technique.intensity())
59+ .to_string(),
60+ );
10261 }
10362 if reach.shopping {
10463 for item in SHOP {
10564 let Some(price) = run.price(item) else {
10665 continue;
10766 };
108- Link {
109- href: &run.path(&format!("/shop/{}", item.slug())),
110- rel: "shop-item",
111- allow: "POST",
112- title: Some(&format!("{} for {price}", item.slug())),
113- lubricant: None,
114- intensity: None,
115- }
116- .write(&mut out);
67+ links.push(
68+ Link::jelq(&run.shop_path(item), "shop-item")
69+ .title(&format!("{} for {price}", item.slug()))
70+ .allow("POST")
71+ .to_string(),
72+ );
11773 }
11874 }
119- out
75+ links.join(", ")
12076 }
Modified file: src/http/meta.rs+5 additions−2 deletions
@@ -24,6 +24,9 @@ use crate::{
2424 pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);
2525 pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);
2626 pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);
27+pub const GRAVEYARD_PATH: &str = "/graveyard";
28+pub const LEADERBOARD_PATH: &str = "/leaderboard";
29+
2730 pub static GRAVEYARD: Resource<Shared> = Resource::new(&[Verb::new("GET", graveyard)]);
2831 pub static LEADERBOARD: Resource<Shared> = Resource::new(&[Verb::new("GET", leaderboard)]);
2932
@@ -43,8 +46,8 @@ fn index(_: &mut Shared, ctx: &Ctx) -> Response {
4346 Json(json!({
4447 "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",
4548 "start": { "href": "/runs", "method": "POST" },
46- "leaderboard": "/leaderboard",
47- "graveyard": "/graveyard",
49+ "leaderboard": LEADERBOARD_PATH,
50+ "graveyard": GRAVEYARD_PATH,
4851 "forum": FORUM_THREAD,
4952 "ui": "/ui/",
5053 })),
Modified file: src/http/mod.rs+2 additions−2 deletions
@@ -161,8 +161,8 @@ fn api(state: Shared) -> Router {
161161 .route("/runs/{id}/diagnosis", scoped(&runs::DIAGNOSIS))
162162 .route("/runs/{id}/footage", scoped(&runs::FOOTAGE))
163163 .route("/forum/threads/{year}", global(&meta::THREAD))
164- .route("/graveyard", global(&meta::GRAVEYARD))
165- .route("/leaderboard", global(&meta::LEADERBOARD))
164+ .route(meta::GRAVEYARD_PATH, global(&meta::GRAVEYARD))
165+ .route(meta::LEADERBOARD_PATH, global(&meta::LEADERBOARD))
166166 .fallback(meta::not_found)
167167 .layer(axum::middleware::from_fn(middleware::chrome))
168168 .layer(CatchPanicLayer::custom(middleware::panicked))
Modified file: src/http/parse.rs+5 additions−6 deletions
@@ -7,13 +7,13 @@ use crate::{
77 };
88
99 pub(super) fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, Problem> {
10+ let listed = |problem: Problem| problem.with("techniques", run.path("/techniques"));
1011 let required = || {
11- Problem::new(
12+ listed(Problem::new(
1213 StatusCode::BAD_REQUEST,
1314 "technique-required",
1415 "No technique was specified.",
15- )
16- .with("techniques", run.path("/techniques"))
16+ ))
1717 };
1818 let names = ctx
1919 .query("technique")
@@ -25,12 +25,11 @@ pub(super) fn parse_techniques(run: &Run, ctx: &Ctx) -> Result<Vec<Technique>, P
2525 .filter(|n| !n.is_empty())
2626 .map(|name| {
2727 Technique::parse(name).ok_or_else(|| {
28- Problem::new(
28+ listed(Problem::new(
2929 StatusCode::BAD_REQUEST,
3030 "unknown-technique",
3131 format!("{name} is not a technique."),
32- )
33- .with("techniques", run.path("/techniques"))
32+ ))
3433 })
3534 })
3635 .collect()
Modified file: src/http/run.rs+5 additions−6 deletions
@@ -8,7 +8,8 @@ use hyper_util::rt::TokioIo;
88 use tokio::io::AsyncWriteExt;
99
1010 use super::{
11- AppState, ctx::Ctx, links, middleware::Compress, registry::Lock, resource::Resource, shame,
11+ AppState, ctx::Ctx, links, meta::GRAVEYARD_PATH, middleware::Compress, registry::Lock,
12+ resource::Resource, shame,
1213 };
1314 use crate::{
1415 game::{
@@ -20,6 +21,7 @@ use crate::{
2021 X_GIRTH, X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA,
2122 X_SUSPICION, X_TURN,
2223 },
24+ link::Link,
2325 locale::Locale,
2426 problem::{PROBLEM_BASE, Problem},
2527 records::Record,
@@ -108,7 +110,7 @@ fn gone(cause: &str, turn: u32) -> Response {
108110 Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
109111 .with("cause", format!("{PROBLEM_BASE}{cause}"))
110112 .with("turn", turn)
111- .with("graveyard", "/graveyard")
113+ .with("graveyard", GRAVEYARD_PATH)
112114 .header(X_RUN_OVER, cause)
113115 .into_response()
114116 }
@@ -126,10 +128,7 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
126128 "The network requires authentication. The hotel would like you to agree to some terms.",
127129 )
128130 .with("portal", portal.as_str())
129- .header(
130- header::LINK,
131- format!("<{portal}>; rel=\"{}portal\"", links::REL_BASE),
132- )
131+ .header(header::LINK, Link::jelq(&portal, "portal").to_string())
133132 .into_response(),
134133 );
135134 }
Modified file: src/http/runs.rs+2 additions−5 deletions
@@ -17,10 +17,7 @@ use super::{
1717 shaft::{get_foreskin, get_length},
1818 };
1919 use crate::{
20- game::{
21- Branch, Cause, Floor, Grip, Item, LUBRICANTS, Lube, Run, Technique,
22- balance::VIEWS_PER_SECOND,
23- },
20+ game::{Branch, Cause, Floor, Grip, Item, Lube, Run, Technique, balance::VIEWS_PER_SECOND},
2421 problem::Problem,
2522 };
2623
@@ -156,7 +153,7 @@ fn get_techniques(run: &mut Run, _: &Ctx) -> Response {
156153 Json(json!({
157154 "usage": format!("JELQ {}?technique=<name>[,<name>]&intensity=<1-10>", run.path("")),
158155 "lubricant": "Declared with Content-Type.",
159- "lubricants": LUBRICANTS,
156+ "lubricants": Lube::content_types(),
160157 "techniques": listed,
161158 }))
162159 .into_response()
Modified file: src/http/shaft.rs+8 additions−15 deletions
@@ -13,6 +13,8 @@ use crate::{
1313 };
1414
1515 const SHAFT_CHUNK: usize = 16;
16+const SHAFT_TYPE: &str = "text/plain; charset=us-ascii";
17+const RANGE_UNIT: &str = "inches";
1618
1719 pub(super) fn millimetres(inches: f64) -> usize {
1820 (inches * MM_PER_INCH).round() as usize
@@ -72,11 +74,8 @@ pub(super) fn partial_inches(
7274 (
7375 StatusCode::PARTIAL_CONTENT,
7476 [
75- (
76- header::CONTENT_TYPE,
77- "text/plain; charset=us-ascii".to_owned(),
78- ),
79- (header::ACCEPT_RANGES, "inches".to_owned()),
77+ (header::CONTENT_TYPE, SHAFT_TYPE.to_owned()),
78+ (header::ACCEPT_RANGES, RANGE_UNIT.to_owned()),
8079 (
8180 header::CONTENT_RANGE,
8281 format!("inches {start:.2}-{end:.2}/{total:.2}"),
@@ -90,11 +89,8 @@ pub(super) fn partial_inches(
9089 pub(super) fn get_length(run: &mut Run, ctx: &Ctx) -> Response {
9190 let inches = run.shown_length();
9291 let body = shaft(millimetres(inches));
93- let content_type = (
94- header::CONTENT_TYPE,
95- "text/plain; charset=us-ascii".to_owned(),
96- );
97- let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
92+ let content_type = (header::CONTENT_TYPE, SHAFT_TYPE.to_owned());
93+ let accept_ranges = (header::ACCEPT_RANGES, RANGE_UNIT.to_owned());
9894
9995 if let Some(range) = ctx
10096 .header(header::RANGE)
@@ -145,11 +141,8 @@ pub(super) fn get_foreskin(run: &mut Run, ctx: &Ctx) -> Response {
145141 StatusCode::OK,
146142 [
147143 (header::ETAG, run.etag()),
148- (
149- header::CONTENT_TYPE,
150- "text/plain; charset=us-ascii".to_owned(),
151- ),
152- (header::ACCEPT_RANGES, "inches".to_owned()),
144+ (header::CONTENT_TYPE, SHAFT_TYPE.to_owned()),
145+ (header::ACCEPT_RANGES, RANGE_UNIT.to_owned()),
153146 ],
154147 body,
155148 )
Modified file: src/lib.rs+1 additions
@@ -2,6 +2,7 @@ mod config;
22 mod game;
33 mod headers;
44 mod http;
5+mod link;
56 mod locale;
67 mod problem;
78 mod records;
Added file: src/link.rs+94 additions
@@ -0,0 +1,94 @@
1+use std::fmt;
2+
3+pub const REL_BASE: &str = "https://jelq.dev/rels/";
4+
5+enum Rel<'a> {
6+ Jelq(&'a str),
7+ Registered(&'a str),
8+}
9+
10+/// One RFC 8288 link. `allow`, `lubricant` and `intensity` are extension attributes (§3), so a client never
11+/// has to guess what a target accepts.
12+pub struct Link<'a> {
13+ href: &'a str,
14+ rel: Rel<'a>,
15+ title: Option<&'a str>,
16+ allow: Option<&'a str>,
17+ lubricant: Option<&'a str>,
18+ intensity: Option<&'a str>,
19+}
20+
21+impl<'a> Link<'a> {
22+ fn new(href: &'a str, rel: Rel<'a>) -> Self {
23+ Self {
24+ href,
25+ rel,
26+ title: None,
27+ allow: None,
28+ lubricant: None,
29+ intensity: None,
30+ }
31+ }
32+
33+ /// A relation of this game's own, named under `REL_BASE`.
34+ pub fn jelq(href: &'a str, rel: &'a str) -> Self {
35+ Self::new(href, Rel::Jelq(rel))
36+ }
37+
38+ /// A relation from the IANA registry, such as `next` or `alternate`.
39+ pub fn registered(href: &'a str, rel: &'a str) -> Self {
40+ Self::new(href, Rel::Registered(rel))
41+ }
42+
43+ pub fn title(self, title: &'a str) -> Self {
44+ Self {
45+ title: Some(title),
46+ ..self
47+ }
48+ }
49+
50+ pub fn allow(self, allow: &'a str) -> Self {
51+ Self {
52+ allow: Some(allow),
53+ ..self
54+ }
55+ }
56+
57+ pub fn lubricant(self, lubricant: Option<&'a str>) -> Self {
58+ Self { lubricant, ..self }
59+ }
60+
61+ pub fn intensity(self, intensity: Option<&'a str>) -> Self {
62+ Self { intensity, ..self }
63+ }
64+}
65+
66+impl fmt::Display for Link<'_> {
67+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68+ write!(f, "<{}>; rel=\"", self.href)?;
69+ match self.rel {
70+ Rel::Jelq(rel) => write!(f, "{REL_BASE}{rel}\"")?,
71+ Rel::Registered(rel) => write!(f, "{rel}\"")?,
72+ }
73+ for (name, value) in [
74+ ("title", self.title),
75+ ("allow", self.allow),
76+ ("lubricant", self.lubricant),
77+ ("intensity", self.intensity),
78+ ] {
79+ if let Some(value) = value {
80+ write!(f, "; {name}=\"{value}\"")?;
81+ }
82+ }
83+ Ok(())
84+ }
85+}
86+
87+/// A `Link` header value listing every link in order.
88+pub fn join<'a>(links: impl IntoIterator<Item = Link<'a>>) -> String {
89+ links
90+ .into_iter()
91+ .map(|link| link.to_string())
92+ .collect::<Vec<_>>()
93+ .join(", ")
94+}
Modified file: src/ui.html+30 additions−36 deletions
@@ -53,32 +53,28 @@ pre, li, p, td, label { overflow-wrap: anywhere; }
5353 const REL = "https://jelq.dev/rels/";
5454 const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"];
5555 const ALL_LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
56-const DEATHS = ["ruptured", "caught", "opened", "banned", "degloved", "zip", "watermelon", "overstayed", "run-ended"];
5756 let stocked = null;
5857 let excuses = [];
5958 let nonce = null;
6059 let inventorySeq = 0;
6160 const STATS = [
62- ["x-turn", "Turn"],
63- ["x-floor-turn", "Floor turn"],
64- ["x-floor", "Floor"],
65- ["x-cash", "Cash"],
66- ["x-self-reported-length", "Self-reported length"],
67- ["x-girth", "Girth"],
68- ["x-foreskin", "Foreskin"],
69- ["x-friction", "Friction"],
70- ["x-eq", "EQ"],
71- ["x-stamina", "Stamina"],
72- ["x-suspicion", "Suspicion"],
73- ["x-shame", "Shame"],
61+ { header: "x-turn", label: "Turn" },
62+ { header: "x-floor-turn", label: "Floor turn" },
63+ { header: "x-floor", label: "Floor", better: 1 },
64+ { header: "x-cash", label: "Cash", better: 1 },
65+ { header: "x-self-reported-length", label: "Self-reported length", better: 1, places: 2, measured: { header: "x-length", label: "Length" } },
66+ { header: "x-girth", label: "Girth", better: 1, places: 2 },
67+ { header: "x-foreskin", label: "Foreskin", better: 1, places: 2 },
68+ { header: "x-friction", label: "Friction", better: -1 },
69+ { header: "x-eq", label: "EQ" },
70+ { header: "x-stamina", label: "Stamina", better: 1 },
71+ { header: "x-suspicion", label: "Suspicion", better: -1 },
72+ { header: "x-shame", label: "Shame", better: -1 },
7473 ];
75-const MEASURED = ["x-length", "Length"];
76-const GROWTH = new Set(["x-length", "x-self-reported-length", "x-girth", "x-foreskin"]);
77-const BETTER = { "x-length": 1, "x-self-reported-length": 1, "x-girth": 1, "x-foreskin": 1, "x-friction": -1, "x-stamina": 1, "x-suspicion": -1, "x-shame": -1, "x-cash": 1, "x-floor": 1 };
7874 const AROUSAL = ["Flaccid", "Cold", "Warm", "Ideal", "Swollen", "Throbbing", "Straining", "Imminent"];
7975 const DANGER_COLOURS = { dangerous: "#b8860b", critical: "red" };
8076 let lastStats = {};
81-const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0]), MEASURED[0]));
77+const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.flatMap(s => [s.header, s.measured?.header]).filter(Boolean)));
8278
8379 const FIELDS = {
8480 seed: { label: "seed (unranked)", kind: "text" },
@@ -219,18 +215,17 @@ function renderStats(headers) {
219215 const table = $("stats");
220216 table.replaceChildren();
221217 const dangers = Object.fromEntries((headers && headers["x-danger"] || "").split(",").filter(Boolean).map(d => d.trim().split("=")));
222- const stats = STATS.map(s => s[0] === "x-self-reported-length" && headers && MEASURED[0] in headers ? MEASURED : s);
218+ const stats = STATS.map(s => s.measured && headers && s.measured.header in headers ? { ...s, ...s.measured, unmeasured: s.header } : s);
223219 for (let i = 0; i < stats.length; i += 2) {
224220 const row = el("tr");
225- for (const [name, label] of stats.slice(i, i + 2)) {
226- const value = headers ? headers[name] : undefined;
221+ for (const stat of stats.slice(i, i + 2)) {
222+ const value = headers ? headers[stat.header] : undefined;
227223 const cell = el("td", {}, value === undefined ? "—" : value);
228- const changed = value === undefined ? null : change(name, value);
224+ const changed = value === undefined ? null : change(stat, value);
229225 if (changed) cell.append(" ", changed);
230- const measured = name === MEASURED[0] && "x-self-reported-length" in lastStats;
231- const danger = DANGER_COLOURS[dangers[name.slice(2)]];
232- const colour = danger || (measured ? "green" : "");
233- row.append(el("th", { align: "left", textContent: label, style: colour ? "color: " + colour : "" }), cell);
226+ const measured = stat.unmeasured in lastStats;
227+ const colour = DANGER_COLOURS[dangers[stat.header.slice(2)]] || (measured ? "green" : "");
228+ row.append(el("th", { align: "left", textContent: stat.label, style: colour ? "color: " + colour : "" }), cell);
234229 }
235230 table.append(row);
236231 }
@@ -241,18 +236,19 @@ function fromIdeal(arousal) {
241236 return arousal === "Priapic" ? AROUSAL.length : Math.abs(AROUSAL.indexOf(arousal) - AROUSAL.indexOf("Ideal"));
242237 }
243238
244-function change(name, value) {
245- const before = lastStats[name];
246- if (before === undefined || before === value || !(name in BETTER || name === "x-eq")) return null;
247- if (name === "x-eq") {
239+function change(stat, value) {
240+ const before = lastStats[stat.header];
241+ if (before === undefined || before === value) return null;
242+ if (stat.header === "x-eq") {
248243 const closer = Math.sign(fromIdeal(before) - fromIdeal(value));
249244 const was = el("b", { textContent: before, style: closer ? "color: " + (closer > 0 ? "green" : "red") : "" });
250245 return el("small", {}, "(was ", was, ")");
251246 }
247+ if (!stat.better) return null;
252248 const delta = parseFloat(value) - parseFloat(before);
253249 if (!(Math.abs(delta) >= 0.005)) return null;
254- const text = (delta > 0 ? "+" : "") + (GROWTH.has(name) ? delta.toFixed(2) : delta);
255- return el("b", { textContent: text, style: "color: " + (Math.sign(delta) === BETTER[name] ? "green" : "red") });
250+ const text = (delta > 0 ? "+" : "") + (stat.places ? delta.toFixed(stat.places) : delta);
251+ return el("b", { textContent: text, style: "color: " + (Math.sign(delta) === stat.better ? "green" : "red") });
256252 }
257253
258254 function renderAlerts(list) {
@@ -415,7 +411,7 @@ function describe(r) {
415411 return lines.join("\n");
416412 }
417413
418-function absorb(r, sentMethod, sentPath) {
414+function absorb(r, sentPath) {
419415 const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
420416 if (headers.etag) etags[sentPath] = headers.etag;
421417 const digest = (headers["www-authenticate"] || "").match(/nonce="([^"]*)"/);
@@ -423,7 +419,6 @@ function absorb(r, sentMethod, sentPath) {
423419 renderExcuses();
424420 let body = null;
425421 try { body = JSON.parse(r.body); } catch (e) {}
426- const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
427422 const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
428423
429424 if (started) {
@@ -457,8 +452,7 @@ function absorb(r, sentMethod, sentPath) {
457452 if (headers.location && !started) {
458453 offered.unshift({ key: "offered", group: "said", name: short(headers.location).split("/").pop() + " (Location)", path: headers.location, methods: ["GET"] });
459454 }
460- const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
461- if (run && (abandoned || DEATHS.includes(problem))) {
455+ if (run && "x-run-over" in headers) {
462456 log("--- the run has ended ---");
463457 renderAlerts(null);
464458 challenged = false;
@@ -522,7 +516,7 @@ async function go(event) {
522516 return;
523517 }
524518 log(sent + "\n" + describe(r));
525- absorb(r, method, path);
519+ absorb(r, path);
526520 }
527521
528522 $("form").onsubmit = go;