an http based roguelike rpg about jelqing
| 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 | <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> |
| 46 | |
| 47 | <script> |
| 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]))); |
| 66 | |
| 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) + "…"); |
| 116 | } |
| 117 | |
| 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; |
| 122 | } |
| 123 | |
| 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); |
| 148 | } |
| 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; |
| 213 | } |
| 214 | |
| 215 | function hrefs(value, found) { |
| 216 | if (value && typeof value === "object") { |
| 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 | } |
| 221 | for (const key in value) hrefs(value[key], found); |
| 222 | } |
| 223 | return found; |
| 224 | } |
| 225 | |
| 226 | function log(text) { |
| 227 | $("log").append(text + "\n"); |
| 228 | $("frame").scrollTop = $("frame").scrollHeight; |
| 229 | } |
| 230 | |
| 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"); |
| 247 | } |
| 248 | |
| 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(); |
| 279 | } |
| 280 | } |
| 281 | |
| 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 | } |
| 308 | |
| 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); |
| 319 | } |
| 320 | |
| 321 | $("form").onsubmit = go; |
| 322 | renderStats(null); |
| 323 | render(); |
| 324 | log("Welcome. Choose \"start a new run\" and send POST to begin.\n"); |
| 325 | </script> |
| 326 | </body> |
| 327 | </html> |