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