atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 185851447ceb

tarball

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

2 files changed+322-156
ui: rebuild the page around links, method radios, and a persistent log

Players had to guess which buttons did anything. Link entries now carry an allow attribute from the same method tables that produce Allow and 405, so the page greys out methods the server would refuse and shows only the request headers that matter for the chosen method.
Modified file: src/http/run.rs+32 additions−21 deletions
@@ -7,7 +7,7 @@ use axum::{
77 use hyper_util::rt::TokioIo;
88 use tokio::io::AsyncWriteExt;
99
10-use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource};
10+use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource, runs};
1111 use crate::{
1212 game::{Floor, Run, balance::SUSPICION_LIMIT},
1313 headers::{
@@ -20,17 +20,6 @@ use crate::{
2020
2121 pub const REL_BASE: &str = "https://jelq.dev/rels/";
2222 const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
23-const LINKED: [&str; 8] = [
24- "floor",
25- "length",
26- "foreskin",
27- "inventory",
28- "techniques",
29- "drawer",
30- "grip",
31- "diagnosis",
32-];
33-
3423 /// Which household hazards stand between a request and the run.
3524 #[derive(Clone, Copy, PartialEq, Eq)]
3625 pub enum Scope {
@@ -95,16 +84,38 @@ fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
9584 .map(IntoResponse::into_response)
9685 }
9786
87+/// Every resource the run can reach right now. The `allow` extension attribute (RFC 8288 §3)
88+/// carries the methods each one accepts, so a client never has to guess.
9889 fn links(run: &Run) -> String {
99- let mut links = vec![format!("<{}>; rel=\"{REL_BASE}run\"", run.path(""))];
100- let shop = run.floor.shop_open().then_some("shop");
101- for rel in LINKED.into_iter().chain(shop) {
102- links.push(format!(
103- "<{}>; rel=\"{REL_BASE}{rel}\"",
104- run.path(&format!("/{rel}"))
105- ));
106- }
107- links.join(", ")
90+ let linked: [(&str, &RunResource, bool); 13] = [
91+ ("", &runs::RUN, true),
92+ ("floor", &runs::FLOOR, true),
93+ ("length", &runs::LENGTH, true),
94+ ("foreskin", &runs::FORESKIN, run.foreskin.is_some()),
95+ ("inventory", &runs::INVENTORY, true),
96+ ("techniques", &runs::TECHNIQUES, true),
97+ ("drawer", &runs::DRAWER, true),
98+ ("grip", &runs::GRIP, true),
99+ ("shop", &runs::SHOP, run.floor.shop_open()),
100+ ("portal", &runs::PORTAL, run.floor == Floor::Hotel),
101+ ("elder", &runs::ELDER, run.floor == Floor::Forum),
102+ ("footage", &runs::FOOTAGE, run.filmed_at.is_some()),
103+ ("diagnosis", &runs::DIAGNOSIS, true),
104+ ];
105+ linked
106+ .into_iter()
107+ .filter(|(_, _, relevant)| *relevant)
108+ .map(|(rel, target, _)| {
109+ let (path, name) = if rel.is_empty() {
110+ (run.path(""), "run")
111+ } else {
112+ (run.path(&format!("/{rel}")), rel)
113+ };
114+ let allow = target.resource.allow(run);
115+ format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"")
116+ })
117+ .collect::<Vec<_>>()
118+ .join(", ")
108119 }
109120
110121 /// Shame lives in a signed cookie so it follows the player across runs. Each response adds this
Modified file: src/ui.html+290 additions−135 deletions
@@ -3,170 +3,325 @@
33 <head>
44 <meta charset="utf-8">
55 <title>jelqd</title>
6-<style>pre { white-space: pre-wrap; } pre, li, p { overflow-wrap: anywhere; }</style>
6+<style>
7+.frame { border: 2px inset; background: #fff; height: 85vh; overflow: scroll; padding: 4px; }
8+#log { white-space: pre-wrap; margin: 0; }
9+pre, li, p, td, label { overflow-wrap: anywhere; }
10+</style>
711 </head>
812 <body>
913
10-<h1 id="status">jelqd</h1>
11-<p id="detail">A roguelike played over HTTP. Press POST /runs to begin.</p>
12-<ul id="links"></ul>
13-<ul id="headers"></ul>
14-<pre id="body"></pre>
15-
16-<hr>
17-
18-<h2>Run</h2>
19-<p>
20-Run <input id="run" size="20" placeholder="/runs/...">
21-seed <input id="seed" size="12" placeholder="(unranked)">
22-<button onclick="start()">POST /runs</button>
23-</p>
24-
25-<h2>JELQ</h2>
26-<p>
27-technique
28-<select id="technique">
29-<option>warmup</option>
30-<option>wet</option>
31-<option>dry</option>
32-<option>stretch</option>
33-<option>uli</option>
34-<option>edge</option>
35-<option>pump</option>
36-<option>ice</option>
37-<option>supplement</option>
38-</select>
39-or combo <input id="combo" size="14" placeholder="wet,stretch">
40-intensity <input id="intensity" type="number" min="1" max="10" value="5" size="3">
41-Content-Type
42-<select id="lube">
43-<option value="">(none)</option>
44-<option>application/baby-oil</option>
45-<option>application/coconut-oil</option>
46-<option>application/lotion</option>
47-<option>application/vaseline</option>
48-<option>text/plain; charset=spit</option>
49-<option>image/jpeg</option>
50-</select>
51-<button onclick="jelq()">JELQ</button>
52-</p>
53-
54-<h2>Methods</h2>
55-<ul>
56-<li>the run: <button onclick="go('GET', '')">GET</button> <button onclick="go('HEAD', '')">HEAD</button> <button onclick="go('PUT', '')">PUT</button> <button onclick="go('OPTIONS', '')">OPTIONS</button> <button onclick="go('TRACE', '')">TRACE</button> <button onclick="go('DELETE', '')">DELETE</button></li>
57-<li>/floor: <button onclick="go('GET', '/floor')">GET</button> <button onclick="go('POST', '/floor')">POST</button></li>
58-<li>/drawer: <button onclick="go('POST', '/drawer')">POST</button></li>
59-<li>/inventory: <button onclick="go('GET', '/inventory')">GET</button> item <input id="item" size="16"> <button onclick="go('GET', '/inventory/' + val('item'))">GET</button> <button onclick="go('DELETE', '/inventory/' + val('item'))">DELETE</button></li>
60-<li>/techniques: <button onclick="go('GET', '/techniques')">GET</button></li>
61-<li>/length: <button onclick="go('GET', '/length')">GET</button> <button onclick="go('HEAD', '/length')">HEAD</button></li>
62-<li>/foreskin: <button onclick="go('GET', '/foreskin')">GET</button> <button onclick="go('DELETE', '/foreskin')">DELETE</button></li>
63-<li>/branches: <button onclick="go('POST', '/branches/forum')">POST forum</button> <button onclick="go('POST', '/branches/close-tab')">POST close-tab</button></li>
64-<li>/shop: <button onclick="go('GET', '/shop')">GET</button> item <input id="buy" size="16"> <button onclick="go('POST', '/shop/' + val('buy'))">POST</button></li>
65-<li>/portal: <button onclick="go('GET', '/portal')">GET</button> <button onclick="go('POST', '/portal')">POST</button></li>
66-<li>/elder: <button onclick="go('GET', '/elder')">GET</button></li>
67-<li>/grip: <button onclick="go('GET', '/grip')">GET</button></li>
68-<li>/diagnosis: <button onclick="go('GET', '/diagnosis')">GET</button></li>
69-<li>PATCH the run, as application/json-patch+json:<br>
70-<textarea id="patch" rows="2" cols="60">[{"op": "test", "path": "/length", "value": 6}]</textarea>
71-<button onclick="go('PATCH', '', [['Content-Type', 'application/json-patch+json']], val('patch'))">PATCH</button></li>
72-<li>anything: method <input id="method" size="8" value="GET"> path <input id="path" size="30" value="/leaderboard"> <button onclick="send(val('method'), val('path'))">Send</button></li>
73-</ul>
74-
75-<h2>Headers</h2>
76-<p>Sent with every request when filled in.</p>
77-<ul>
78-<li>Authorization <input id="authorization" size="50" placeholder='Excuse "homework"'></li>
79-<li>Proxy-Authorization <input id="proxy-authorization" size="30" placeholder="Ruler 5.4"></li>
80-<li>If-Match <input id="if-match" size="24"></li>
81-<li>If-None-Match <input id="if-none-match" size="24"></li>
82-<li>If-Modified-Since <input id="if-modified-since" size="32"></li>
83-<li>Range <input id="range" size="16" placeholder="inches=0-2"></li>
84-<li>Accept <input id="accept" size="30"></li>
85-<li>Accept-Encoding <input id="accept-encoding" size="10"></li>
86-<li>Expect <input id="expect" size="16"></li>
87-<li>Upgrade <input id="upgrade" size="16" placeholder="underhand"></li>
88-</ul>
89-
90-<hr>
91-<p><a href="/leaderboard">/leaderboard</a> <a href="/graveyard">/graveyard</a> <a href="/forum/threads/2009">/forum/threads/2009</a></p>
14+<h1>jelqd</h1>
15+<p>A roguelike played over HTTP. Pick a resource, pick a method, send it. The log on the right is everything the server has said to you.</p>
16+
17+<table width="100%" cellspacing="6">
18+<tr>
19+<td width="45%" valign="top">
20+<div class="frame">
21+
22+<form id="form">
23+<h3>Method</h3>
24+<div id="methods"></div>
25+
26+<h3>Request</h3>
27+<table id="fields" cellpadding="2"></table>
28+
29+<p><input type="submit" id="send" value="Send" style="font-size: 1.3em"></p>
30+
31+<h3>Stats</h3>
32+<table id="stats" border="1" cellspacing="0" cellpadding="3"></table>
33+
34+<h3>Resource</h3>
35+<div id="targets"></div>
36+</form>
37+
38+<p><small>Every request goes through <code>/ui/send</code>, which replays it inside the server, so the page can show redirects and send methods and headers that browsers refuse.</small></p>
39+</div>
40+</td>
41+<td valign="top">
42+<div class="frame" id="frame"><pre id="log"></pre></div>
43+</td>
44+</tr>
45+</table>
9246
9347 <script>
94-const HEADERS = ["Authorization", "Proxy-Authorization", "If-Match", "If-None-Match", "If-Modified-Since", "Range", "Accept", "Accept-Encoding", "Expect", "Upgrade"];
48+const REL = "https://jelq.dev/rels/";
49+const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
50+const TECHNIQUES = ["warmup", "wet", "dry", "stretch", "uli", "edge", "pump", "ice", "supplement"];
51+const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zipper", "run-ended"];
52+const STATS = [
53+ ["x-floor", "Floor"],
54+ ["x-self-reported-length", "Self-reported length"],
55+ ["x-girth", "Girth"],
56+ ["x-foreskin", "Foreskin"],
57+ ["x-eq", "EQ (aim for 50 to 70)"],
58+ ["x-stamina", "Stamina"],
59+ ["x-suspicion", "Suspicion"],
60+ ["x-shame", "Shame"],
61+ ["x-cash", "Cash"],
62+ ["x-observed-by", "Being watched by"],
63+ ["via", "At the door"],
64+];
65+const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0])));
9566
96-function val(id) {
97- return document.getElementById(id).value.trim();
67+const FIELDS = {
68+ seed: { label: "seed (unranked)", kind: "text" },
69+ technique: { label: "technique", kind: "select", options: TECHNIQUES },
70+ also: { label: "combo with", kind: "select", options: ["", "wet", "dry", "stretch", "uli"] },
71+ intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
72+ 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+ patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
74+ accept: { label: "Accept", header: "Accept", kind: "text" },
75+ range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
76+ ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
77+ ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
78+ ifModifiedSince: { label: "If-Modified-Since", header: "If-Modified-Since", kind: "text", placeholder: "an HTTP-date" },
79+ proxy: { label: "Proxy-Authorization", header: "Proxy-Authorization", kind: "text", placeholder: "Ruler 5.4" },
80+ upgrade: { label: "Upgrade", header: "Upgrade", kind: "select", options: ["", "underhand", "overhand"] },
81+ authorization: { label: "Authorization", header: "Authorization", kind: "text", placeholder: 'Excuse "homework"' },
82+ extra: { label: "other headers, one per line", kind: "textarea", placeholder: "Expect: gains" },
83+};
84+const BY_TARGET = {
85+ "runs POST": ["seed"],
86+ "run JELQ": ["technique", "also", "intensity", "lube"],
87+ "run GET": ["accept"],
88+ "run PATCH": ["patch"],
89+ "length GET": ["range"],
90+ "length HEAD": ["range"],
91+ "foreskin DELETE": ["ifMatch"],
92+ "elder GET": ["proxy", "ifModifiedSince"],
93+ "grip GET": ["upgrade"],
94+ "diagnosis GET": ["ifNoneMatch"],
95+};
96+const ALWAYS = ["authorization", "extra"];
97+const GLOBALS = [
98+ { key: "runs", name: "start a new run", path: "/runs", methods: ["POST"] },
99+ { key: "global", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
100+ { key: "global", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
101+ { key: "global", name: "the 2009 forum thread", path: "/forum/threads/2009", methods: ["GET", "HEAD", "OPTIONS"] },
102+];
103+
104+let run = null;
105+let linked = [];
106+let offered = [];
107+let target = "/runs";
108+let method = "POST";
109+const values = {};
110+
111+const $ = id => document.getElementById(id);
112+const fieldValue = name => values[name] ?? FIELDS[name].value ?? (FIELDS[name].options ? FIELDS[name].options[0] : "");
113+
114+function short(path) {
115+ return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
98116 }
99117
100-function put(id, text) {
101- document.getElementById(id).textContent = text;
118+function el(tag, props, ...children) {
119+ const node = Object.assign(document.createElement(tag), props || {});
120+ for (const child of children) node.append(child);
121+ return node;
102122 }
103123
104-function item(list, text, onclick) {
105- const li = document.createElement("li");
106- if (onclick) {
107- const button = document.createElement("button");
108- button.textContent = text;
109- button.onclick = onclick;
110- li.appendChild(button);
111- } else {
112- li.textContent = text;
124+function targets() {
125+ return (run ? linked : []).concat(offered, GLOBALS);
126+}
127+
128+function current() {
129+ return targets().find(t => t.path === target) || GLOBALS[0];
130+}
131+
132+function chooseTarget(t) {
133+ target = t.path;
134+ method = t.methods.includes("JELQ") ? "JELQ" : t.methods.find(m => m !== "HEAD" && m !== "OPTIONS") || t.methods[0];
135+ render();
136+}
137+
138+function renderStats(headers) {
139+ const table = $("stats");
140+ table.replaceChildren();
141+ for (let i = 0; i < STATS.length; i += 2) {
142+ const row = el("tr");
143+ for (const [name, label] of STATS.slice(i, i + 2)) {
144+ const value = headers ? headers[name] : undefined;
145+ row.append(el("th", { align: "left", textContent: label }), el("td", { textContent: value === undefined ? "—" : value }));
146+ }
147+ table.append(row);
113148 }
114- document.getElementById(list).appendChild(li);
149+}
150+
151+function renderTargets() {
152+ const box = $("targets");
153+ box.replaceChildren();
154+ for (const t of targets()) {
155+ const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
156+ box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
157+ }
158+}
159+
160+function renderMethods() {
161+ const box = $("methods");
162+ box.replaceChildren();
163+ const allowed = current().methods;
164+ for (const m of METHODS) {
165+ const radio = el("input", { type: "radio", name: "method", disabled: !allowed.includes(m), checked: m === method, onchange: () => { method = m; render(); } });
166+ const label = el("label", {}, radio, " ");
167+ label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
168+ box.append(label, " ");
169+ }
170+}
171+
172+function fieldNames() {
173+ const key = current().key + " " + method;
174+ return (BY_TARGET[key] || []).concat(ALWAYS);
175+}
176+
177+function renderFields() {
178+ const table = $("fields");
179+ table.replaceChildren();
180+ for (const name of fieldNames()) {
181+ const f = FIELDS[name];
182+ const value = fieldValue(name);
183+ let input;
184+ if (f.kind === "select") {
185+ input = el("select", {}, ...f.options.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
186+ } else if (f.kind === "textarea") {
187+ input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
188+ } else {
189+ input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });
190+ }
191+ input.oninput = input.onchange = () => { values[name] = input.value; };
192+ table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
193+ }
194+ $("send").value = method + " " + short(current().path);
195+}
196+
197+function render() {
198+ if (!current().methods.includes(method)) return chooseTarget(current());
199+ renderTargets();
200+ renderMethods();
201+ renderFields();
202+}
203+
204+function parseLinks(header) {
205+ const found = [];
206+ for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
207+ const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
208+ const name = (params.rel || "").replace(REL, "");
209+ const methods = (params.allow || "GET").split(",").map(s => s.trim());
210+ found.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
211+ }
212+ return found;
115213 }
116214
117215 function hrefs(value, found) {
118216 if (value && typeof value === "object") {
119- if (typeof value.href === "string") found.push([value.method || "GET", value.href]);
217+ if (typeof value.href === "string") {
218+ const name = value.title || value.item || value.name || value.href.split("/").pop();
219+ found.push({ key: "offered", name, path: value.href, methods: [value.method || "GET"] });
220+ }
120221 for (const key in value) hrefs(value[key], found);
121222 }
122223 return found;
123224 }
124225
125-async function send(method, path, extra, body) {
126- const headers = HEADERS.map(name => [name, val(name.toLowerCase())]).filter(h => h[1]);
127- if (val("upgrade")) headers.push(["Connection", "upgrade"]);
128- const response = await fetch("/ui/send", {
129- method: "POST",
130- headers: {"Content-Type": "application/json"},
131- body: JSON.stringify({method, path, headers: headers.concat(extra || []), body: body || ""}),
132- });
133- show(await response.json(), path);
226+function log(text) {
227+ $("log").append(text + "\n");
228+ $("frame").scrollTop = $("frame").scrollHeight;
134229 }
135230
136-function show(r, path) {
137- put("status", r.status + " " + (r.reason || ""));
138- let parsed = null;
139- try { parsed = JSON.parse(r.body); } catch (e) {}
140- put("detail", parsed && parsed.detail ? parsed.detail : "");
141- put("body", r.body);
142- document.getElementById("headers").replaceChildren();
143- document.getElementById("links").replaceChildren();
144- for (const [name, value] of r.headers) item("headers", name + ": " + value);
145- const location = r.headers.find(h => h[0] === "location");
146- if (location && path === "/runs" && r.status === 201) document.getElementById("run").value = location[1];
147- if (location) item("links", "GET " + location[1], () => send("GET", location[1]));
148- for (const [method, href] of hrefs(parsed, [])) item("links", method + " " + href, () => send(method, href));
231+function describe(r) {
232+ const lines = ["< " + r.status + " " + r.reason];
233+ for (const [name, value] of r.headers) {
234+ if (!QUIET.has(name)) lines.push("< " + name + ": " + short(value));
235+ }
236+ let body = null;
237+ try { body = JSON.parse(r.body); } catch (e) {}
238+ if (body && typeof body === "object") {
239+ const rest = Object.assign({}, body);
240+ if (rest.detail) lines.push(" " + rest.detail);
241+ for (const key of ["type", "title", "status", "detail"]) delete rest[key];
242+ if (Object.keys(rest).length) lines.push(short(JSON.stringify(rest, null, 2)).replace(/^/gm, " "));
243+ } else if (r.body) {
244+ lines.push(" " + r.body);
245+ }
246+ return lines.join("\n");
149247 }
150248
151-function go(method, sub, extra, body) {
152- if (!val("run")) {
153- put("status", "No run");
154- put("detail", "Press POST /runs first.");
155- return;
249+function absorb(r, sentMethod, sentPath) {
250+ const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
251+ let body = null;
252+ try { body = JSON.parse(r.body); } catch (e) {}
253+ const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
254+
255+ if (sentPath.startsWith("/runs?") || sentPath === "/runs") {
256+ if (r.status === 201) {
257+ run = headers.location;
258+ log("--- a new run has started at " + short(run) + " ---");
259+ }
260+ }
261+ if ("x-eq" in headers) renderStats(headers);
262+ if (headers.link) linked = parseLinks(headers.link);
263+ offered = body ? hrefs(body, []) : [];
264+ if (headers.location && !(sentPath === "/runs" || sentPath.startsWith("/runs?"))) {
265+ offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
266+ }
267+ const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
268+ if (run && (abandoned || DEATHS.includes(problem))) {
269+ log("--- the run has ended ---");
270+ run = null;
271+ linked = [];
272+ }
273+ const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
274+ if (started || !targets().some(t => t.path === target)) {
275+ const home = linked.find(t => t.key === "run");
276+ if (home) chooseTarget(home); else chooseTarget(GLOBALS[0]);
277+ } else {
278+ render();
156279 }
157- send(method, val("run") + sub, extra, body);
158280 }
159281
160-function start() {
161- send("POST", val("seed") ? "/runs?seed=" + encodeURIComponent(val("seed")) : "/runs");
162-}
282+async function go(event) {
283+ event.preventDefault();
284+ const t = current();
285+ const key = t.key + " " + method;
286+ const value = name => fieldValue(name).trim();
287+ const headers = [];
288+ let path = t.path;
289+ let body = "";
290+ for (const name of fieldNames()) {
291+ const f = FIELDS[name];
292+ if (f.header && value(name)) headers.push([f.header, value(name)]);
293+ }
294+ if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
295+ for (const line of value("extra").split("\n")) {
296+ const at = line.indexOf(":");
297+ if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
298+ }
299+ if (key === "run JELQ") {
300+ const techniques = value("also") ? value("technique") + "," + value("also") : value("technique");
301+ path += "?technique=" + encodeURIComponent(techniques) + "&intensity=" + encodeURIComponent(value("intensity"));
302+ }
303+ if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
304+ if (method === "PATCH") {
305+ headers.push(["Content-Type", "application/json-patch+json"]);
306+ body = value("patch");
307+ }
163308
164-function jelq() {
165- const technique = val("combo") || val("technique");
166- const query = "?technique=" + encodeURIComponent(technique) + "&intensity=" + encodeURIComponent(val("intensity"));
167- const lube = val("lube");
168- go("JELQ", query, lube ? [["Content-Type", lube]] : []);
309+ log("> " + method + " " + short(path));
310+ for (const [name, v] of headers) log("> " + name + ": " + v);
311+ const response = await fetch("/ui/send", {
312+ method: "POST",
313+ headers: { "Content-Type": "application/json" },
314+ body: JSON.stringify({ method, path, headers, body }),
315+ });
316+ const r = await response.json();
317+ log(describe(r) + "\n");
318+ absorb(r, method, path);
169319 }
320+
321+$("form").onsubmit = go;
322+renderStats(null);
323+render();
324+log("Welcome. Choose \"start a new run\" and send POST to begin.\n");
170325 </script>
171326 </body>
172327 </html>