atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 58e1fb3943e6

tarball

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

5 files changed+229-83
ui: offer only the moves the run can make right now

The page offered every technique and greyed out refused methods, so players tried things the server would only refuse. The server now advertises each usable technique as its own Link entry with its lubricant rule, and the page drops everything else instead of disabling it.
Modified file: src/game/techniques.rs+18 additions−5 deletions
@@ -92,6 +92,16 @@ pub enum LubeRule {
9292 Optional,
9393 }
9494
95+impl LubeRule {
96+ fn name(self) -> &'static str {
97+ match self {
98+ Self::Required => "required",
99+ Self::Forbidden => "forbidden",
100+ Self::Optional => "optional",
101+ }
102+ }
103+}
104+
95105 pub struct Profile {
96106 pub dc: i32,
97107 pub length: f64,
@@ -196,6 +206,13 @@ impl Technique {
196206 Self::ALL.into_iter().find(|t| t.name() == name)
197207 }
198208
209+ pub fn lubricant(self) -> Option<&'static str> {
210+ match self {
211+ Self::Training(training) => Some(training.profile().lube.name()),
212+ Self::Utility(_) => None,
213+ }
214+ }
215+
199216 pub fn describe(self, forum_read: bool) -> Value {
200217 let training = match self {
201218 Self::Utility(utility) => {
@@ -204,11 +221,7 @@ impl Technique {
204221 Self::Training(training) => training,
205222 };
206223 let profile = training.profile();
207- let lubricant = match profile.lube {
208- LubeRule::Required => "required",
209- LubeRule::Forbidden => "forbidden",
210- LubeRule::Optional => "optional",
211- };
224+ let lubricant = profile.lube.name();
212225 json!({
213226 "name": training.name(),
214227 "dc": profile.dc,
Modified file: src/game/turn.rs+64 additions−38 deletions
@@ -176,6 +176,15 @@ impl Run {
176176 }
177177
178178 fn ready(&self, technique: Technique, lube: Option<Lube>) -> Result<(), Problem> {
179+ self.access(technique)?;
180+ match technique {
181+ Technique::Training(training) => Self::check_lube(training, lube),
182+ Technique::Utility(_) => Ok(()),
183+ }
184+ }
185+
186+ /// Whether the run could attempt the technique now, ignoring the lubricant.
187+ fn access(&self, technique: Technique) -> Result<(), Problem> {
179188 if technique == Technique::Training(Training::Uli) && !self.forum_read() {
180189 return Err(Problem::new(
181190 StatusCode::FORBIDDEN,
@@ -191,48 +200,65 @@ impl Run {
191200 )
192201 .with("technique", Utility::Warmup.name()));
193202 }
194- let training = match technique {
195- Technique::Utility(Utility::Pump) if !self.items.pump_pro => {
196- return Err(Problem::new(
197- StatusCode::FAILED_DEPENDENCY,
198- "no-pump",
199- "The pump technique depends on a pump.",
200- ));
201- }
202- Technique::Utility(_) => return Ok(()),
203- Technique::Training(training) => training,
204- };
205- let profile = training.profile();
206- match (profile.lube, lube) {
207- (LubeRule::Required, None) => {
208- return Err(Problem::new(
209- StatusCode::UNSUPPORTED_MEDIA_TYPE,
210- "lubricant-required",
211- format!(
212- "The {} technique requires a lubricant. None was declared.",
213- training.name()
214- ),
215- )
216- .with("lubricants", json!(LUBRICANTS)));
217- }
218- (LubeRule::Forbidden, Some(_)) => {
219- return Err(Problem::new(
220- StatusCode::UNSUPPORTED_MEDIA_TYPE,
221- "lubricant-declared",
222- format!(
223- "The {} technique was requested. A lubricant was declared.",
224- training.name()
225- ),
226- ));
227- }
228- _ => {}
203+ match technique {
204+ Technique::Utility(Utility::Pump) if !self.items.pump_pro => Err(Problem::new(
205+ StatusCode::FAILED_DEPENDENCY,
206+ "no-pump",
207+ "The pump technique depends on a pump.",
208+ )),
209+ Technique::Utility(_) => Ok(()),
210+ Technique::Training(training) => match training.profile().foreskin {
211+ Some((min, max)) => self.check_foreskin(min, max),
212+ None => Ok(()),
213+ },
229214 }
230- match profile.foreskin {
231- Some((min, max)) => self.check_foreskin(min, max),
232- None => Ok(()),
215+ }
216+
217+ fn check_lube(training: Training, lube: Option<Lube>) -> Result<(), Problem> {
218+ match (training.profile().lube, lube) {
219+ (LubeRule::Required, None) => Err(Problem::new(
220+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
221+ "lubricant-required",
222+ format!(
223+ "The {} technique requires a lubricant. None was declared.",
224+ training.name()
225+ ),
226+ )
227+ .with("lubricants", json!(LUBRICANTS))),
228+ (LubeRule::Forbidden, Some(_)) => Err(Problem::new(
229+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
230+ "lubricant-declared",
231+ format!(
232+ "The {} technique was requested. A lubricant was declared.",
233+ training.name()
234+ ),
235+ )),
236+ _ => Ok(()),
233237 }
234238 }
235239
240+ /// The techniques a JELQ could use right now without being refused before the roll.
241+ pub fn techniques_now(&self, now: Instant) -> Vec<Technique> {
242+ let blocked = !self.floor.timed()
243+ || self.items.cage
244+ || self.story == Story::Pending
245+ || self.refractory_until.is_some_and(|until| now < until);
246+ if blocked {
247+ return Vec::new();
248+ }
249+ let rested = self.refractory_until.is_some();
250+ Technique::ALL
251+ .into_iter()
252+ .filter(|&technique| match technique {
253+ Technique::Utility(Utility::Warmup) => true,
254+ Technique::Utility(Utility::Ice) => self.items.ice_packs > 0,
255+ Technique::Utility(Utility::Supplement) => self.items.supplements > 0,
256+ _ if rested => false,
257+ _ => self.access(technique).is_ok() && self.check_storage().is_ok(),
258+ })
259+ .collect()
260+ }
261+
236262 fn check_foreskin(&self, min: f64, max: f64) -> Result<(), Problem> {
237263 let Some(f) = self.foreskin else {
238264 if min <= 0.0 {
Modified file: src/http/run.rs+16 additions−4 deletions
@@ -1,5 +1,7 @@
11 //! Plumbing shared by every resource under `/runs/{id}`.
22
3+use std::time::Instant;
4+
35 use axum::{
46 http::{HeaderName, HeaderValue, StatusCode, header},
57 response::{IntoResponse, Response},
@@ -88,9 +90,9 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
8890 .map(IntoResponse::into_response)
8991 }
9092
91-/// Every resource the run can reach right now. The `allow` extension attribute (RFC 8288 §3)
92-/// carries the methods each one accepts, so a client never has to guess.
93-fn links(run: &Run) -> String {
93+/// Every resource the run can reach right now, then every technique a JELQ could use. The `allow`
94+/// and `lubricant` extension attributes (RFC 8288 §3) say what each accepts, so a client never guesses.
95+fn links(run: &Run, now: Instant) -> String {
9496 let linked: [(&str, &RunResource, bool); 13] = [
9597 ("", &runs::RUN, true),
9698 ("floor", &runs::FLOOR, true),
@@ -118,6 +120,16 @@ fn links(run: &Run) -> String {
118120 let allow = target.resource.allow(run);
119121 format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
120122 })
123+ .chain(run.techniques_now(now).into_iter().map(|technique| {
124+ let name = technique.name();
125+ let lubricant = technique
126+ .lubricant()
127+ .map_or(String::new(), |rule| format!("; lubricant=\"{rule}\""));
128+ format!(
129+ "<{}?technique={name}>; rel=\"{REL_BASE}technique\"; title=\"{name}\"; allow=\"JELQ\"{lubricant}",
130+ run.path("")
131+ )
132+ }))
121133 .collect::<Vec<_>>()
122134 .join(", ")
123135 }
@@ -157,7 +169,7 @@ pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response)
157169 (X_SHAME, shame.to_string()),
158170 (X_CASH, run.cash.to_string()),
159171 (X_FLOOR, run.floor.number().to_string()),
160- (header::LINK, links(run)),
172+ (header::LINK, links(run, ctx.now)),
161173 ];
162174 if let Some(observer) = run.observed {
163175 stats.push((X_OBSERVED_BY, observer.name().into()));
Modified file: src/tests.rs+34 additions
@@ -715,3 +715,37 @@ async fn the_default_locale_is_british() {
715715 assert!(header(&floor, "vary").contains("Accept-Language"));
716716 assert_eq!(json(floor).await["name"], "Nan's house at Christmas");
717717 }
718+
719+fn advertised(response: &Response) -> Vec<String> {
720+ header(response, "link")
721+ .split(", <")
722+ .filter(|entry| entry.contains("rels/technique"))
723+ .filter_map(|entry| {
724+ entry
725+ .split("title=\"")
726+ .nth(1)?
727+ .split('"')
728+ .next()
729+ .map(str::to_owned)
730+ })
731+ .collect()
732+}
733+
734+#[tokio::test]
735+async fn links_offer_only_usable_techniques() {
736+ let app = seeded_with(Floor::Bedroom, |run| run.foreskin = Some(1.0));
737+ let cold = send(&app, "GET", "/runs/cafebabe", &[], "").await;
738+ assert_eq!(advertised(&cold), ["warmup", "ice"]);
739+ let warm = send(&app, "JELQ", "/runs/cafebabe?technique=warmup", &[], "").await;
740+ let usable = advertised(&warm);
741+ assert!(usable.contains(&"wet".to_owned()) && usable.contains(&"dry".to_owned()));
742+ assert!(!usable.contains(&"uli".to_owned()), "uli needs the forum");
743+ assert!(!usable.contains(&"pump".to_owned()), "pump needs a pump");
744+ assert!(
745+ header(&warm, "link").contains("title=\"wet\"; allow=\"JELQ\"; lubricant=\"required\"")
746+ );
747+
748+ let (forum, _) = seeded(Floor::Forum);
749+ let response = send(&forum, "GET", "/runs/cafebabe", &[], "").await;
750+ assert!(advertised(&response).is_empty());
751+}
Modified file: src/ui.html+97 additions−36 deletions
@@ -50,7 +50,7 @@ Language <select id="locale"><option>en-GB</option><option>en-US</option><option
5050 <script>
5151 const REL = "https://jelq.dev/rels/";
5252 const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
53-const TECHNIQUES = ["warmup", "wet", "dry", "stretch", "uli", "edge", "pump", "ice", "supplement"];
53+const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
5454 const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];
5555 const STATS = [
5656 ["x-floor", "Floor"],
@@ -67,12 +67,11 @@ const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-co
6767
6868 const FIELDS = {
6969 seed: { label: "seed (unranked)", kind: "text" },
70- technique: { label: "technique", kind: "select", options: TECHNIQUES },
71- also: { label: "combo with", kind: "select", options: ["", "wet", "dry", "stretch", "uli"] },
70+ technique: { label: "technique", kind: "select" },
71+ also: { label: "combo with", kind: "select" },
7272 intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
73- lube: { label: "Content-Type", header: "Content-Type", kind: "select", options: ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"] },
73+ lube: { label: "Content-Type", header: "Content-Type", kind: "select" },
7474 patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
75- accept: { label: "Accept", header: "Accept", kind: "text" },
7675 range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
7776 ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
7877 ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
@@ -84,7 +83,6 @@ const FIELDS = {
8483 };
8584 const BY_TARGET = {
8685 "runs POST": ["seed"],
87- "run JELQ": ["technique", "also", "intensity", "lube"],
8886 "run PATCH": ["patch"],
8987 "length GET": ["range"],
9088 "length HEAD": ["range"],
@@ -102,6 +100,7 @@ const GLOBALS = [
102100
103101 let run = null;
104102 let linked = [];
103+let techniques = [];
105104 let offered = [];
106105 let challenged = false;
107106 let target = "/runs";
@@ -109,7 +108,6 @@ let method = "POST";
109108 const values = {};
110109
111110 const $ = id => document.getElementById(id);
112-const fieldValue = name => values[name] ?? FIELDS[name].value ?? (FIELDS[name].options ? FIELDS[name].options[0] : "");
113111
114112 function short(path) {
115113 return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
@@ -121,6 +119,56 @@ function el(tag, props, ...children) {
121119 return node;
122120 }
123121
122+function titled(text) {
123+ return text.charAt(0).toUpperCase() + text.slice(1);
124+}
125+
126+function technique(name) {
127+ return techniques.find(t => t.name === name);
128+}
129+
130+function clashes(a, b) {
131+ const rules = [a.lubricant, b.lubricant];
132+ return rules.includes("required") && rules.includes("forbidden");
133+}
134+
135+// The options a field can take right now, or null for free text.
136+function options(name) {
137+ if (name === "technique") return techniques.map(t => t.name);
138+ if (name === "also") {
139+ const first = technique(fieldValue("technique"));
140+ if (!first || !first.lubricant) return [""];
141+ const partners = techniques.filter(t => t.lubricant && t.name !== first.name && !clashes(first, t));
142+ return [""].concat(partners.map(t => t.name));
143+ }
144+ if (name === "lube") return lubeRule() === "required" ? LUBES.filter(Boolean) : LUBES;
145+ return FIELDS[name].options || null;
146+}
147+
148+function raw(name) {
149+ return values[name] ?? FIELDS[name].value ?? "";
150+}
151+
152+function fieldValue(name) {
153+ const allowed = options(name);
154+ const value = raw(name);
155+ if (allowed && !allowed.includes(value)) return allowed[0] ?? "";
156+ return value;
157+}
158+
159+function lubeRule() {
160+ const picked = [technique(fieldValue("technique")), technique(fieldValue("also"))].filter(Boolean);
161+ if (!picked.length || !picked[0].lubricant) return "none";
162+ for (const rule of ["forbidden", "required"]) {
163+ if (picked.some(t => t.lubricant === rule)) return rule;
164+ }
165+ return "optional";
166+}
167+
168+function allowedMethods(t) {
169+ return t.key === "run" && !techniques.length ? t.methods.filter(m => m !== "JELQ") : t.methods;
170+}
171+
124172 function targets() {
125173 return (run ? linked : []).concat(offered, GLOBALS);
126174 }
@@ -130,8 +178,9 @@ function current() {
130178 }
131179
132180 function chooseTarget(t) {
181+ const allowed = allowedMethods(t);
133182 target = t.path;
134- method = t.methods.includes("JELQ") ? "JELQ" : t.methods.find(m => m !== "HEAD" && m !== "OPTIONS") || t.methods[0];
183+ method = allowed.includes("JELQ") ? "JELQ" : allowed.find(m => m !== "HEAD" && m !== "OPTIONS") || allowed[0];
135184 render();
136185 }
137186
@@ -148,10 +197,6 @@ function renderStats(headers) {
148197 }
149198 }
150199
151-function titled(text) {
152- return text.charAt(0).toUpperCase() + text.slice(1);
153-}
154-
155200 function renderAlerts(list) {
156201 const box = $("alerts");
157202 box.replaceChildren();
@@ -179,9 +224,9 @@ function renderTargets() {
179224 function renderMethods() {
180225 const box = $("methods");
181226 box.replaceChildren();
182- const allowed = current().methods;
183- for (const m of METHODS) {
184- const radio = el("input", { type: "radio", name: "method", disabled: !allowed.includes(m), checked: m === method, onchange: () => { method = m; render(); } });
227+ const allowed = allowedMethods(current());
228+ for (const m of METHODS.filter(m => allowed.includes(m))) {
229+ const radio = el("input", { type: "radio", name: "method", checked: m === method, onchange: () => { method = m; render(); } });
185230 const label = el("label", {}, radio, " ");
186231 label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
187232 box.append(label, " ");
@@ -190,7 +235,15 @@ function renderMethods() {
190235
191236 function fieldNames() {
192237 const t = current();
193- const names = BY_TARGET[t.key + " " + method] || [];
238+ const key = t.key + " " + method;
239+ let names = BY_TARGET[key] || [];
240+ if (key === "run JELQ") {
241+ const chosen = technique(fieldValue("technique"));
242+ names = ["technique"];
243+ if (options("also").length > 1) names.push("also");
244+ if (chosen && chosen.lubricant) names.push("intensity");
245+ if (["required", "optional"].includes(lubeRule())) names.push("lube");
246+ }
194247 const scoped = run && !["runs", "global"].includes(t.key);
195248 const asked = challenged && scoped && !names.includes("authorization");
196249 return (asked ? ["authorization"] : []).concat(names);
@@ -202,18 +255,20 @@ function renderFields() {
202255 for (const name of fieldNames()) {
203256 const f = FIELDS[name];
204257 const value = fieldValue(name);
258+ const choices = options(name);
205259 let input;
206- if (f.kind === "select") {
207- input = el("select", {}, ...f.options.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
260+ if (choices) {
261+ input = el("select", {}, ...choices.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
208262 } else if (f.kind === "textarea") {
209263 input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
210264 } else {
211265 input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });
212266 }
213- input.oninput = input.onchange = () => { values[name] = input.value; };
267+ input.oninput = () => { values[name] = input.value; };
268+ input.onchange = () => { values[name] = input.value; if (choices) renderFields(); };
214269 table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
215270 }
216- const extra = el("textarea", { rows: 3, cols: 40, value: fieldValue("extra"), placeholder: FIELDS.extra.placeholder });
271+ const extra = el("textarea", { rows: 3, cols: 40, value: raw("extra"), placeholder: FIELDS.extra.placeholder });
217272 extra.oninput = () => { values.extra = extra.value; };
218273 const more = el("details", { open: Boolean(values.extra) }, el("summary", { textContent: "more headers" }), extra);
219274 table.append(el("tr", {}, el("td"), el("td", {}, more)));
@@ -221,21 +276,26 @@ function renderFields() {
221276 }
222277
223278 function render() {
224- if (!current().methods.includes(method)) return chooseTarget(current());
279+ if (!allowedMethods(current()).includes(method)) return chooseTarget(current());
225280 renderTargets();
226281 renderMethods();
227282 renderFields();
228283 }
229284
230285 function parseLinks(header) {
231- const found = [];
286+ const resources = [];
287+ const moves = [];
232288 for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
233289 const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
234290 const name = (params.rel || "").replace(REL, "");
291+ if (name === "technique") {
292+ moves.push({ name: params.title, lubricant: params.lubricant || null });
293+ continue;
294+ }
235295 const methods = (params.allow || "GET").split(",").map(s => s.trim());
236- found.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
296+ resources.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
237297 }
238- return found;
298+ return [resources, moves];
239299 }
240300
241301 function hrefs(value, found) {
@@ -277,21 +337,20 @@ function absorb(r, sentMethod, sentPath) {
277337 let body = null;
278338 try { body = JSON.parse(r.body); } catch (e) {}
279339 const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
340+ const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
280341
281- if (sentPath.startsWith("/runs?") || sentPath === "/runs") {
282- if (r.status === 201) {
283- run = headers.location;
284- log("--- a new run has started at " + short(run) + " ---");
285- }
342+ if (started) {
343+ run = headers.location;
344+ log("--- a new run has started at " + short(run) + " ---");
286345 }
287346 if ("x-eq" in headers) {
288347 renderStats(headers);
289348 renderAlerts(r.headers);
290349 challenged = r.status === 401 || "via" in headers;
291350 }
292- if (headers.link) linked = parseLinks(headers.link);
351+ if (headers.link) [linked, techniques] = parseLinks(headers.link);
293352 offered = body ? hrefs(body, []) : [];
294- if (headers.location && !(sentPath === "/runs" || sentPath.startsWith("/runs?"))) {
353+ if (headers.location && !started) {
295354 offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
296355 }
297356 const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
@@ -301,11 +360,11 @@ function absorb(r, sentMethod, sentPath) {
301360 challenged = false;
302361 run = null;
303362 linked = [];
363+ techniques = [];
304364 }
305- const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
306365 if (started || !targets().some(t => t.path === target)) {
307366 const home = linked.find(t => t.key === "run");
308- if (home) chooseTarget(home); else chooseTarget(GLOBALS[0]);
367+ chooseTarget(home || GLOBALS[0]);
309368 } else {
310369 render();
311370 }
@@ -324,13 +383,15 @@ async function go(event) {
324383 if (f.header && value(name)) headers.push([f.header, value(name)]);
325384 }
326385 if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
327- for (const line of value("extra").split("\n")) {
386+ for (const line of raw("extra").split("\n")) {
328387 const at = line.indexOf(":");
329388 if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
330389 }
331390 if (key === "run JELQ") {
332- const techniques = value("also") ? value("technique") + "," + value("also") : value("technique");
333- path += "?technique=" + encodeURIComponent(techniques) + "&intensity=" + encodeURIComponent(value("intensity"));
391+ const names = fieldNames();
392+ const chosen = [value("technique")].concat(names.includes("also") && value("also") ? [value("also")] : []);
393+ path += "?technique=" + encodeURIComponent(chosen.join(","));
394+ if (names.includes("intensity")) path += "&intensity=" + encodeURIComponent(value("intensity"));
334395 }
335396 if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
336397 if (method === "PATCH") {