atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
431c1bc8ded1d56c4871dc3268b363eca5d8b93e
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/ui.htmlHtml347 lines12.7 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.</p>
16
17
<table width="100%" cellspacing="6">
18
<tr>
19
<td width="45%" valign="top">
20
<div class="frame">
21
22
<div id="alerts"></div>
23
24
<form id="form">
25
<h3>Method</h3>
26
<div id="methods"></div>
27
28
<h3>Request</h3>
29
<table id="fields" cellpadding="2"></table>
30
31
<p><input type="submit" id="send" value="Send" style="font-size: 1.3em"></p>
32
33
<h3>Stats</h3>
34
<table id="stats" border="1" cellspacing="0" cellpadding="3"></table>
35
36
<h3>Resource</h3>
37
<div id="targets"></div>
38
</form>
39
40
<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>
41
</div>
42
</td>
43
<td valign="top">
44
<div class="frame" id="frame"><pre id="log"></pre></div>
45
</td>
46
</tr>
47
</table>
48
49
<script>
50
const REL = "https://jelq.dev/rels/";
51
const METHODS = ["JELQ", "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"];
52
const TECHNIQUES = ["warmup", "wet", "dry", "stretch", "uli", "edge", "pump", "ice", "supplement"];
53
const DEATHS = ["vascular-event", "caught", "banned", "degloved", "zip", "run-ended"];
54
const STATS = [
55
  ["x-floor", "Floor"],
56
  ["x-self-reported-length", "Self-reported length"],
57
  ["x-girth", "Girth"],
58
  ["x-foreskin", "Foreskin"],
59
  ["x-eq", "EQ (aim for 50 to 70)"],
60
  ["x-stamina", "Stamina"],
61
  ["x-suspicion", "Suspicion"],
62
  ["x-shame", "Shame"],
63
  ["x-cash", "Cash"],
64
];
65
const OBSERVERS = { cat: "The cat is watching.", greg: "Greg is watching.", nan: "Nan is watching." };
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", options: TECHNIQUES },
71
  also: { label: "combo with", kind: "select", options: ["", "wet", "dry", "stretch", "uli"] },
72
  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"] },
74
  patch: { label: "body", kind: "textarea", value: '[{"op": "test", "path": "/length", "value": 6}]' },
75
  accept: { label: "Accept", header: "Accept", kind: "text" },
76
  range: { label: "Range", header: "Range", kind: "text", placeholder: "inches=0-2" },
77
  ifMatch: { label: "If-Match", header: "If-Match", kind: "text" },
78
  ifNoneMatch: { label: "If-None-Match", header: "If-None-Match", kind: "text" },
79
  ifModifiedSince: { label: "If-Modified-Since", header: "If-Modified-Since", kind: "text", placeholder: "an HTTP-date" },
80
  proxy: { label: "Proxy-Authorization", header: "Proxy-Authorization", kind: "text", placeholder: "Ruler 5.4" },
81
  upgrade: { label: "Upgrade", header: "Upgrade", kind: "select", options: ["", "underhand", "overhand"] },
82
  authorization: { label: "Authorization", header: "Authorization", kind: "text", placeholder: 'Excuse "homework"' },
83
  extra: { label: "other headers, one per line", kind: "textarea", placeholder: "Expect: gains" },
