atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
f3a1913c1299571fbfcb552007761bae715c4434
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/ui.htmlHtml536 lines21.1 KB
1
<!DOCTYPE html>
2
<html>
3
<head>
4
<meta charset="utf-8">
5
<title>jelqd</title>
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>
11
</head>
12
<body>
13
14
<h1>jelqd</h1>
15
<p>A roguelike played over HTTP.</p>
16
<p>Pick a resource, pick a method, send it.<br>The log on the right is everything the server has said to you.</p>
17
<p>Language <select id="locale"><option>en-GB</option><option>en-US</option><option>en-AU</option></select></p>
18
19
<table width="100%" cellspacing="6">
20
<tr>
21
<td width="45%" valign="top">
22
<div class="frame">
23
24
<div id="alerts"></div>
25
26
<form id="form">
27
<h3>Method</h3>
28
<div id="methods"></div>
29
<datalist id="excuses"></datalist>
30
31
<h3>Request</h3>
32
<table id="fields" cellpadding="2"></table>
33
34
<p><input type="submit" id="send" value="Send" style="font-size: 1.3em"></p>
35
36
<h3>Stats</h3>
37
<table id="stats" border="1" cellspacing="0" cellpadding="3"></table>
38
39
<h3>Resource</h3>
40
<div id="targets"></div>
41
</form>
42
43
<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>
44
</div>
45
</td>
46
<td valign="top">
47
<div class="frame" id="frame"><pre id="log"></pre></div>
48
</td>
49
</tr>
50
</table>
51
52
<script>
53
const REL = "https://jelq.dev/rels/";
54
const METHODS = ["JELQ", "HOLD", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"];
55
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"];
57
let stocked = null;
58
let excuses = [];
59
let nonce = null;
60
let inventorySeq = 0;
61
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"],
74
];
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 };
78
const AROUSAL = ["Flaccid", "Cold", "Warm", "Ideal", "Swollen", "Throbbing", "Straining", "Imminent"];
79
const DANGER_COLOURS = { dangerous: "#b8860b", critical: "red" };
80
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]));
82
83
const FIELDS = {
84
  seed: { label: "seed (unranked)", kind: "text" },
85
  technique: { label: "technique", kind: "select" },
86
  also: { label: "combo with", kind: "select" },
87
  intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
88
  lube: { label: "Content-Type", header: "Content-Type", kind: "select" },
89
  range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
90
  ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
91
  ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
92
  ifModifiedSince: { label: "If-Modified-Since", header: "If-Modified-Since", kind: "text", placeholder: "an HTTP-date" },
93
  proxy: { label: "Proxy-Authorization", header: "Proxy-Authorization", kind: "text", placeholder: "Ruler 5.4" },
94
  upgrade: { label: "Upgrade", header: "Upgrade", kind: "select", options: ["", "underhand", "overhand"] },
95
  authorization: { label: "Authorization", header: "Authorization", kind: "text", placeholder: 'Excuse "homework"' },
96
  extra: { label: "other headers, one per line", kind: "textarea", placeholder: "Expect: gains" },
97
};
98
const BY_TARGET = {
99
  "runs POST": ["seed"],
100
  "length GET": ["range"],
101
  "length HEAD": ["range"],
102
  "foreskin DELETE": ["ifMatch"],
103
  "elder GET": ["proxy", "ifModifiedSince"],
104
  "grip GET": ["upgrade"],
105
  "diagnosis GET": ["authorization", "ifNoneMatch"],
106
};
107
const GROUPS = [
108
  ["play", "Play"],
109
  ["shop", "Shop"],
110
  ["you", "You"],
111
  ["said", "From the last response"],
112
  ["reading", "Reading"],
113
  ["meta", "Meta"],
114
];
115
const GROUP_OF = { run: "play", floor: "play", drawer: "play", shop: "play", "shop-item": "shop", kitchen: "play", portal: "play", elder: "play", diagnosis: "play", length: "you", foreskin: "you", grip: "you", inventory: "you", footage: "you", techniques: "reading", thread: "reading" };
116
const NAMES = { run: "the run", thread: "the 2009 forum thread" };
117
const GLOBALS = [
118
  { key: "runs", group: "play", name: "start a new run", path: "/runs", methods: ["POST"] },
119
  { key: "global", group: "meta", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
120
  { key: "global", group: "meta", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
121
];
122
123
let run = null;
124
let linked = [];
125
let techniques = [];
126
let offered = [];
127
let known = new Set();
128
let fresh = new Set();
129
let challenged = false;
130
const etags = {};
131
let target = "/runs";
132
let method = "POST";
133
const values = {};
134
135
const $ = id => document.getElementById(id);
136
137
function short(path) {
138
  return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
139
}
140
141
function el(tag, props, ...children) {
142
  const node = Object.assign(document.createElement(tag), props || {});
143
  for (const child of children) node.append(child);
144
  return node;
145
}
146
147
function titled(text) {
148
  return text.charAt(0).toUpperCase() + text.slice(1);
149
}
150
151
function technique(name) {
152
  return techniques.find(t => t.name === name);
153
}
154
155
function clashes(a, b) {
156
  const rules = [a.lubricant, b.lubricant];
157
  return rules.includes("required") && rules.includes("forbidden");
158
}
159
160
// The options a field can take right now, or null for free text.
161
function options(name) {
162
  if (name === "technique") return techniques.map(t => t.name);
163
  if (name === "also") {
164
    const first = technique(fieldValue("technique"));
165
    if (!first || !first.intensity) return [""];
166
    const partners = techniques.filter(t => t.intensity && t.name !== first.name && !clashes(first, t));
167
    return [""].concat(partners.map(t => t.name));
168
  }
169
  if (name === "lube") {
170
    const lubes = stocked === null ? ALL_LUBES : ALL_LUBES.filter(l => l === "" || l.includes("/") && !l.startsWith("application/") || stocked.includes(l.replace("application/", "")));
171
    return lubeRule() === "required" ? lubes.filter(Boolean) : lubes;
172
  }
173
  return FIELDS[name].options || null;
174
}
175
176
function raw(name) {
177
  return values[name] ?? FIELDS[name].value ?? "";
178
}
179
180
function fieldValue(name) {
181
  const allowed = options(name);
182
  const value = raw(name);
183
  if (allowed && !allowed.includes(value)) return allowed[0] ?? "";
184
  return value;
185
}
186
187
function lubeRule() {
188
  const picked = [technique(fieldValue("technique")), technique(fieldValue("also"))].filter(Boolean);
189
  if (!picked.some(t => t.lubricant)) return "none";
190
  for (const rule of ["forbidden", "required"]) {
191
    if (picked.some(t => t.lubricant === rule)) return rule;
192
  }
193
  return "optional";
194
}
195
196
function allowedMethods(t) {
197
  return t.key === "run" && !techniques.length ? t.methods.filter(m => m !== "JELQ") : t.methods;
198
}
199
200
function targets() {
201
  const listed = run ? linked : [];
202
  const known = new Set(listed.concat(GLOBALS).map(t => t.path));
203
  const fresh = offered.filter((t, i) => !known.has(t.path) && offered.findIndex(o => o.path === t.path) === i);
204
  return listed.concat(fresh, GLOBALS);
205
}
206
207
function current() {
208
  return targets().find(t => t.path === target) || GLOBALS[0];
209
}
210
211
function chooseTarget(t) {
212
  const allowed = allowedMethods(t);
213
  target = t.path;
214
  method = allowed.includes("JELQ") ? "JELQ" : allowed.find(m => m !== "HEAD" && m !== "OPTIONS") || allowed[0];
215
  render();
216
}
217
218
function renderStats(headers) {
219
  const table = $("stats");
220
  table.replaceChildren();
221
  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);
223
  for (let i = 0; i < stats.length; i += 2) {
224
    const row = el("tr");
225
    for (const [name, label] of stats.slice(i, i + 2)) {
226
      const value = headers ? headers[name] : undefined;
227
      const cell = el("td", {}, value === undefined ? "—" : value);
228
      const changed = value === undefined ? null : change(name, value);
229
      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);
234
    }
235
    table.append(row);
236
  }
237
  lastStats = headers || {};
238
}
239
240
function fromIdeal(arousal) {
241
  return arousal === "Priapic" ? AROUSAL.length : Math.abs(AROUSAL.indexOf(arousal) - AROUSAL.indexOf("Ideal"));
242
}
243
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") {
248
    const closer = Math.sign(fromIdeal(before) - fromIdeal(value));
