semif-api-rocm

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

room.js (13359B)


      1 /* Puzzle room demo — a first-person view of a grid room; SemIf makes every
      2  * move from the text state alone. Wrapped in an IIFE so its local `$`
      3  * (getElementById) never collides with app.js's top-level `$` (querySelector).
      4  * Requires room-rules.js to have defined the global `RoomRules` first. */
      5 (() => {
      6 "use strict";
      7 
      8 const { W, H, MAX_STEPS, DIRS, makeLayout, newState, turn, forward, stateText, QUESTION, optionsFor } = RoomRules;
      9 const STEP_PAUSE_MS = 400;   // pacing between decisions so a human can watch
     10 
     11 /* ── mutable game state ─────────────────────────────────────────────── */
     12 let layout = makeLayout(1);          // deterministic default; Randomize reseeds
     13 let state = newState(layout);
     14 let mode = "idle";                   // idle | auto | manual | won | lost
     15 let deciding = false;
     16 let stateMode = "guided";            // "guided" = FPP + compass hint; "fpp" = prose only; "map" = top-down ASCII
     17 // POIs the cone has ever revealed this layout: Known-line bearings persist
     18 // until a new layout is dealt (Reset / Randomize / Seed).
     19 const discovered = new Set();
     20 let decisionN = 0;
     21 let runToken = 0;                    // invalidates in-flight decisions on reset
     22 
     23 const $ = (id) => document.getElementById(id);
     24 const cv = $("room-cv"), ctx = cv.getContext("2d");
     25 
     26 function refreshLabel() {
     27   $("room-label").textContent =
     28     `Room — seed ${layout.seed} · key: ${state.hasKey ? "found" : "missing"} · ` +
     29     `door: ${state.doorOpen ? "open" : "locked"} · steps: ${state.steps}/${MAX_STEPS}`;
     30 }
     31 
     32 // The game shares a page with text fields, so its keyboard shortcuts must not
     33 // swallow typing or drive the game from another tab.
     34 const roomActive = () => {
     35   const t = document.activeElement;
     36   if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
     37   return !$("panel-room").hidden;
     38 };
     39 
     40 /* ── first-person rendering (DDA raycast over the same grid) ────────── */
     41 const RAYS = 240;                    // FOV comes from RoomRules.PLANE (120°)
     42 
     43 function render() {
     44   const w = cv.width, h = cv.height;
     45   ctx.fillStyle = "#10141d";                       // ceiling
     46   ctx.fillRect(0, 0, w, h / 2);
     47   ctx.fillStyle = "#242b3a";                       // floor
     48   ctx.fillRect(0, h / 2, w, h / 2);
     49 
     50   const dir = DIRS[state.dir];
     51   const planeX = -dir.dy * RoomRules.PLANE, planeY = dir.dx * RoomRules.PLANE;
     52   const strip = w / RAYS;
     53   const zbuf = new Float32Array(RAYS);
     54 
     55   for (let i = 0; i < RAYS; i++) {
     56     const cam = 2 * i / RAYS - 1;
     57     // RoomRules.cast is the single definition of sight-blocking: same math
     58     // the FPP state text uses, so the canvas and "In view" can't drift.
     59     const hit = RoomRules.cast(state, layout, dir.dx + planeX * cam, dir.dy + planeY * cam);
     60     zbuf[i] = hit.dist;
     61     const lineH = h / hit.dist;
     62     const shade = Math.max(0.2, 1 - hit.dist / 12) * (hit.side === 1 ? 0.8 : 1);
     63     const base = hit.cell === "D" ? [152, 98, 44] : [104, 120, 152];
     64     ctx.fillStyle = `rgb(${base.map((c) => Math.round(c * shade)).join(",")})`;
     65     ctx.fillRect(Math.floor(i * strip), h / 2 - lineH / 2, Math.ceil(strip), lineH);
     66   }
     67 
     68   drawSprite(zbuf, strip, layout.exit, true, (s) => {                      // exit: tall green portal
     69     ctx.fillStyle = "#1d3a24";
     70     ctx.fillRect(s.x0, s.top, s.width, s.height);
     71     ctx.fillStyle = "#56d364";
     72     ctx.fillRect(s.x0 + s.width * 0.15, s.top + s.height * 0.1, s.width * 0.7, s.height * 0.8);
     73   });
     74   if (!state.hasKey) {
     75     drawSprite(zbuf, strip, layout.key, true, (s) => {                   // key: small yellow disc
     76       ctx.fillStyle = "#e3b341";
     77       ctx.beginPath();
     78       ctx.arc(s.cx, s.cy, Math.max(2, s.width * 0.4), 0, Math.PI * 2);
     79       ctx.fill();
     80     }, 0.30, 0.55);
     81   }
     82 
     83   // HUD: facing + carry, so the human view matches what the state asserts.
     84   ctx.fillStyle = "rgba(11, 14, 20, 0.65)";
     85   ctx.fillRect(8, 8, 236, 22);
     86   ctx.fillStyle = "#c9d1d9";
     87   ctx.font = "13px monospace";
     88   ctx.fillText(`facing ${DIRS[state.dir].name} · ${state.hasKey ? "key ✓" : "no key"}`, 14, 23);
     89 }
     90 
     91 // Project a cell-center billboard into the view, clipping each column against
     92 // the wall z-buffer. scale = height fraction of a wall at that distance,
     93 // lift = vertical centering (0.5 = middle).
     94 function drawSprite(zbuf, strip, cell, visible, draw, scale = 0.85, lift = 0.5) {
     95   if (!visible) return;
     96   const { tx, ty } = RoomRules.project(state, cell);
     97   if (ty <= 0.15) return;
     98   const w = cv.width, h = cv.height;
     99   const cx = (w / 2) * (1 + tx / ty);
    100   const height = (h / ty) * scale;
    101   const width = height * 0.6;
    102   const s = {
    103     cx, cy: h / 2 + (lift - 0.5) * (h / ty),
    104     x0: cx - width / 2, width, height,
    105     top: h / 2 + (lift - 0.5) * (h / ty) - height / 2,
    106   };
    107   const col0 = Math.max(0, Math.floor(s.x0 / strip));
    108   const col1 = Math.min(zbuf.length - 1, Math.floor((s.x0 + width) / strip));
    109   for (let c = col0; c <= col1; c++) {
    110     if (zbuf[c] <= ty) continue;      // wall nearer than the sprite here
    111     ctx.save();
    112     ctx.beginPath();
    113     ctx.rect(c * strip, 0, strip + 1, h);
    114     ctx.clip();
    115     draw(s);
    116     ctx.restore();
    117   }
    118 }
    119 
    120 /* ── SemIf decision loop ────────────────────────────────────────────── */
    121 async function requestDecision() {
    122   if (deciding || mode !== "auto") return;
    123   deciding = true;
    124   const token = runToken;
    125   setStatus("thinking…", "");
    126   // Fold whatever the cone currently sees into the discovered set, so the
    127   // observation that reveals a POI is the last one without its bearing.
    128   for (const o of RoomRules.visibleObjects(state, layout)) {
    129     const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name];
    130     if (id) discovered.add(id);
    131   }
    132   const text = stateText(state, layout, stateMode, discovered);
    133   $("room-state-view").textContent = text;  const n = ++decisionN;
    134   let result, elapsedMs, err = null;
    135   try {
    136     const t0 = performance.now();
    137     const resp = await fetch("/decide", {
    138       method: "POST", headers: { "Content-Type": "application/json" },
    139       body: JSON.stringify({ id: `room-${n}`, state: text, question: QUESTION, options: optionsFor(state, layout) }),
    140     });
    141     elapsedMs = performance.now() - t0;
    142     const payload = JSON.parse(await resp.text());
    143     if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
    144     result = payload;
    145   } catch (e) { err = e; }
    146   if (token !== runToken || mode !== "auto") return;   // reset while we waited
    147   deciding = false;
    148   if (err) {
    149     logError(n, err.message);
    150     setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
    151     setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
    152     return;
    153   }
    154   const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
    155   const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a));
    156   logDecision(n, best, pairs, elapsedMs, text);
    157   applyAction(best[0]);
    158   render(); refreshLabel();
    159   if (mode !== "auto") return;                          // applyAction may have won/lost
    160   if (state.steps >= MAX_STEPS) { lose(`No exit after ${MAX_STEPS} steps.`); return; }
    161   setStatus(`playing… · step ${state.steps}/${MAX_STEPS}`, "");
    162   setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, STEP_PAUSE_MS);
    163 }
    164 
    165 function applyAction(action) {
    166   if (action === "left" || action === "right") {
    167     state = turn(state, action);
    168   } else {
    169     const r = forward(state, layout);
    170     state = r.state;
    171     if (r.outcome === "win") { win(); return; }
    172   }
    173 }
    174 
    175 /* ── decision log (DOM built with textContent, like the main UI) ────── */
    176 function clearLog() { const l = $("room-log"); while (l.firstChild) l.removeChild(l.firstChild); }
    177 function addEntry(n, cls) {
    178   const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
    179   const head = document.createElement("div"); head.className = "head";
    180   const num = document.createElement("span"); num.className = "n";
    181   num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`;
    182   const choice = document.createElement("span"); choice.className = "choice";
    183   const ms = document.createElement("span"); ms.className = "ms";
    184   head.append(num, choice, ms); e.appendChild(head);
    185   $("room-log").prepend(e);
    186   return { e, choice, ms };
    187 }
    188 function logDecision(n, best, pairs, elapsedMs, sentState) {
    189   const { e, choice, ms } = addEntry(n);
    190   choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
    191   ms.textContent = `${elapsedMs.toFixed(0)} ms`;
    192   const probs = document.createElement("div"); probs.className = "probs";
    193   probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join("   ");
    194   e.appendChild(probs);
    195   const bar = document.createElement("div"); bar.className = "bar";
    196   const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
    197   bar.appendChild(fill); e.appendChild(bar);
    198   e.title = sentState;
    199 }
    200 function logError(n, msg) {
    201   const { e, choice } = addEntry(n, "error");
    202   choice.textContent = "request failed";
    203   const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
    204   e.appendChild(d);
    205 }
    206 
    207 /* ── status / win / lose / reset ────────────────────────────────────── */
    208 function setStatus(t, cls) { const s = $("room-status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; }
    209 function win() {
    210   mode = "won"; deciding = false;
    211   setStatus(`🏁 exit reached in ${state.steps} steps (${decisionN} SemIf decision(s)) — press Reset to try another seed`, "win");
    212   render(); refreshLabel();
    213 }
    214 function lose(msg) {
    215   mode = "lost"; deciding = false;
    216   setStatus(`✗ ${msg} The model is lost — press Reset to retry.`, "err");
    217   render(); refreshLabel();
    218 }
    219 function reset() {
    220   runToken++;
    221   deciding = false;
    222   state = newState(layout);
    223   decisionN = 0;
    224   mode = "idle";
    225   discovered.clear();
    226   $("room-start").disabled = false;
    227   setStatus("idle — press Start", "");
    228   $("room-state-view").textContent = stateText(state, layout, stateMode, discovered);   // idle preview: same text /decide would receive
    229   clearLog();
    230   const empty = document.createElement("div"); empty.className = "empty";
    231   empty.textContent = "No decisions yet.";
    232   $("room-log").appendChild(empty);
    233   render(); refreshLabel();
    234 }
    235 
    236 /* ── controls ───────────────────────────────────────────────────────── */
    237 const stateModes = ["guided", "fpp", "map"];
    238 const setStateMode = (m) => {
    239   stateMode = m;
    240   for (const name of stateModes) $("room-mode-" + name).className = name === m ? "on" : "";
    241 };
    242 for (const name of stateModes) $("room-mode-" + name).addEventListener("click", () => setStateMode(name));
    243 
    244 $("room-start").addEventListener("click", () => {
    245   if (mode === "auto") return;
    246   if (mode === "won" || mode === "lost") reset();
    247   mode = "auto";
    248   $("room-start").disabled = true;
    249   void requestDecision();
    250 });
    251 $("room-reset").addEventListener("click", reset);
    252 
    253 /* ── seeded layout (reproducible) ───────────────────────────────────── */
    254 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
    255 function applySeed(seedNum) {
    256   layout = makeLayout(seedNum);
    257   reset();
    258 }
    259 $("room-randomize").addEventListener("click", () => {
    260   const s = (Math.random() * 0x100000000) >>> 0;
    261   $("room-seed").value = String(s);
    262   applySeed(s);
    263 });
    264 $("room-seed").addEventListener("change", () => {
    265   const s = parseSeed($("room-seed").value);
    266   if (s !== null) applySeed(s);
    267 });
    268 
    269 /* ── manual drive ───────────────────────────────────────────────────── */
    270 addEventListener("keydown", (e) => {
    271   if (!roomActive()) return;
    272   if (mode !== "idle" && mode !== "manual") return;
    273   let acted = false;
    274   if (e.key === "ArrowLeft" || e.key === "a") { state = turn(state, "left"); acted = true; }
    275   else if (e.key === "ArrowRight" || e.key === "d") { state = turn(state, "right"); acted = true; }
    276   else if (e.key === "ArrowUp" || e.key === "w" || e.key === " ") {
    277     e.preventDefault();
    278     const r = forward(state, layout);
    279     state = r.state;
    280     acted = true;
    281     if (r.outcome === "win") { win(); return; }
    282   }
    283   if (!acted) return;
    284   if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); }
    285   render(); refreshLabel();
    286   // Mirror the decision text into the pane, exactly as a /decide call would
    287   // compose it — manual play doubles as a debugger for the prompt.
    288   for (const o of RoomRules.visibleObjects(state, layout)) {
    289     const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name];
    290     if (id) discovered.add(id);
    291   }
    292   $("room-state-view").textContent = stateText(state, layout, stateMode, discovered);
    293   if (state.steps >= MAX_STEPS) lose(`No exit after ${MAX_STEPS} steps.`);
    294 });
    295 
    296 reset();
    297 })();