atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
585e6b7ce08636105da83cdbed5ff24679d0db4d
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/ui.htmlHtml432 lines15.6 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. Pick a resource, pick a method, send it. The log on the right is everything the server has said to you.
16
Language <select id="locale"><option>en-GB</option><option>en-US</option><option>en-AU</option></select></p>
17
18
<table width="100%" cellspacing="6">
19
<tr>
20
<td width="45%" valign="top">
21
<div class="frame">
22
23
<div id="alerts"></div>
24
25
<form id="form">
26
<h3>Method</h3>
27
<div id="methods"></div>
28
29
<h3>Request</h3>
30
<table id="fields" cellpadding="2"></table>
31
32
<p><input type="submit" id="send" value="Send" style="font-size: 1.3em"></p>
33
34
<h3>Stats</h3>
35
<table id="stats" border="1" cellspacing="0" cellpadding="3"></table>
36
37
<h3>Resource</h3>
38
<div id="targets"></div>
39
</form>
40
41
<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>
42
</div>
43
</td>
44
<td valign="top">
45
<div class="frame" id="frame"><pre id="log"></pre></div>
46
</td>
47
</tr>
48
</table>
49
50
<script>
51
const REL = "https://jelq.dev/rels/";
52
const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
53
const LUBES = ["", "application/baby-oil", "application/coconut-oil", "application/lotion", "application/vaseline", "text/plain; charset=spit", "image/jpeg"];
54
const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];
55
const STATS = [
56
  ["x-floor", "Floor"],
57
  ["x-self-reported-length", "Self-reported length"],
58
  ["x-girth", "Girth"],
59
  ["x-foreskin", "Foreskin"],
60
  ["x-eq", "EQ (aim for 50 to 70)"],
61
  ["x-stamina", "Stamina"],
62
  ["x-suspicion", "Suspicion"],
63
  ["x-shame", "Shame"],
64
  ["x-cash", "Cash"],
65
];
66
const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0])));
67
68
const FIELDS = {
69
  seed: { label: "seed (unranked)", kind: "text" },
70
  technique: { label: "technique", kind: "select" },
71
  also: { label: "combo with", kind: "select" },
72
  intensity: { label: "intensity, 1 to 10", kind: "number", value: "5" },
73
  lube: { label: "Content-Type", header: "Content-Type", kind: "select" },
74
  patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
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 PATCH": ["patch"],
87
  "length GET": ["range"],
88
  "length HEAD": ["range"],
89
  "foreskin DELETE": ["ifMatch"],
90
  "elder GET": ["proxy", "ifModifiedSince"],
91
  "grip GET": ["upgrade"],
92
  "diagnosis GET": ["authorization", "ifNoneMatch"],
93
};
94
const GLOBALS = [
95
  { key: "runs", name: "start a new run", path: "/runs", methods: ["POST"] },
96
  { key: "global", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
97
  { key: "global", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
98
  { key: "global", name: "the 2009 forum thread", path: "/forum/threads/2009", methods: ["GET", "HEAD", "OPTIONS"] },
99
];
100
101
let run = null;
102
let linked = [];
103
let techniques = [];
104
let offered = [];
105
let challenged = false;
106
let target = "/runs";
107
let method = "POST";
108
const values = {};
109
110
const $ = id => document.getElementById(id);
111
112
function short(path) {
113
  return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
114
}
115
116
function el(tag, props, ...children) {
117
  const node = Object.assign(document.createElement(tag), props || {});
118
  for (const child of children) node.append(child);
119
  return node;
120
}
121
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
172
function targets() {
173
  return (run ? linked : []).concat(offered, GLOBALS);
174
}
175
176
function current() {
177
  return targets().find(t => t.path === target) || GLOBALS[0];
178
}
179
180
function chooseTarget(t) {
181
  const allowed = allowedMethods(t);
182
  target = t.path;
183
  method = allowed.includes("JELQ") ? "JELQ" : allowed.find(m => m !== "HEAD" && m !== "OPTIONS") || allowed[0];
184
  render();
185
}
186
187
function renderStats(headers) {
188
  const table = $("stats");
189
  table.replaceChildren();
190
  for (let i = 0; i < STATS.length; i += 2) {
191
    const row = el("tr");
192
    for (const [name, label] of STATS.slice(i, i + 2)) {
193
      const value = headers ? headers[name] : undefined;
194
      row.append(el("th", { align: "left", textContent: label }), el("td", { textContent: value === undefined ? "—" : value }));
195
    }
196
    table.append(row);
197
  }
198
}
199
200
function renderAlerts(list) {
201
  const box = $("alerts");
202
  box.replaceChildren();
203
  const alerts = [];
204
  for (const [name, value] of list || []) {
205
    if (name === "via") alerts.push(titled(value.split(" ").pop()) + " is at the door.");
206
    if (name === "x-observed-by") alerts.push((value === "cat" ? "The cat" : titled(value)) + " is watching.");
207
    if (name === "warning") {
208
      const text = (value.match(/"([^"]*)"/) || [, value])[1];
209
      alerts.push(titled(text) + ".");
210
    }
211
  }
212
  for (const alert of alerts) box.append(el("h2", { textContent: alert }));
213
}
214
215
function renderTargets() {
216
  const box = $("targets");
217
  box.replaceChildren();
218
  for (const t of targets()) {
219
    const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
220
    box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
221
  }
222
}
223
224
function renderMethods() {
225
  const box = $("methods");
226
  box.replaceChildren();
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(); } });
230
    const label = el("label", {}, radio, " ");
