an http based roguelike rpg about jelqing
Commit 185851447ceb
tarball2 files changed+322-156
@@ -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
+ linked106
+ .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@@ -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
-technique28
-<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-Type42
-<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>