atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit d604371299fb

tarball

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

5 files changed+95-18
ui: group resources and only link the ones worth visiting

A flat list of every resource offered drawers that were already empty and a diagnosis that only answers 425 off the clinic floor. The server now links resources only when they would do something, and the page groups them by what they are for.
Modified file: src/game/floors.rs+5 additions
@@ -190,4 +190,9 @@ impl Run {
190190 )
191191 .header(header::LOCATION, self.path("")))
192192 }
193+
194+ /// Whether searching the drawer on this floor would turn anything up.
195+ pub fn drawer_worth_searching(&self) -> bool {
196+ self.here.drawer_searches == 0 || (self.floor == Floor::Nans && self.items.cage)
197+ }
193198 }
Modified file: src/http/resource.rs+10 additions−1 deletions
@@ -58,8 +58,17 @@ impl<T> Resource<T> {
5858 }
5959
6060 pub fn allow(&self, target: &T) -> String {
61+ Self::listed(self.verbs.iter().filter(|verb| verb.available(target)))
62+ }
63+
64+ /// The methods with every guard assumed open, for resources that have none.
65+ pub fn allow_any(&self) -> String {
66+ Self::listed(self.verbs.iter())
67+ }
68+
69+ fn listed<'a>(verbs: impl Iterator<Item = &'a Verb<T>>) -> String {
6170 let mut methods = Vec::new();
62- for verb in self.verbs.iter().filter(|verb| verb.available(target)) {
71+ for verb in verbs {
6372 methods.push(verb.method);
6473 if verb.method == "GET" {
6574 methods.push("HEAD");
Modified file: src/http/run.rs+22 additions−6 deletions
@@ -9,9 +9,9 @@ use axum::{
99 use hyper_util::rt::TokioIo;
1010 use tokio::io::AsyncWriteExt;
1111
12-use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource, runs};
12+use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs};
1313 use crate::{
14- game::{Floor, Run, balance::SUSPICION_LIMIT},
14+ game::{Elder, FORUM_THREAD, Floor, Run, balance::SUSPICION_LIMIT},
1515 headers::{
1616 X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_OBSERVED_BY, X_SELF_REPORTED_LENGTH, X_SHAME,
1717 X_STAMINA, X_SUSPICION,
@@ -101,13 +101,21 @@ fn links(run: &Run, now: Instant) -> String {
101101 ("foreskin", &runs::FORESKIN, run.foreskin.is_some()),
102102 ("inventory", &runs::INVENTORY, true),
103103 ("techniques", &runs::TECHNIQUES, true),
104- ("drawer", &runs::DRAWER, true),
104+ ("drawer", &runs::DRAWER, run.drawer_worth_searching()),
105105 ("grip", &runs::GRIP, true),
106106 ("shop", &runs::SHOP, run.floor.shop_open()),
107- ("portal", &runs::PORTAL, run.floor == Floor::Hotel),
108- ("elder", &runs::ELDER, run.floor == Floor::Forum),
107+ (
108+ "portal",
109+ &runs::PORTAL,
110+ run.floor == Floor::Hotel && !run.here.wifi_accepted,
111+ ),
112+ (
113+ "elder",
114+ &runs::ELDER,
115+ run.floor == Floor::Forum && run.elder != Elder::Beaten,
116+ ),
109117 ("footage", &runs::FOOTAGE, run.filmed_at.is_some()),
110- ("diagnosis", &runs::DIAGNOSIS, true),
118+ ("diagnosis", &runs::DIAGNOSIS, run.floor == Floor::Clinic),
111119 ];
112120 linked
113121 .into_iter()
@@ -121,6 +129,14 @@ fn links(run: &Run, now: Instant) -> String {
121129 let allow = target.resource.allow(run);
122130 format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
123131 })
132+ .chain(
133+ (run.forum_read() || run.floor.number() >= Floor::Forum.number()).then(|| {
134+ format!(
135+ "<{FORUM_THREAD}>; rel=\"{REL_BASE}thread\"; allow=\"{}\"",
136+ meta::THREAD.allow_any()
137+ )
138+ }),
139+ )
124140 .chain(run.techniques_now(now).into_iter().map(|technique| {
125141 let name = technique.name();
126142 let lubricant = technique
Modified file: src/tests.rs+30 additions
@@ -749,3 +749,33 @@ async fn links_offer_only_usable_techniques() {
749749 let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
750750 assert!(advertised(&response).is_empty());
751751 }
752+
753+fn linked_rels(response: &Response) -> Vec<String> {
754+ header(response, "link")
755+ .split(", <")
756+ .filter_map(|entry| {
757+ entry
758+ .split("rels/")
759+ .nth(1)?
760+ .split('"')
761+ .next()
762+ .map(str::to_owned)
763+ })
764+ .filter(|rel| rel != "technique")
765+ .collect()
766+}
767+
768+#[tokio::test]
769+async fn links_hide_resources_that_would_do_nothing() {
770+ let (bedroom, _) = seeded(Floor::Bedroom);
771+ let before = linked_rels(&send(&bedroom, "GET", "/runs/cafebabe", &[], "").await);
772+ assert!(before.contains(&"drawer".to_owned()));
773+ assert!(!before.contains(&"diagnosis".to_owned()));
774+ assert!(!before.contains(&"thread".to_owned()));
775+ let searched = send(&bedroom, "POST", "/runs/cafebabe/drawer", &[], "").await;
776+ assert!(!linked_rels(&searched).contains(&"drawer".to_owned()));
777+
778+ let (clinic, _) = seeded(Floor::Clinic);
779+ let rels = linked_rels(&send(&clinic, "GET", "/runs/cafebabe", &[], "").await);
780+ assert!(rels.contains(&"diagnosis".to_owned()) && rels.contains(&"thread".to_owned()));
781+}
Modified file: src/ui.html+28 additions−11 deletions
@@ -91,11 +91,19 @@ const BY_TARGET = {
9191 "grip GET": ["upgrade"],
9292 "diagnosis GET": ["authorization", "ifNoneMatch"],
9393 };
94+const GROUPS = [
95+ ["play", "Play"],
96+ ["you", "You"],
97+ ["said", "From the last response"],
98+ ["reading", "Reading"],
99+ ["meta", "Meta"],
100+];
101+const GROUP_OF = { run: "play", floor: "play", drawer: "play", shop: "play", portal: "play", elder: "play", diagnosis: "play", length: "you", foreskin: "you", grip: "you", inventory: "you", footage: "you", techniques: "reading", thread: "reading" };
102+const NAMES = { run: "the run", thread: "the 2009 forum thread" };
94103 const GLOBALS = [
95- { key: "runs", name: "start a new run", path: "/runs", methods: ["POST"] },
96- { key: "global", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
97- { key: "global", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
98- { key: "global", name: "the 2009 forum thread", path: "/forum/threads/2009", methods: ["GET", "HEAD", "OPTIONS"] },
104+ { key: "runs", group: "play", name: "start a new run", path: "/runs", methods: ["POST"] },
105+ { key: "global", group: "meta", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
106+ { key: "global", group: "meta", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
99107 ];
100108
101109 let run = null;
@@ -170,7 +178,10 @@ function allowedMethods(t) {
170178 }
171179
172180 function targets() {
173- return (run ? linked : []).concat(offered, GLOBALS);
181+ const listed = run ? linked : [];
182+ const known = new Set(listed.concat(GLOBALS).map(t => t.path));
183+ const fresh = offered.filter((t, i) => !known.has(t.path) && offered.findIndex(o => o.path === t.path) === i);
184+ return listed.concat(fresh, GLOBALS);
174185 }
175186
176187 function current() {
@@ -217,9 +228,15 @@ function renderAlerts(list) {
217228 function renderTargets() {
218229 const box = $("targets");
219230 box.replaceChildren();
220- for (const t of targets()) {
221- const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
222- box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
231+ const all = targets();
232+ for (const [group, title] of GROUPS) {
233+ const members = all.filter(t => t.group === group);
234+ if (!members.length) continue;
235+ box.append(el("h4", { textContent: title }));
236+ for (const t of members) {
237+ const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
238+ box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
239+ }
223240 }
224241 }
225242
@@ -295,7 +312,7 @@ function parseLinks(header) {
295312 continue;
296313 }
297314 const methods = (params.allow || "GET").split(",").map(s => s.trim());
298- resources.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
315+ resources.push({ key: name, group: GROUP_OF[name] || "play", name: NAMES[name] || name, path: match[1], methods });
299316 }
300317 return [resources, moves];
301318 }
@@ -304,7 +321,7 @@ function hrefs(value, found) {
304321 if (value && typeof value === "object") {
305322 if (typeof value.href === "string") {
306323 const name = value.title || value.item || value.name || value.href.split("/").pop();
307- found.push({ key: "offered", name, path: value.href, methods: [value.method || "GET"] });
324+ found.push({ key: "offered", group: "said", name, path: value.href, methods: [value.method || "GET"] });
308325 }
309326 for (const key in value) hrefs(value[key], found);
310327 }
@@ -356,7 +373,7 @@ function absorb(r, sentMethod, sentPath) {
356373 if (headers.link) [linked, techniques] = parseLinks(headers.link);
357374 offered = body ? hrefs(body, []) : [];
358375 if (headers.location && !started) {
359- offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
376+ offered.unshift({ key: "offered", group: "said", name: short(headers.location).split("/").pop() + " (Location)", path: headers.location, methods: ["GET"] });
360377 }
361378 const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
362379 if (run && (abandoned || DEATHS.includes(problem))) {