semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

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 })();