249
    const was = el("b", { textContent: before, style: closer ? "color: " + (closer > 0 ? "green" : "red") : "" });
250
    return el("small", {}, "(was ", was, ")");
251
  }
252
  const delta = parseFloat(value) - parseFloat(before);
253
  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") });
256
}
257
258
function renderAlerts(list) {
259
  const box = $("alerts");
260
  box.replaceChildren();
261
  const alerts = [];
262
  let door = null;
263
  for (const [name, value] of list || []) {
264
    if (name === "via") door = titled(value.split(" ").pop()) + " is at the door.";
265
    if (name === "x-observed-by") alerts.push((value === "cat" ? "The cat" : titled(value)) + " is watching.");
266
    if (name === "warning") {
267
      const text = (value.match(/"([^"]*)"/) || [, value])[1];
268
      alerts.push(titled(text) + (/[.!?]$/.test(text) ? "" : "."));
269
    }
270
  }
271
  if (door && !alerts.some(a => a.startsWith(door))) alerts.unshift(door);
272
  const stamina = (list || []).find(([name]) => name === "x-stamina");
273
  if (stamina && stamina[1].startsWith("0/")) alerts.push("Exhausted. HOLD for the refractory period.");
274
  for (const alert of alerts) box.append(el("h2", { textContent: alert }));
275
}
276
277
function renderTargets() {
278
  const box = $("targets");
279
  box.replaceChildren();
280
  const all = targets();
281
  for (const [group, title] of GROUPS) {
282
    const members = all.filter(t => t.group === group);
283
    if (!members.length) continue;
284
    box.append(el("h4", { textContent: title }));
285
    for (const t of members) {
286
      const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
287
      const name = fresh.has(t.path) ? el("b", { textContent: t.name }) : t.name;
288
      box.append(el("label", {}, radio, " ", name, " ", el("small", { textContent: short(t.path) })), el("br"));
289
    }
290
  }
291
}
292
293
function renderMethods() {
294
  const box = $("methods");
295
  box.replaceChildren();
296
  const allowed = allowedMethods(current());
297
  for (const m of METHODS.filter(m => allowed.includes(m))) {
298
    const radio = el("input", { type: "radio", name: "method", checked: m === method, onchange: () => { method = m; render(); } });
299
    const label = el("label", {}, radio, " ");
300
    label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
301
    box.append(label, " ");
302
  }
303
}
304
305
function fieldNames() {
306
  const t = current();
307
  const key = t.key + " " + method;
308
  let names = BY_TARGET[key] || [];
309
  if (key === "run JELQ") {
310
    const chosen = technique(fieldValue("technique"));
311
    names = ["technique"];
312
    if (options("also").length > 1) names.push("also");
313
    if (chosen && chosen.intensity) names.push("intensity");
314
    if (["required", "optional"].includes(lubeRule())) names.push("lube");
315
  }
316
  const scoped = run && !["runs", "global"].includes(t.key);
317
  const asked = challenged && scoped && !names.includes("authorization");
318
  return (asked ? ["authorization"] : []).concat(names);
319
}
320
321
function renderExcuses() {
322
  const said = excuses.concat(["the truth"]);
323
  $("excuses").replaceChildren(...said.map(e => el("option", {
324
    value: nonce ? 'Digest username="' + e + '", nonce="' + nonce + '"' : 'Excuse "' + e + '"',
325
  })));
326
}
327
328
function renderFields() {
329
  const table = $("fields");
330
  table.replaceChildren();
331
  for (const name of fieldNames()) {
332
    const f = FIELDS[name];
333
    let value = fieldValue(name);
334
    if (name === "ifMatch" && !value) value = values.ifMatch = etags[current().path] || "";
335
    const choices = options(name);
336
    let input;
337
    if (choices) {
338
      const isNew = o => ["technique", "also"].includes(name) && fresh.has("technique " + o);
339
      input = el("select", {}, ...choices.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value, style: isNew(o) ? "font-weight: bold" : "" })));
