semif-api-rocm

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

game.js (11977B)


      1 /* Platformer demo — the game loop lives inside a host page (index.html) as the
      2  * "Platformer demo" tab. Wrapped in an IIFE so its local `const $`
      3  * (getElementById) never collides with app.js's top-level `$` (querySelector).
      4  * Requires game-rules.js to have defined the global `GameRules` first. */
      5 (() => {
      6 "use strict";
      7 
      8 /* ── level & physics (cells: column 0..67, row 0 top .. 7 ground line) ─ */
      9 // PITS and MAX_JUMPS are read via GameRules.* getters at use time (the pit
     10 // layout can be re-randomised at runtime), so they are NOT destructured here.
     11 const { W, H, GROUND, GOAL, START_X, JUMP_VY, floorAt, QUESTION, OPTIONS } = GameRules;
     12 const TICK_MS = 90;
     13 
     14 /* ── mutable game state ─────────────────────────────────────────────── */
     15 const player = { x: START_X, y: GROUND, vy: 0, onGround: true };
     16 let running = false;      // auto mode: player currently holds "run right"
     17 let mode = "idle";        // idle | auto | manual | won | lost
     18 let deciding = false;
     19 let stateMode = "guided";  // "guided" = prose + hint; "unguided" = prose facts; "runlength" = run-length encoding; "ascii" = symbolic row (none but guided include a hint)
     20 let jumpsLeft = GameRules.MAX_JUMPS;
     21 let deniedStreak = 0;   // consecutive jump-attempts with an empty budget
     22 let tickTimer = null;
     23 let decisionN = 0;
     24 let runToken = 0;         // invalidates in-flight decisions on reset
     25 
     26 const $ = (id) => document.getElementById(id);
     27 const cv = $("cv"), ctx = cv.getContext("2d");
     28 const CELL = cv.width / W, ROWH = cv.height / H;
     29 function refreshLevelLabel() {
     30   $("level-label").textContent =
     31     `Level — pits at ${GameRules.PITS.map(([a, b]) => `${a}–${b + 1}`).join(", ")}; flag at ${GOAL}`;
     32 }
     33 refreshLevelLabel();
     34 
     35 // The game shares a page with text fields, so its keyboard shortcuts must not
     36 // swallow typing or drive the game from another tab. Only act while the game
     37 // panel is visible and focus is not in an editable element.
     38 const gameActive = () => {
     39   const t = document.activeElement;
     40   if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
     41   return !$("panel-game").hidden;
     42 };
     43 
     44 /* ── the ONE textual state: source for rendering prompts AND the API ── */
     45 function stateText() {
     46   return GameRules.stateText(player, jumpsLeft, stateMode);
     47 }
     48 
     49 /* ── rendering (uses the same coordinates as the state text) ────────── */
     50 function render() {
     51   ctx.clearRect(0, 0, cv.width, cv.height);
     52   for (let c = 0; c < W; c++) {
     53     if (!floorAt(c)) {   // pit: dark shaft
     54       ctx.fillStyle = "#0a0c11";
     55       ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
     56       continue;
     57     }
     58     ctx.fillStyle = "#2a3140";
     59     ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
     60     ctx.fillStyle = "#3a4356";
     61     ctx.fillRect(c * CELL, GROUND * ROWH, CELL, 3);
     62   }
     63   // goal flag
     64   const gx = GOAL * CELL;
     65   ctx.strokeStyle = "#56d364"; ctx.lineWidth = 2;
     66   ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH); ctx.lineTo(gx, GROUND * ROWH - 34); ctx.stroke();
     67   ctx.fillStyle = "#56d364";
     68   ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH - 34);
     69   ctx.lineTo(gx + 18, GROUND * ROWH - 27); ctx.lineTo(gx, GROUND * ROWH - 20); ctx.fill();
     70   // player: blue when grounded, amber mid-jump
     71   ctx.fillStyle = player.onGround ? "#69c0ff" : "#e3a008";
     72   ctx.beginPath(); ctx.arc(player.x * CELL, player.y * ROWH - 8, 8, 0, Math.PI * 2); ctx.fill();
     73 }
     74 
     75 /* ── physics tick ───────────────────────────────────────────────────── */
     76 function tick() {
     77   if (mode !== "auto" && mode !== "manual") return;
     78   const next = GameRules.advance(player, mode === "auto" ? running : keyRun);
     79   Object.assign(player, next.player);
     80   if (next.fell) { lose("The player fell into a pit."); return; }
     81   if (player.x >= GOAL) { win(); return; }
     82   render();
     83   // Mirror the exact state text into the pane on every tick — in manual mode
     84   // this is the debugging view of what the decider would receive right now.
     85   $("state-view").textContent = stateText();
     86   // Running commits to one tick. A jump commits until landing.
     87   if (mode === "auto" && player.onGround) { stopTicks(); void requestDecision(); }
     88 }
     89 
     90 function jump() {
     91   if (!player.onGround || jumpsLeft <= 0 || mode === "won" || mode === "lost") return;
     92   jumpsLeft--; deniedStreak = 0;
     93   player.onGround = false; player.vy = JUMP_VY;
     94 }
     95 
     96 /* ── SemIf decision loop ────────────────────────────────────────────── */
     97 async function requestDecision() {
     98   if (deciding || mode !== "auto") return;
     99   deciding = true;
    100   const token = runToken;
    101   stopTicks();                       // physics freezes while we think
    102   setStatus("thinking…", "");
    103   const text = stateText();
    104   $("state-view").textContent = text;
    105   const n = ++decisionN;
    106   let result, elapsedMs, err = null;
    107   try {
    108     const t0 = performance.now();
    109     const resp = await fetch("/decide", {
    110       method: "POST", headers: { "Content-Type": "application/json" },
    111       body: JSON.stringify({ id: `platformer-${n}`, state: text, question: QUESTION, options: OPTIONS }),
    112     });
    113     elapsedMs = performance.now() - t0;
    114     const payload = JSON.parse(await resp.text());
    115     if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
    116     result = payload;
    117   } catch (e) { err = e; }
    118   if (token !== runToken || mode !== "auto") return;   // reset (or re-run) while we waited
    119   deciding = false;
    120   if (err) {
    121     logError(n, err.message);
    122     setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
    123     setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
    124     return;
    125   }
    126   const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
    127   const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a));
    128   logDecision(n, best, pairs, elapsedMs, text);
    129   setStatus(`playing… · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} left`, "");
    130   running = best[0] === "run";
    131   if (running) deniedStreak = 0;
    132   if (best[0] === "jump") {
    133     if (jumpsLeft > 0) {
    134       jump();
    135     } else {
    136       deniedStreak++; running = false;
    137       if (deniedStreak >= 3) { lose("The model is out of jumps and keeps trying to jump."); return; }
    138       setStatus(`jump denied — 0 jumps left (asked ${deniedStreak}× in a row)`, "err");
    139     }
    140   }
    141   if (mode !== "auto") return;
    142   startTicks();
    143   render();
    144 }
    145 
    146 /* ── decision log (DOM built with textContent, like the main UI) ────── */
    147 function clearLog() { const l = $("log"); while (l.firstChild) l.removeChild(l.firstChild); }
    148 function addEntry(n, cls) {
    149   const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
    150   const head = document.createElement("div"); head.className = "head";
    151   const num = document.createElement("span"); num.className = "n";
    152   num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`;
    153   const choice = document.createElement("span"); choice.className = "choice";
    154   const ms = document.createElement("span"); ms.className = "ms";
    155   head.append(num, choice, ms); e.appendChild(head);
    156   $("log").prepend(e);
    157   return { e, choice, ms };
    158 }
    159 function logDecision(n, best, pairs, elapsedMs, sentState) {
    160   const { e, choice, ms } = addEntry(n);
    161   choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
    162   ms.textContent = `${elapsedMs.toFixed(0)} ms`;
    163   const probs = document.createElement("div"); probs.className = "probs";
    164   probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join("   ");
    165   e.appendChild(probs);
    166   const bar = document.createElement("div"); bar.className = "bar";
    167   const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
    168   bar.appendChild(fill); e.appendChild(bar);
    169   e.title = sentState;   // hover to see the exact state that produced this call
    170 }
    171 function logError(n, msg) {
    172   const { e, choice } = addEntry(n, "error");
    173   choice.textContent = "request failed";
    174   const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
    175   e.appendChild(d);
    176 }
    177 
    178 /* ── status / win / lose / reset ────────────────────────────────────── */
    179 function setStatus(t, cls) { const s = $("status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; }
    180 function startTicks() { if (!tickTimer) tickTimer = setInterval(tick, TICK_MS); }
    181 function stopTicks() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }
    182 function win() {
    183   mode = "won"; stopTicks(); running = false;
    184   setStatus(`🏁 level complete in ${decisionN} SemIf decision(s), ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} to spare — press Reset to run it again`, "win");
    185   render();
    186 }
    187 function lose(msg) {
    188   mode = "lost"; stopTicks(); running = false;
    189   setStatus(`✗ ${msg} The model chose badly — press Reset to retry.`, "err");
    190   render();
    191 }
    192 function reset() {
    193   runToken++;
    194   stopTicks(); deciding = false; running = false; keyRun = false;
    195   jumpsLeft = GameRules.MAX_JUMPS; deniedStreak = 0;
    196   player.x = START_X; player.y = GROUND; player.vy = 0; player.onGround = true;
    197   mode = "idle"; decisionN = 0;
    198   $("btn-start").disabled = false;
    199   setStatus("idle — press Start", "");
    200   $("state-view").textContent = stateText();   // idle preview: same text /decide would receive
    201   clearLog();
    202   const empty = document.createElement("div"); empty.className = "empty";
    203   empty.textContent = "No decisions yet."; $("log").appendChild(empty);
    204   render();
    205 }
    206 
    207 /* ── controls ───────────────────────────────────────────────────────── */
    208 const stateModes = ["guided", "unguided", "runlength", "ascii"];
    209 const setStateMode = (m) => {
    210   stateMode = m;
    211   for (const name of stateModes) $("mode-" + name).className = name === m ? "on" : "";
    212 };
    213 for (const name of stateModes) $("mode-" + name).addEventListener("click", () => setStateMode(name));
    214 
    215 $("btn-start").addEventListener("click", () => {
    216   if (mode === "auto") return;
    217   if (mode === "won" || mode === "lost") reset();
    218   mode = "auto";
    219   $("btn-start").disabled = true;
    220   void requestDecision();
    221 });
    222 $("btn-reset").addEventListener("click", reset);
    223 
    224 /* ── random pit layout (seeded, reproducible) ──────────────────── */
    225 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
    226 function applySeed(seedNum) {
    227   GameRules.setPits(GameRules.makePits(seedNum));
    228   refreshLevelLabel();
    229   reset();                 // floorAt/MAX_JUMPS already reflect the new layout
    230 }
    231 $("btn-randomize").addEventListener("click", () => {
    232   const s = (Math.random() * 0x100000000) >>> 0;
    233   $("seed").value = String(s);
    234   applySeed(s);
    235 });
    236 // Type a seed and press Enter (or blur) to reproduce that exact layout.
    237 $("seed").addEventListener("change", () => {
    238   const s = parseSeed($("seed").value);
    239   if (s !== null) applySeed(s);
    240 });
    241 
    242 let keyRun = false;
    243 addEventListener("keydown", (e) => {
    244   if (!gameActive()) return;
    245   if (mode === "auto" || mode === "won" || mode === "lost") return;
    246   if (e.key === "ArrowRight" || e.key === "d") {
    247     keyRun = true;
    248     if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
    249   }
    250   if (e.key === " " || e.key === "ArrowUp" || e.key === "w") {
    251     e.preventDefault();
    252     if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
    253     jump();
    254   }
    255 });
    256 addEventListener("keyup", (e) => { if (e.key === "ArrowRight" || e.key === "d") keyRun = false; });
    257 
    258 reset();
    259 })();