231
    label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
232
    box.append(label, " ");
233
  }
234
}
235
236
function fieldNames() {
237
  const t = current();
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
  }
247
  const scoped = run && !["runs", "global"].includes(t.key);
248
  const asked = challenged && scoped && !names.includes("authorization");
249
  return (asked ? ["authorization"] : []).concat(names);
250
}
251
252
function renderFields() {
253
  const table = $("fields");
254
  table.replaceChildren();
255
  for (const name of fieldNames()) {
256
    const f = FIELDS[name];
257
    const value = fieldValue(name);
258
    const choices = options(name);
259
    let input;
260
    if (choices) {
261
      input = el("select", {}, ...choices.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
262
    } else if (f.kind === "textarea") {
263
      input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
264
    } else {
265
      input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });
266
    }
267
    input.oninput = () => { values[name] = input.value; };
268
    input.onchange = () => { values[name] = input.value; if (choices) renderFields(); };
269
    table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
270
  }
271
  const extra = el("textarea", { rows: 3, cols: 40, value: raw("extra"), placeholder: FIELDS.extra.placeholder });
272
  extra.oninput = () => { values.extra = extra.value; };
273
  const more = el("details", { open: Boolean(values.extra) }, el("summary", { textContent: "more headers" }), extra);
274
  table.append(el("tr", {}, el("td"), el("td", {}, more)));
275
  $("send").value = method + " " + short(current().path);
276
}
277
278
function render() {
279
  if (!allowedMethods(current()).includes(method)) return chooseTarget(current());
280
  renderTargets();
281
  renderMethods();
282
  renderFields();
283
}
284
285
function parseLinks(header) {
286
  const resources = [];
287
  const moves = [];
288
  for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
289
    const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
290
    const name = (params.rel || "").replace(REL, "");
291
    if (name === "technique") {
292
      moves.push({ name: params.title, lubricant: params.lubricant || null });
293
      continue;
294
    }
295
    const methods = (params.allow || "GET").split(",").map(s => s.trim());
296
    resources.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
297
  }
298
  return [resources, moves];