84
};
85
const BY_TARGET = {
86
  "runs POST": ["seed"],
87
  "run JELQ": ["technique", "also", "intensity", "lube"],
88
  "run GET": ["accept"],
89
  "run PATCH": ["patch"],
90
  "length GET": ["range"],
91
  "length HEAD": ["range"],
92
  "foreskin DELETE": ["ifMatch"],
93
  "elder GET": ["proxy", "ifModifiedSince"],
94
  "grip GET": ["upgrade"],
95
  "diagnosis GET": ["ifNoneMatch"],
96
};
97
const ALWAYS = ["authorization", "extra"];
98
const GLOBALS = [
99
  { key: "runs", name: "start a new run", path: "/runs", methods: ["POST"] },
100
  { key: "global", name: "leaderboard", path: "/leaderboard", methods: ["GET", "HEAD", "OPTIONS"] },
101
  { key: "global", name: "graveyard", path: "/graveyard", methods: ["GET", "HEAD", "OPTIONS"] },
102
  { key: "global", name: "the 2009 forum thread", path: "/forum/threads/2009", methods: ["GET", "HEAD", "OPTIONS"] },
103
];
104
105
let run = null;
106
let linked = [];
107
let offered = [];
108
let target = "/runs";
109
let method = "POST";
110
const values = {};
111
112
const $ = id => document.getElementById(id);
113
const fieldValue = name => values[name] ?? FIELDS[name].value ?? (FIELDS[name].options ? FIELDS[name].options[0] : "");
114
115
function short(path) {
116
  return path.replace(/[0-9a-f]{32}/, id => id.slice(0, 6) + "…");
117
}
118
119
function el(tag, props, ...children) {
120
  const node = Object.assign(document.createElement(tag), props || {});
121
  for (const child of children) node.append(child);
122
  return node;
123
}
124
125
function targets() {
126
  return (run ? linked : []).concat(offered, GLOBALS);
127
}
128
129
function current() {
130
  return targets().find(t => t.path === target) || GLOBALS[0];
131
}
132
133
function chooseTarget(t) {
134
  target = t.path;
135
  method = t.methods.includes("JELQ") ? "JELQ" : t.methods.find(m => m !== "HEAD" && m !== "OPTIONS") || t.methods[0];
136
  render();
137
}
138
139
function renderStats(headers) {
140
  const table = $("stats");
141
  table.replaceChildren();
142
  for (let i = 0; i < STATS.length; i += 2) {
143
    const row = el("tr");
144
    for (const [name, label] of STATS.slice(i, i + 2)) {
145
      const value = headers ? headers[name] : undefined;
146
      row.append(el("th", { align: "left", textContent: label }), el("td", { textContent: value === undefined ? "—" : value }));
147
    }
148
    table.append(row);
149
  }
150
}
151
152
function renderAlerts(list) {
153
  const box = $("alerts");
154
  box.replaceChildren();
155
  const alerts = [];
156
  for (const [name, value] of list || []) {
157
    if (name === "via" && value.includes("mum")) alerts.push("Mum is at the door.");
158
    if (name === "x-observed-by") alerts.push(OBSERVERS[value] || "Someone is watching.");
159
    if (name === "warning") {
160
      const text = (value.match(/"([^"]*)"/) || [, value])[1];
161
      alerts.push(text.charAt(0).toUpperCase() + text.slice(1) + ".");
162
    }
163
  }
164
  for (const alert of alerts) box.append(el("h2", { textContent: alert }));
165
}
166
167
function renderTargets() {
168
  const box = $("targets");
169
  box.replaceChildren();
170
  for (const t of targets()) {
171
    const radio = el("input", { type: "radio", name: "target", checked: t.path === target, onchange: () => chooseTarget(t) });
172
    box.append(el("label", {}, radio, " " + t.name + " ", el("small", { textContent: short(t.path) })), el("br"));
173
  }
174
}
175
176
function renderMethods() {
177
  const box = $("methods");
178
  box.replaceChildren();
179
  const allowed = current().methods;
180
  for (const m of METHODS) {
181
    const radio = el("input", { type: "radio", name: "method", disabled: !allowed.includes(m), checked: m === method, onchange: () => { method = m; render(); } });
182
    const label = el("label", {}, radio, " ");
183
    label.append(m === "JELQ" ? el("big", {}, el("b", { textContent: "JELQ" })) : m);
184
    box.append(label, " ");
185
  }
186
}
187
188
function fieldNames() {
189
  const key = current().key + " " + method;
190
  return (BY_TARGET[key] || []).concat(ALWAYS);
191
}
192
193
function renderFields() {
194
  const table = $("fields");
195
  table.replaceChildren();
196
  for (const name of fieldNames()) {
197
    const f = FIELDS[name];
198
    const value = fieldValue(name);
199
    let input;
200
    if (f.kind === "select") {
201
      input = el("select", {}, ...f.options.map(o => el("option", { value: o, textContent: o || "(none)", selected: o === value })));
202
    } else if (f.kind === "textarea") {
203
      input = el("textarea", { rows: 3, cols: 40, value, placeholder: f.placeholder || "" });
204
    } else {
205
      input = el("input", { type: f.kind, value, placeholder: f.placeholder || "", size: 30 });
206
    }
207
    input.oninput = input.onchange = () => { values[name] = input.value; };
208
    table.append(el("tr", {}, el("td", { align: "right", valign: "top", textContent: f.label }), el("td", {}, input)));
209
  }
210
  $("send").value = method + " " + short(current().path);
211
}
212
213
function render() {
214
  if (!current().methods.includes(method)) return chooseTarget(current());
215
  renderTargets();
216
  renderMethods();
217
  renderFields();
218
}
219
220
function parseLinks(header) {
221
  const found = [];
222
  for (const match of header.matchAll(/<([^>]*)>((?:\s*;\s*[a-z-]+="[^"]*")*)/g)) {
223
    const params = Object.fromEntries([...match[2].matchAll(/([a-z-]+)="([^"]*)"/g)].map(p => [p[1], p[2]]));
224
    const name = (params.rel || "").replace(REL, "");
225
    const methods = (params.allow || "GET").split(",").map(s => s.trim());
226
    found.push({ key: name, name: name === "run" ? "the run" : name, path: match[1], methods });
227
  }
228
  return found;
229
}
230
231
function hrefs(value, found) {
232
  if (value && typeof value === "object") {
233
    if (typeof value.href === "string") {
234
      const name = value.title || value.item || value.name || value.href.split("/").pop();
235
      found.push({ key: "offered", name, path: value.href, methods: [value.method || "GET"] });
236
    }
237
    for (const key in value) hrefs(value[key], found);
238
  }
239
  return found;
240
}
241
242
function log(text) {
243
  $("log").append(text + "\n");
244
  $("frame").scrollTop = $("frame").scrollHeight;
245
}
246
247
function describe(r) {
248
  const lines = ["< " + r.status + " " + r.reason];
249
  for (const [name, value] of r.headers) {
250
    if (!QUIET.has(name)) lines.push("< " + name + ": " + short(value));
251
  }
252
  let body = null;
253
  try { body = JSON.parse(r.body); } catch (e) {}
254
  if (body && typeof body === "object") {
255
    const rest = Object.assign({}, body);
256
    if (rest.detail) lines.push("  " + rest.detail);
257
    for (const key of ["type", "title", "status", "detail"]) delete rest[key];
258
    if (Object.keys(rest).length) lines.push(short(JSON.stringify(rest, null, 2)).replace(/^/gm, "  "));
259
  } else if (r.body) {
260
    lines.push("  " + r.body);
261
  }
262
  return lines.join("\n");
263
}
264
265
function absorb(r, sentMethod, sentPath) {
266
  const headers = Object.fromEntries(r.headers.map(([n, v]) => [n.toLowerCase(), v]));
267
  let body = null;
268
  try { body = JSON.parse(r.body); } catch (e) {}
269
  const problem = body && typeof body.type === "string" ? body.type.split("/").pop() : null;
270
271
  if (sentPath.startsWith("/runs?") || sentPath === "/runs") {
272
    if (r.status === 201) {
273
      run = headers.location;
274
      log("--- a new run has started at " + short(run) + " ---");
275
    }
276
  }
277
  if ("x-eq" in headers) {
278
    renderStats(headers);
279
    renderAlerts(r.headers);
280
  }
281
  if (headers.link) linked = parseLinks(headers.link);
282
  offered = body ? hrefs(body, []) : [];
283
  if (headers.location && !(sentPath === "/runs" || sentPath.startsWith("/runs?"))) {
284
    offered.unshift({ key: "offered", name: "Location", path: headers.location, methods: ["GET"] });
285
  }
286
  const abandoned = sentMethod === "DELETE" && sentPath === run && r.status === 204;
287
  if (run && (abandoned || DEATHS.includes(problem))) {
288
    log("--- the run has ended ---");
289
    renderAlerts(null);
290
    run = null;
291
    linked = [];
292
  }
293
  const started = r.status === 201 && (sentPath === "/runs" || sentPath.startsWith("/runs?"));
294
  if (started || !targets().some(t => t.path === target)) {
295
    const home = linked.find(t => t.key === "run");
296
    if (home) chooseTarget(home); else chooseTarget(GLOBALS[0]);
297
  } else {
298
    render();
299
  }
300
}
301
302
async function go(event) {
303
  event.preventDefault();
304
  const t = current();
305
  const key = t.key + " " + method;
306
  const value = name => fieldValue(name).trim();
307
  const headers = [];
308
  let path = t.path;
309
  let body = "";
310
  for (const name of fieldNames()) {
311
    const f = FIELDS[name];
312
    if (f.header && value(name)) headers.push([f.header, value(name)]);
313
  }
314
  if (fieldNames().includes("upgrade") && value("upgrade")) headers.push(["Connection", "upgrade"]);
315
  for (const line of value("extra").split("\n")) {
316
    const at = line.indexOf(":");
317
    if (at > 0) headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]);
318
  }
319
  if (key === "run JELQ") {
320
    const techniques = value("also") ? value("technique") + "," + value("also") : value("technique");
321
    path += "?technique=" + encodeURIComponent(techniques) + "&intensity=" + encodeURIComponent(value("intensity"));
322
  }
323
  if (key === "runs POST" && value("seed")) path += "?seed=" + encodeURIComponent(value("seed"));
324
  if (method === "PATCH") {
325
    headers.push(["Content-Type", "application/json-patch+json"]);
326
    body = value("patch");
327
  }
328
329
  log("> " + method + " " + short(path));
330
  for (const [name, v] of headers) log("> " + name + ": " + v);
331
  const response = await fetch("/ui/send", {
332
    method: "POST",
333
    headers: { "Content-Type": "application/json" },
334
    body: JSON.stringify({ method, path, headers, body }),
335
  });
336
  const r = await response.json();
337
  log(describe(r) + "\n");
338
  absorb(r, method, path);
339
}
340
341
$("form").onsubmit = go;
342
renderStats(null);
343
render();
344
log("Welcome. Choose \"start a new run\" and send POST to begin.\n");
345
</script>
346
</body>
347
</html>