340
    } else if (f.kind === "textarea") {
341
      input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
342
    } else {
343
      const hint = name === "authorization" && nonce ? 'Digest username="helping Nan", nonce="' + nonce + '"' : f.placeholder || "";
344
      input = el("input", { type: f.kind, value, placeholder: hint, size: 30 });
345
    }
346
    input.oninput = () => { values[name] = input.value; };
347
    input.onchange = () => { values[name] = input.value; if (choices) renderFields(); };
348
    if (name === "authorization") input.setAttribute("list", "excuses");
349
    table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
350
  }
351
  const extra = el("textarea", { rows: 3, cols: 40, value: raw("extra"), placeholder: FIELDS.extra.placeholder });
352
  extra.oninput = () => { values.extra = extra.value; };
353
  const more = el("details", { open: Boolean(values.extra) }, el("summary", { textContent: "more headers" }), extra);
354
  table.append(el("tr", {}, el("td"), el("td", {}, more)));
355
  $("send").value = method + " " + short(current().path);
356
}
357
358
function render() {
359
  if (!allowedMethods(current()).includes(method)) return chooseTarget(current());
360
  renderTargets();
361
  renderMethods();
362
  renderFields();
363
}
364
365
function parseLinks(header) {
366
  const resources = [];
367
  const moves = [];
368
  for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
369
    const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
370
    const name = (params.rel || "").replace(REL, "");
371
    if (name === "technique") {
372
      moves.push({ name: params.title, lubricant: params.lubricant || null, intensity: params.intensity || null });
373
      continue;
374
    }
375
    const methods = (params.allow || "GET").split(",").map(s => s.trim());
376
    resources.push({ key: name, group: GROUP_OF[name] || "play", name: NAMES[name] || params.title || name, path: match[1], methods });
377
  }