299
}
300
301
function hrefs(value, found) {
302
  if (value && typeof value === "object") {
303
    if (typeof value.href === "string") {
304
      const name = value.title || value.item || value.name || value.href.split("/").pop();
305
      found.push({ key: "offered", name, path: value.href, methods: [value.method || "GET"] });
306
    }
307
    for (const key in value) hrefs(value[key], found);
308
  }
309
  return found;
310
}
311
312
function log(text) {
313
  $("log").prepend(text + "\n\n");
314
  $("frame").scrollTop = 0;
315
}
316
317
function describe(r) {
318
  const lines = ["< " + r.status + " " + r.reason];
319
  for (const [name, value] of r.headers) {
320
    if (!QUIET.has(name)) lines.push("< " + name + ": " + short(value));
321
  }
322
  let body = null;
323
  try { body = JSON.parse(r.body); } catch (e) {}
324
  if (body && typeof body === "object") {
325
    const rest = Object.assign({}, body);
326
    if (rest.detail) lines.push("  " + rest.detail);
327
    for (const key of ["type", "title", "status", "detail"]) delete rest[key];
328
    if (Object.keys(rest).length) lines.push(short(JSON.stringify(rest, null, 2)).replace(/^/gm, "  "));
329
  } else if (r.body) {
330
    lines.push("  " + r.body);
331
  }
332
  return lines.join("\n");
333
}
334
335
function absorb(r, sentMethod, sentPath) {
336
  const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
337
  let body = null;
338
  try { body = JSON.parse(r.body); } catch (e) {}
339
  const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
340
  const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
341
342
  if (started) {
343
    run = headers.location;
344
    log("--- a new run has started at " + short(run) + " ---");
345
  }
346
  if ("x-eq" in headers) {
347
    renderStats(headers);
348
    renderAlerts(r.headers);
349
    challenged = r.status === 401 || "via" in headers;
350
  }
351
  if (headers.link) [linked, techniques] = parseLinks(headers.link);
352
  offered = body ? hrefs(body, []) : [];
353
  if (headers.location && !started) {
354
    offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
355
  }
356
  const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
357
  if (run && (abandoned || DEATHS.includes(problem))) {
358
    log("--- the run has ended ---");
359
    renderAlerts(null);
360
    challenged = false;
361
    run = null;
362
    linked = [];
363
    techniques = [];
364
  }
365
  if (started || !targets().some(t => t.path === target)) {
366
    const home = linked.find(t => t.key === "run");
367
    chooseTarget(home || GLOBALS[0]);
368
  } else {
369
    render();
370
  }
371
}
372
373
async function go(event) {
374
  event.preventDefault();
375
  const t = current();
376
  const key = t.key + " " + method;
377
  const value = name => fieldValue(name).trim();
378
  const headers = [];
379
  let path = t.path;
380
  let body = "";
381
  for (const name of fieldNames()) {
382
    const f = FIELDS[name];
383
    if (f.header && value(name)) headers.push([f.header, value(name)]);
384
  }
385
  if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
386
  for (const line of raw("extra").split("\n")) {
387
    const at = line.indexOf(":");
388
    if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
389
  }
390
  if (key === "run JELQ") {
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"));
395
  }
396
  if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
397
  if (method === "PATCH") {
398
    headers.push(["Content-Type", "application/json-patch+json"]);
399
    body = value("patch");
400
  }
401
402
  const sent = ["> " + method + " " + short(path)].concat(headers.map(([name, v]) => "> " + name + ": " + v)).join("\n");
403
  headers.push(["Accept-Language", $("locale").value]);
404
  let r;
405
  try {
406
    const response = await fetch("/ui/send", {
407
      method: "POST",
408
      headers: { "Content-Type": "application/json" },
409
      body: JSON.stringify({ method, path, headers, body }),
410
    });
411
    r = await response.json();
412
  } catch (error) {
413
    log(sent + "\n! The server did not answer. Is jelqd still running? (" + error.message + ")");
414
    return;
415
  }
416
  if (!Array.isArray(r.headers)) {
417
    log(sent + "\n! The page could not read that reply:\n  " + JSON.stringify(r));
418
    return;
419
  }
420
  log(sent + "\n" + describe(r));
421
  absorb(r, method, path);
422
}
423
424
$("form").onsubmit = go;
425
$("locale").value = localStorage.getItem("locale") || "en-GB";
426
$("locale").onchange = () => localStorage.setItem("locale", $("locale").value);
427
renderStats(null);
428
render();
429
log("Welcome. Choose \"start a new run\" and send POST to begin. The newest turn is always at the top.");
430
</script>
431
</body>
432
</html>