room-self-game.js (24770B)
1 /* Puzzle room, self-learning variant — the game loop for the "Puzzle room SL" 2 * tab. Same room and first-person renderer as room.js, but the decision model 3 * is given no rules: only a neutral observation (FPP prose or the top-down 4 * map) and the bare actions forward/left/right, plus an "insufficient" 5 * escape hatch. Two events trigger a /plan call whose output is injected into 6 * every later observation as "Rules": 7 * 8 * 1. The model picks "insufficient" as its leading option at all 9 * (INSUFFICIENT_P = 0). The room has no failure signal until the step 10 * budget runs out, so a wandering model must be able to ask for rules 11 * early — unlike the platformer, which demands p ≥ 0.99. The planner 12 * writes full game rules, not a fix for one stuck situation, so the 13 * room restarts with the learned rules rather than re-deciding the 14 * same dead end (the platformer, with its dense per-step feedback, 15 * re-decides the same state instead). 16 * 2. The step budget (RoomRules.MAX_STEPS) runs out. The planner runs, 17 * then the room resets with the learned rules in place. 18 * 19 * Wrapped in an IIFE so its local `$` never collides with app.js. Requires 20 * room-rules.js, room-self-rules.js, and planner.js to have defined their 21 * globals first. */ 22 (() => { 23 "use strict"; 24 25 const { W, H, MAX_STEPS, DIRS, makeLayout, newState, turn, forward } = RoomRules; 26 const { QUESTION, optionsFor } = SelfRoomRules; 27 const STEP_PAUSE_MS = 400; // pacing between decisions so a human can watch 28 // Any plurality win for "insufficient" plans — see Planner.triggered's 29 // threshold argument. The platformer's 0.99 would let a lost model wander 30 // for most of the 80-step budget before anything intervened. 31 const INSUFFICIENT_P = 0; 32 33 /* ── mutable game state ─────────────────────────────────────────────── */ 34 let layout = makeLayout(1); // deterministic default; Randomize reseeds 35 let state = newState(layout); 36 let mode = "idle"; // idle | auto | manual | won | lost 37 let deciding = false; 38 let planning = false; 39 let stateMode = "fpp"; // "fpp" = neutral prose | "map" = top-down 40 let decisionN = 0; 41 let planN = 0; 42 let learnedRules = ""; // last /plan output; leads every observation 43 // POIs the cone has ever revealed this layout: Known-line bearings persist 44 // for the layout's life — auto-restarts keep them (same landmarks), full 45 // Reset clears them (new layout). 46 const discovered = new Set(); 47 let transcript = Planner.fresh(); // completed actions + outcomes for /plan 48 let pending = null; // decision being executed, recorded once it resolves 49 let runToken = 0; // invalidates in-flight work on reset 50 // Run stats for the completion summary: wall time from the Start click 51 // (spanning auto-restarts), failures, decisions, and the total token spend 52 // of every planner call. 53 const freshStats = () => ({ startedAt: null, failures: 0, decisions: 0, plans: 0, 54 planCompletionTokens: 0, planReasoningTokens: 0, planPromptTokens: 0, planMs: 0 }); 55 let stats = freshStats(); 56 const count = (n) => n.toLocaleString("en-US"); 57 58 const $ = (id) => document.getElementById(id); 59 const cv = $("rs-cv"), ctx = cv.getContext("2d"); 60 61 function refreshLabel() { 62 $("rs-label").textContent = 63 `Room — seed ${layout.seed} · key: ${state.hasKey ? "found" : "missing"} · ` + 64 `door: ${state.doorOpen ? "open" : "locked"} · steps: ${state.steps}/${MAX_STEPS}`; 65 } 66 refreshLabel(); 67 68 // Same rule as room.js: shortcuts only while this panel is visible and focus 69 // is not in an editable element. 70 const roomActive = () => { 71 const t = document.activeElement; 72 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false; 73 return !$("panel-roomself").hidden; 74 }; 75 76 /* ── the ONE textual state: source for rendering prompts AND the API ── */ 77 // Bare observation: no learned rules. This is what the transcript records — 78 // the planner already receives the current rules ONCE via Planner.context's 79 // "Previous rules", so re-embedding them in all 24 history turns 80 // would just bloat its prompt and blur which rules were current. 81 // Also folds whatever the cone currently sees into the discovered set, so 82 // the observation that reveals a POI is the last one without its bearing. 83 function bareState() { 84 for (const o of RoomRules.visibleObjects(state, layout)) { 85 const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name]; 86 if (id) discovered.add(id); 87 } 88 return SelfRoomRules.stateText(state, layout, stateMode, discovered); 89 } 90 function stateText() { 91 const base = bareState(); 92 // Learned rules lead, the current state follows. The freshest tokens 93 // should be the immediate evidence the decision is made from, not the 94 // standing rules — the decision pass reads once, and recent tokens weigh 95 // most. 96 return learnedRules ? `Rules:\n${learnedRules}\n\n${base}` : base; 97 } 98 99 /* ── first-person rendering (DDA raycast over the same grid) ────────── */ 100 const RAYS = 240; 101 102 function render() { 103 const w = cv.width, h = cv.height; 104 ctx.fillStyle = "#10141d"; // ceiling 105 ctx.fillRect(0, 0, w, h / 2); 106 ctx.fillStyle = "#242b3a"; // floor 107 ctx.fillRect(0, h / 2, w, h / 2); 108 109 const dir = DIRS[state.dir]; 110 const planeX = -dir.dy * RoomRules.PLANE, planeY = dir.dx * RoomRules.PLANE; 111 const strip = w / RAYS; 112 const zbuf = new Float32Array(RAYS); 113 114 for (let i = 0; i < RAYS; i++) { 115 const cam = 2 * i / RAYS - 1; 116 // RoomRules.cast is the single definition of sight-blocking: same math 117 // the FPP state text uses, so the canvas and "In view" can't drift. 118 const hit = RoomRules.cast(state, layout, dir.dx + planeX * cam, dir.dy + planeY * cam); 119 zbuf[i] = hit.dist; 120 const lineH = h / hit.dist; 121 const shade = Math.max(0.2, 1 - hit.dist / 12) * (hit.side === 1 ? 0.8 : 1); 122 const base = hit.cell === "D" ? [152, 98, 44] : [104, 120, 152]; 123 ctx.fillStyle = `rgb(${base.map((c) => Math.round(c * shade)).join(",")})`; 124 ctx.fillRect(Math.floor(i * strip), h / 2 - lineH / 2, Math.ceil(strip), lineH); 125 } 126 127 drawSprite(zbuf, strip, layout.exit, true, (s) => { // exit: tall green portal 128 ctx.fillStyle = "#1d3a24"; 129 ctx.fillRect(s.x0, s.top, s.width, s.height); 130 ctx.fillStyle = "#56d364"; 131 ctx.fillRect(s.x0 + s.width * 0.15, s.top + s.height * 0.1, s.width * 0.7, s.height * 0.8); 132 }); 133 if (!state.hasKey) { 134 drawSprite(zbuf, strip, layout.key, true, (s) => { // key: small yellow disc 135 ctx.fillStyle = "#e3b341"; 136 ctx.beginPath(); 137 ctx.arc(s.cx, s.cy, Math.max(2, s.width * 0.4), 0, Math.PI * 2); 138 ctx.fill(); 139 }, 0.30, 0.55); 140 } 141 142 // HUD: facing + carry, so the human view matches what the state asserts. 143 ctx.fillStyle = "rgba(11, 14, 20, 0.65)"; 144 ctx.fillRect(8, 8, 236, 22); 145 ctx.fillStyle = "#c9d1d9"; 146 ctx.font = "13px monospace"; 147 ctx.fillText(`facing ${DIRS[state.dir].name} · ${state.hasKey ? "key ✓" : "no key"}`, 14, 23); 148 } 149 150 // Project a cell-center billboard into the view, clipping each column against 151 // the wall z-buffer. scale = height fraction of a wall at that distance, 152 // lift = vertical centering (0.5 = middle). 153 function drawSprite(zbuf, strip, cell, visible, draw, scale = 0.85, lift = 0.5) { 154 if (!visible) return; 155 const { tx, ty } = RoomRules.project(state, cell); 156 if (ty <= 0.15) return; 157 const w = cv.width, h = cv.height; 158 const cx = (w / 2) * (1 + tx / ty); 159 const height = (h / ty) * scale; 160 const width = height * 0.6; 161 const s = { 162 cx, cy: h / 2 + (lift - 0.5) * (h / ty), 163 x0: cx - width / 2, width, height, 164 top: h / 2 + (lift - 0.5) * (h / ty) - height / 2, 165 }; 166 const col0 = Math.max(0, Math.floor(s.x0 / strip)); 167 const col1 = Math.min(zbuf.length - 1, Math.floor((s.x0 + width) / strip)); 168 for (let c = col0; c <= col1; c++) { 169 if (zbuf[c] <= ty) continue; // wall nearer than the sprite here 170 ctx.save(); 171 ctx.beginPath(); 172 ctx.rect(c * strip, 0, strip + 1, h); 173 ctx.clip(); 174 draw(s); 175 ctx.restore(); 176 } 177 } 178 179 /* ── transcript: one turn per completed action, read by /plan ───────── */ 180 function recordOutcome(outcome) { 181 if (!pending) return; // manual play: no observation/choice to record 182 Planner.record(transcript, 183 `Observation:\n${pending.state}\nChosen action: ${pending.choice}\nOutcome: ${outcome}`); 184 pending = null; 185 } 186 187 // forward() outcomes as third-person facts for the transcript. 188 const OUTCOME_TEXT = { 189 moved: "the player moved forward.", 190 bump: "the player bumped into a wall.", 191 locked: "the player tried a locked door.", 192 opened: "the player unlocked the door with the key and stepped through.", 193 key: "the player picked up the key.", 194 }; 195 196 /* ── SemIf decision loop (turn-based: one decision = one action) ────── */ 197 async function requestDecision() { 198 if (deciding || planning || mode !== "auto") return; 199 deciding = true; 200 const token = runToken; 201 setStatus("thinking…", ""); 202 const text = stateText(); 203 const bare = bareState(); // transcript records this, rules live in /plan context 204 $("rs-state-view").textContent = text; 205 const n = ++decisionN; 206 let result, elapsedMs, err = null; 207 try { 208 const t0 = performance.now(); 209 const resp = await fetch("/decide", { 210 method: "POST", headers: { "Content-Type": "application/json" }, 211 body: JSON.stringify({ id: `roomself-${n}`, state: text, question: QUESTION, options: optionsFor(state, layout) }), 212 }); 213 elapsedMs = performance.now() - t0; 214 const payload = JSON.parse(await resp.text()); 215 if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText); 216 result = payload; 217 } catch (e) { err = e; } 218 if (token !== runToken || mode !== "auto") return; // reset while we waited 219 deciding = false; 220 if (err) { 221 logError(n, err.message); 222 setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err"); 223 setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000); 224 return; 225 } 226 stats.decisions++; 227 const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]); 228 const [choice, p] = Planner.best(result); 229 logDecision(n, [choice, p], pairs, elapsedMs, text); 230 231 if (choice === Planner.INSUFFICIENT_ID) { 232 if (Planner.triggered(result, INSUFFICIENT_P)) { 233 // "I cannot decide": the planner writes full game rules, not a way out 234 // of this exact spot — end the attempt and restart the room so the 235 // model applies the rules from the start, on the same layout, with 236 // the transcript intact. 237 pending = null; 238 lose("The model cannot decide and asks for rules."); 239 void replanAndRetry("insufficient"); 240 return; 241 } 242 // Unreachable with INSUFFICIENT_P = 0 (any plurality plans), kept for 243 // symmetry with the platformer: a weak insufficient loses the argmax to 244 // the best real action, with a visible note. 245 const real = pairs.filter(([id]) => id !== Planner.INSUFFICIENT_ID) 246 .sort((a, b) => b[1] - a[1])[0]; 247 setStatus(`insufficient evidence (p ${p.toFixed(3)}) — taking best real action: ${real[0]}`, ""); 248 applyAction(real[0], bare, token); 249 return; 250 } 251 applyAction(choice, bare, token); 252 } 253 254 function applyAction(choice, observed, token) { 255 pending = { state: observed, choice }; 256 let outcome; 257 if (choice === "left" || choice === "right") { 258 state = turn(state, choice); 259 outcome = `the player turned ${choice}.`; 260 } else { 261 const r = forward(state, layout); 262 state = r.state; 263 if (r.outcome === "win") { 264 recordOutcome("the player reached the exit."); 265 win(); 266 return; 267 } 268 outcome = OUTCOME_TEXT[r.outcome]; 269 } 270 recordOutcome(outcome); 271 render(); refreshLabel(); 272 if (mode !== "auto") return; // applyAction may have won/lost 273 if (state.steps >= MAX_STEPS) { 274 recordOutcome("the player used up the step budget."); 275 lose(`No exit after ${MAX_STEPS} steps.`); 276 void replanAndRetry("steps"); 277 return; 278 } 279 setStatus(`playing… · step ${state.steps}/${MAX_STEPS}`, ""); 280 setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, STEP_PAUSE_MS); 281 } 282 283 /* ── /plan: derive rules from the transcript, then keep playing ─────── */ 284 async function replan(reason, token) { 285 planning = true; 286 const n = ++planN; 287 setPlanningStatus(reason === "steps" 288 ? "out of steps — planning rules from the transcript…" 289 : "insufficient evidence — planning rules from the transcript…"); 290 const { e, choice, ms } = addPlanEntry(n, reason); 291 let payload = null, err = null, elapsedMs = 0; 292 try { 293 const t0 = performance.now(); 294 // The endpoint is game-agnostic: Planner.context() wraps the simulation's 295 // one-line goal with the trigger note and the rules currently in effect — 296 // on repeat failures the planner can see and amend its own previous plan. 297 const trigger = reason === "steps" 298 ? "the actor used up the step budget and the attempt ended" 299 : "the actor declared the evidence insufficient and could not decide"; 300 payload = await Planner.request({ 301 id: `roomself-plan-${n}`, 302 prompt: Planner.context(SelfRoomRules.PLAN_GOAL, trigger, learnedRules), 303 transcript, 304 }); 305 elapsedMs = performance.now() - t0; 306 } catch (e2) { err = e2; } 307 if (token !== runToken) return false; // reset while planning: discard result 308 planning = false; 309 if (err) { 310 choice.textContent = "planning failed"; 311 const d = document.createElement("div"); d.className = "probs"; d.textContent = err.message; 312 e.appendChild(d); 313 setStatus(`planning failed: ${err.message} — press Reset or Start to continue`, "err"); 314 return false; 315 } 316 learnedRules = payload.rules; 317 $("rs-rules-view").value = learnedRules; 318 const usage = payload.usage || {}; 319 stats.plans++; 320 stats.planMs += elapsedMs; 321 stats.planCompletionTokens += usage.completion_tokens || 0; 322 stats.planPromptTokens += usage.prompt_tokens || 0; 323 stats.planReasoningTokens += (usage.completion_tokens_details || {}).reasoning_tokens || 0; 324 renderStats(); 325 choice.textContent = `→ rules learned (${count(usage.completion_tokens || 0)} tokens)`; 326 ms.textContent = `${elapsedMs.toFixed(0)} ms`; 327 // Planner output, expandable in place: the rules pane only ever shows the 328 // latest set, so the log entry keeps every plan (rules + thinking trace) 329 // inspectable. Click toggles between a preview and the full text. 330 const detailParts = ["rules:\n" + payload.rules]; 331 if (payload.reasoning) detailParts.push("thinking:\n" + payload.reasoning); 332 const detail = detailParts.join("\n\n"); 333 const d = document.createElement("div"); d.className = "probs expandable"; 334 let open = false; 335 const paint = () => { 336 d.textContent = (open ? detail : detail.slice(0, 240) + (detail.length > 240 ? "…" : "")) + 337 (detail.length > 240 ? (open ? " ▲" : " ▼") : ""); 338 }; 339 paint(); 340 d.addEventListener("click", () => { open = !open; paint(); }); 341 e.appendChild(d); 342 e.title = detail; // hover for the full trace 343 if (payload.truncated) { 344 const warn = document.createElement("div"); warn.className = "probs"; 345 warn.textContent = "warning: reply hit the server's token limit and was truncated"; 346 e.appendChild(warn); 347 } 348 setStatus("rules updated", ""); 349 return true; 350 } 351 352 // Failed attempt (step budget exhausted, or the model asked for rules): 353 // plan, then restart the room with the learned rules and go again. This 354 // continues the SAME run: stats and the Start-click timer keep going. The 355 // layout is unchanged — the model gets another try at the same room, now 356 // with rules. 357 async function replanAndRetry(reason) { 358 const token = runToken; 359 if (!await replan(reason, token)) return; 360 if (mode !== "lost" || token !== runToken) return; // user took over meanwhile 361 resetLevel(); 362 setStatus("restarting with learned rules…", ""); 363 mode = "auto"; 364 $("rs-start").disabled = true; 365 void requestDecision(); 366 } 367 368 /* ── decision log ───────────────────────────────────────────────────── */ 369 function clearLog() { const l = $("rs-log"); while (l.firstChild) l.removeChild(l.firstChild); } 370 function addEntry(n, cls) { 371 const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`; 372 const head = document.createElement("div"); head.className = "head"; 373 const num = document.createElement("span"); num.className = "n"; 374 num.textContent = `#${n} · ${stateMode}`; 375 const choice = document.createElement("span"); choice.className = "choice"; 376 const ms = document.createElement("span"); ms.className = "ms"; 377 head.append(num, choice, ms); e.appendChild(head); 378 $("rs-log").prepend(e); 379 return { e, choice, ms }; 380 } 381 function logDecision(n, best, pairs, elapsedMs, sentState) { 382 const { e, choice, ms } = addEntry(n); 383 choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`; 384 ms.textContent = `${elapsedMs.toFixed(0)} ms`; 385 const probs = document.createElement("div"); probs.className = "probs"; 386 probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" "); 387 e.appendChild(probs); 388 const bar = document.createElement("div"); bar.className = "bar"; 389 const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`; 390 bar.appendChild(fill); e.appendChild(bar); 391 e.title = sentState; 392 } 393 function addPlanEntry(n, reason) { 394 const entry = addEntry(n, "plan"); 395 entry.e.firstChild.firstChild.textContent = `#plan ${n} · ${reason} · ${stateMode}`; 396 return entry; 397 } 398 function logError(n, msg) { 399 const { e, choice } = addEntry(n, "error"); 400 choice.textContent = "request failed"; 401 const d = document.createElement("div"); d.className = "probs"; d.textContent = msg; 402 e.appendChild(d); 403 } 404 405 /* ── status / stats / win / lose / reset ────────────────────────────── */ 406 function setStatus(t, cls) { 407 const s = $("rs-status"); 408 s.className = `status${cls ? " " + cls : ""}`; 409 s.textContent = t; 410 } 411 // Planning status: same text and size, but each letter becomes a span with a 412 // staggered negative animation delay, so the CSS rainbow travels down the 413 // text as a wave while /plan runs. Any later setStatus() restores plain text. 414 function setPlanningStatus(t) { 415 const s = $("rs-status"); 416 s.className = "status planning"; 417 s.replaceChildren(...[...t].map((ch, i) => { 418 const span = document.createElement("span"); 419 span.textContent = ch; 420 span.style.setProperty("--i", i); 421 return span; 422 })); 423 } 424 425 // Completion summary, refreshed after every plan and at the end of the run. 426 // Wall time is measured from the Start click and spans auto-restarts. 427 function renderStats(result) { 428 const lines = []; 429 if (result) lines.push(`result: ${result}`); 430 if (stats.startedAt !== null) { 431 lines.push(`wall time: ${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s (from Start click)`); 432 } 433 lines.push(`decisions: ${stats.decisions}`); 434 lines.push(`failures: ${stats.failures}`); 435 lines.push(`plans: ${stats.plans}`); 436 if (stats.plans > 0) { 437 const reasoning = stats.planReasoningTokens ? ` (${count(stats.planReasoningTokens)} reasoning)` : ""; 438 lines.push(`planner tokens: ${count(stats.planCompletionTokens)} completion${reasoning} · ${count(stats.planPromptTokens)} prompt`); 439 lines.push(`planning time: ${(stats.planMs / 1000).toFixed(1)} s`); 440 } 441 $("rs-stats").textContent = lines.join("\n"); 442 } 443 function win() { 444 mode = "won"; 445 const seconds = stats.startedAt !== null ? `${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s` : "—"; 446 const thinking = stats.plans > 0 447 ? ` · ${stats.plans} plan${stats.plans === 1 ? "" : "s"}, ${count(stats.planCompletionTokens)} planner tokens` 448 : ""; 449 setStatus(`🏁 exit reached in ${state.steps} steps · ${seconds} · ${stats.decisions} decision(s) · ${stats.failures} failure(s)${thinking} — press Reset to run it again`, "win"); 450 renderStats("success"); 451 render(); refreshLabel(); 452 } 453 function lose(msg) { 454 mode = "lost"; 455 stats.failures++; 456 renderStats(); 457 setStatus(`✗ ${msg}`, "err"); 458 render(); refreshLabel(); 459 } 460 // Room state only: keeps learning (rules, transcript) AND run stats, so the 461 // auto-restart after the budget runs out continues the same run. Full Reset 462 // below clears everything. 463 function resetLevel() { 464 runToken++; 465 deciding = false; planning = false; 466 state = newState(layout); 467 mode = "idle"; decisionN = 0; pending = null; 468 render(); refreshLabel(); 469 } 470 // Full reset: room + learned rules + transcript + run stats. The next Start 471 // begins a fresh run from a blank slate. 472 function reset() { 473 resetLevel(); 474 learnedRules = ""; 475 discovered.clear(); 476 transcript = Planner.fresh(); 477 stats = freshStats(); 478 clearLog(); // the decision/plan log is part of the run, not learning 479 $("rs-start").disabled = false; 480 $("rs-rules-view").value = ""; 481 $("rs-stats").textContent = "(run not started)"; 482 setStatus("idle — press Start", ""); 483 $("rs-state-view").textContent = stateText(); // idle preview: same text /decide would receive 484 } 485 function forgetRules() { 486 learnedRules = ""; 487 transcript = Planner.fresh(); 488 $("rs-rules-view").value = ""; 489 setStatus("rules forgotten", ""); 490 } 491 492 // The rules pane is an editor: whatever is in it IS the rules, hand-typed or 493 // pasted, so a good plan can be captured, tweaked, or replayed for a 494 // reproducible run. Edits apply to the very next decision. 495 $("rs-rules-view").addEventListener("input", (e) => { learnedRules = e.target.value; }); 496 497 /* ── controls ───────────────────────────────────────────────────────── */ 498 const stateModes = ["fpp", "map"]; 499 const setStateMode = (m) => { 500 stateMode = m; 501 for (const name of stateModes) $("rs-mode-" + name).className = name === m ? "on" : ""; 502 }; 503 for (const name of stateModes) $("rs-mode-" + name).addEventListener("click", () => setStateMode(name)); 504 505 $("rs-start").addEventListener("click", () => { 506 if (mode === "auto") return; 507 if (mode === "won" || mode === "lost") reset(); 508 stats = freshStats(); // a Start click begins a new timed run 509 stats.startedAt = performance.now(); 510 renderStats(); 511 mode = "auto"; 512 $("rs-start").disabled = true; 513 void requestDecision(); 514 }); 515 $("rs-reset").addEventListener("click", reset); 516 $("rs-forget").addEventListener("click", forgetRules); 517 518 /* ── seeded layout (reproducible) ───────────────────────────────────── */ 519 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; }; 520 function applySeed(seedNum) { 521 layout = makeLayout(seedNum); 522 reset(); 523 } 524 $("rs-randomize").addEventListener("click", () => { 525 const s = (Math.random() * 0x100000000) >>> 0; 526 $("rs-seed").value = String(s); 527 applySeed(s); 528 }); 529 $("rs-seed").addEventListener("change", () => { 530 const s = parseSeed($("rs-seed").value); 531 if (s !== null) applySeed(s); 532 }); 533 534 /* ── manual drive ───────────────────────────────────────────────────── */ 535 addEventListener("keydown", (e) => { 536 if (!roomActive()) return; 537 if (mode !== "idle" && mode !== "manual") return; 538 let acted = false; 539 if (e.key === "ArrowLeft" || e.key === "a") { state = turn(state, "left"); acted = true; } 540 else if (e.key === "ArrowRight" || e.key === "d") { state = turn(state, "right"); acted = true; } 541 else if (e.key === "ArrowUp" || e.key === "w" || e.key === " ") { 542 e.preventDefault(); 543 const r = forward(state, layout); 544 state = r.state; 545 acted = true; 546 if (r.outcome === "win") { win(); return; } 547 } 548 if (!acted) return; 549 if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); } 550 render(); refreshLabel(); 551 // Mirror the decision text into the pane, exactly as requestDecision() 552 // composes it (rules + bare state) — manual play doubles as a debugger 553 // for the prompt. 554 $("rs-state-view").textContent = stateText(); 555 if (state.steps >= MAX_STEPS) { 556 lose(`No exit after ${MAX_STEPS} steps.`); 557 void replanAndRetry("steps"); 558 } 559 }); 560 561 reset(); 562 })();