378
  return [resources, moves];
379
}
380
381
function hrefs(value, found) {
382
  if (value && typeof value === "object") {
383
    if (typeof value.href === "string") {
384
      const name = value.title || value.item || value.name || value.href.split("/").pop();
385
      found.push({ key: "offered", group: "said", name, path: value.href, methods: [value.method || "GET"] });
386
    }
387
    for (const key in value) hrefs(value[key], found);
388
  }
389
  return found;
390
}
391
392
function log(text) {
393
  $("log").prepend(text + "\n\n");
394
  $("frame").scrollTop = 0;
395
}
396
397
function describe(r) {
398
  const lines = ["< " + r.status + " " + r.reason];
399
  for (const [name, value] of r.headers) {
400
    if (!QUIET.has(name)) lines.push("< " + name + ": " + short(value));
401
  }
402
  let body = null;
403
  try { body = JSON.parse(r.body); } catch (e) {}
404
  if (body && typeof body === "object") {
405
    const rest = Object.assign({}, body);
406
    if (rest.detail) lines.push("  " + rest.detail);
407
    for (const key of ["type", "title", "status", "detail"]) delete rest[key];
408
    if (Object.keys(rest).length) lines.push(short(JSON.stringify(rest, null, 2)).replace(/^/gm, "  "));
409
  } else if (r.body) {
410
    const range = (r.headers.find(h => h[0] === "content-range") || [])[1] || "";
411
    const partial = r.status === 206 && range.match(/^bytes (\d+)-(\d+)\/(\d+)$/);
412
    lines.push("  " + r.body);
413
    if (partial) lines.push("  [bytes " + partial[1] + "-" + partial[2] + " of " + partial[3] + ". Half-mast.]");
414
  }
415
  return lines.join("\n");
416
}
417
418
function absorb(r, sentMethod, sentPath) {
419
  const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
420
  if (headers.etag) etags[sentPath] = headers.etag;
421
  const digest = (headers["www-authenticate"] || "").match(/nonce="([^"]*)"/);
422
  if ("www-authenticate" in headers) nonce = digest ? digest[1] : null;
423
  renderExcuses();
424
  let body = null;
425
  try { body = JSON.parse(r.body); } catch (e) {}
426
  const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
427
  const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
428
429
  if (started) {
430
    run = headers.location;
431
    lastStats = {};
432
    log("--- a new run has started at " + short(run) + " ---");
433
  }
434
  if ("x-eq" in headers) {
435
    renderStats(headers);
436
    renderAlerts(r.headers);
437
    challenged = r.status === 401 || "via" in headers;
438
  }
439
  if (headers.link) [linked, techniques] = parseLinks(headers.link);
440
  if (run) {
441
    const active = run;
442
    const seq = ++inventorySeq;
443
    fetch("/ui/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method: "GET", path: run + "/inventory", headers: [], body: "" }) })
444
      .then(r => r.json())
445
      .then(r => {
446
        if (run !== active || seq !== inventorySeq) return;
447
        try {
448
          const held = JSON.parse(r.body);
449
          stocked = (held.items || []).map(i => i.name);
450
          excuses = held.excuses || [];
451
          renderExcuses();
452
          render();
453
        } catch (e) {}
454
      });
455
  }
456
  offered = body ? hrefs(body, []) : [];
457
  if (headers.location && !started) {
458
    offered.unshift({ key: "offered", group: "said", name: short(headers.location).split("/").pop() + " (Location)", path: headers.location, methods: ["GET"] });
459
  }
460
  const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
461
  if (run && (abandoned || DEATHS.includes(problem))) {
462
    log("--- the run has ended ---");
463
    renderAlerts(null);
464
    challenged = false;
465
    run = null;
466
    linked = [];
467
    techniques = [];
468
  }
469
  const menu = targets().filter(t => t.group !== "said").map(t => t.path).concat(techniques.map(t => "technique " + t.name));
470
  fresh = started ? new Set() : new Set(menu.filter(m => !known.has(m)));
471
  known = new Set(menu);
472
  if (started || !targets().some(t => t.path === target)) {
473
    const home = linked.find(t => t.key === "run");
474
    chooseTarget(home || GLOBALS[0]);
475
  } else {
476
    render();
477
  }
478
}
479
480
async function go(event) {
481
  event.preventDefault();
482
  const t = current();
483
  const key = t.key + " " + method;
484
  const value = name => fieldValue(name).trim();
485
  const headers = [];
486
  let path = t.path;
487
  for (const name of fieldNames()) {
488
    const f = FIELDS[name];
489
    if (f.header && value(name)) headers.push([f.header, value(name)]);
490
  }
491
  if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
492
  for (const line of raw("extra").split("\n")) {
493
    const at = line.indexOf(":");
494
    if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
495
  }
496
  if (key === "run JELQ") {
497
    const names = fieldNames();
498
    const chosen = [value("technique")].concat(names.includes("also") && value("also") ? [value("also")] : []);
499
    path += "?technique=" + encodeURIComponent(chosen.join(","));
500
    if (names.includes("intensity")) path += "&intensity=" + encodeURIComponent(value("intensity"));
501
    delete values.also;
502
  }
503
  if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
504
505
  const sent = ["> " + method + " " + short(path)].concat(headers.map(([name, v]) => "> " + name + ": " + v)).join("\n");
506
  delete values.authorization;
507
  headers.push(["Accept-Language", $("locale").value]);
508
  let r;
509
  try {
510
    const response = await fetch("/ui/send", {
511
      method: "POST",
512
      headers: { "Content-Type": "application/json" },
513
      body: JSON.stringify({ method, path, headers }),
514
    });
515
    r = await response.json();
516
  } catch (error) {
517
    log(sent + "\n! The server did not answer. Is jelqd still running? (" + error.message + ")");
518
    return;
519
  }
520
  if (!Array.isArray(r.headers)) {
521
    log(sent + "\n! The page could not read that reply:\n  " + JSON.stringify(r));
522
    return;
523
  }
524
  log(sent + "\n" + describe(r));
525
  absorb(r, method, path);
526
}
527
528
$("form").onsubmit = go;
529
$("locale").value = localStorage.getItem("locale") || "en-GB";
530
$("locale").onchange = () => localStorage.setItem("locale", $("locale").value);
531
renderStats(null);
532
render();
533
log("Welcome. Choose \"start a new run\" and send POST to begin. The newest turn is always at the top.");
534
</script>
535
</body>
536
</html>