Spaces:
Runtime error
Runtime error
feat(web): show handover memory + persona rubric for LLM spectate (same as human play); highlight each entity's behaviour as a courier passes it
d42e3af | <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>AgentnessBench — Play</title> | |
| <style> | |
| :root { --bg:#11131a; --fg:#e8e8ee; --muted:#8a90a2; --line:#2a2e3a; } | |
| * { box-sizing: border-box; } | |
| body { margin:0; background:var(--bg); color:var(--fg); | |
| font:14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| display:flex; min-height:100vh; align-items:center; justify-content:center; } | |
| .card { background:#171a23; border:1px solid var(--line); border-radius:12px; | |
| padding:24px; width:min(720px, 94vw); } | |
| h1 { font-size:18px; margin:0 0 16px; } | |
| label { display:block; margin:10px 0 4px; color:var(--muted); } | |
| select, input { background:#0e1017; color:var(--fg); border:1px solid var(--line); | |
| border-radius:6px; padding:6px 8px; font:inherit; } | |
| .row { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-end; } | |
| button { background:#2a3140; color:var(--fg); border:1px solid var(--line); | |
| border-radius:8px; padding:8px 14px; font:inherit; cursor:pointer; } | |
| button:hover { background:#343c4e; } | |
| button.primary { background:#3b5bdb; border-color:#3b5bdb; } | |
| #grid { display:grid; gap:2px; margin:18px auto; width:max-content; | |
| background:var(--line); padding:2px; border-radius:6px; } | |
| .cell { width:var(--cs,30px); height:var(--cs,30px); border-radius:3px; } | |
| .status { color:var(--muted); display:flex; justify-content:space-between; } | |
| .pad { display:grid; grid-template-columns:repeat(3,56px); gap:8px; | |
| justify-content:center; margin-top:12px; } | |
| .pad button { height:48px; } | |
| .toggle button { padding:4px 9px; border:1px solid var(--line); background:#0e1017; | |
| color:var(--muted); border-radius:6px; margin-right:4px; cursor:pointer; } | |
| .toggle button.active { background:#3b5bdb; border-color:#3b5bdb; color:#fff; } | |
| .pad .blank { visibility:hidden; } | |
| .hidden { display:none; } | |
| .outcome { font-size:22px; text-align:center; margin:8px 0 16px; } | |
| .win { color:#4FCC30; } .lose { color:#F93C31; } | |
| table { width:100%; border-collapse:collapse; margin-top:10px; } | |
| th, td { border-bottom:1px solid var(--line); padding:4px 6px; text-align:right; } | |
| th:first-child, td:first-child { text-align:left; } | |
| .diag { color:#FFDC00; } | |
| .metrics{display:flex;gap:12px;margin:12px 0} | |
| .metric{flex:1;background:#0f1218;border:1px solid var(--line);border-radius:8px;padding:10px;text-align:center} | |
| .metric b{display:block;font-size:20px;color:var(--fg)} .metric span{font-size:12px;color:var(--muted)} | |
| td.approx{color:var(--muted)} | |
| #rubric{margin:6px 0;font-size:13px} | |
| #rubric .rubhead{color:var(--fg);margin-bottom:4px} | |
| #rubric table{width:100%;border-collapse:collapse} | |
| #rubric th{color:var(--muted);font-weight:normal;border-bottom:1px solid var(--line);padding:3px 6px} | |
| #rubric td{text-align:center;padding:4px 6px;border-bottom:1px solid var(--line);transition:background .15s} | |
| #rubric td.rub-active{background:#3b5bdb;color:#fff;border-radius:4px;font-weight:bold} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h1>AgentnessBench</h1> | |
| <!-- START SCREEN --> | |
| <div id="start"> | |
| <div class="row"> | |
| <div><label>scenario</label><select id="scenario"></select></div> | |
| <div><label>difficulty</label><select id="difficulty"></select></div> | |
| <div><label>seed</label><input id="seed" type="number" value="42" style="width:90px"></div> | |
| <div><label>turns</label><input id="turns" type="number" value="100" style="width:70px"></div> | |
| <div><label><input id="probe" type="checkbox"> probe</label></div> | |
| <div><label>mode</label><select id="mode"> | |
| <option value="play">Play (human)</option> | |
| <option value="spectate">Spectate (LLM)</option> | |
| </select></div> | |
| <div><label>memory</label><select id="memory"> | |
| <option value="default">scenario default</option> | |
| <option value="none">none</option> | |
| <option value="persona:risk_averse">persona · risk-averse (sample)</option> | |
| <option value="persona:risk_seeking">persona · risk-seeking</option> | |
| <option value="persona:survival_optimal">persona · survival-optimal</option> | |
| <option value="policy:survival_dynamic">policy · survival (dynamic evade)</option> | |
| <option value="policy:survival_refuge">policy · survival (hide in refuge)</option> | |
| <option value="policy:food_rush">policy · food rush (ignore predator)</option> | |
| <option value="generate">LLM self-play (generate)</option> | |
| <option value="latest">latest saved</option> | |
| </select></div> | |
| <div id="modelWrap" class="hidden"> | |
| <label>provider</label> | |
| <span id="providerToggle" class="toggle"></span> | |
| <input id="modelId" value="demo" style="width:170px" placeholder="model id" | |
| title="model id for the selected provider; final spec is provider:id"></div> | |
| <div><button class="primary" id="startBtn">New game</button></div> | |
| </div> | |
| <p style="color:var(--muted)">Move <b>A</b> (you) to survive the predator <b>B</b>. Arrow keys or WASD; walls block.</p> | |
| </div> | |
| <!-- PLAY SCREEN --> | |
| <div id="play" class="hidden"> | |
| <div class="status"><span id="meta"></span><span id="turnInfo"></span><span id="hp" style="margin-left:12px"></span></div> | |
| <div id="memInfo" style="color:var(--muted);font-size:12px"></div> | |
| <div id="rubric" class="hidden"></div> | |
| <div id="memReplay" class="hidden" style="padding:6px 0"> | |
| <div style="margin-bottom:6px"> | |
| <span style="color:var(--fg)">memory replay</span> | |
| <span id="memTypeToggle" class="toggle" style="margin-right:8px"></span> | |
| <button id="memReplayPrev" type="button">◀ prev</button> | |
| <button id="memReplayNext" type="button">next ▶</button> | |
| <button id="memReplayPlay" type="button">▶ play</button> | |
| <label style="color:var(--muted);margin-left:8px">배속 | |
| <select id="memSpeed"> | |
| <option value="800">1×</option> | |
| <option value="400">2×</option> | |
| <option value="200" selected>4×</option> | |
| <option value="100">8×</option> | |
| <option value="40">20×</option> | |
| </select></label> | |
| <button id="memReplayDone" type="button" style="margin-left:8px">▶ start playing</button> | |
| <span id="memReplayCap" style="color:var(--muted);margin-left:8px"></span> | |
| </div> | |
| </div> | |
| <div id="grid"></div> | |
| <div id="analysis" class="hidden"> | |
| <div id="thinking" style="color:var(--muted)"></div> | |
| <div id="reasoning" style="white-space:pre-wrap;margin:8px 0"></div> | |
| <table id="turnlog"><tr><th>turn</th><th>action</th><th>optimal</th> | |
| <th>habit</th><th>reward</th><th>=opt?</th></tr></table> | |
| <p style="text-align:center;margin-top:10px"> | |
| <label style="color:var(--muted)">speed</label> | |
| <select id="speed"> | |
| <option value="2200">very slow</option> | |
| <option value="1300" selected>slow</option> | |
| <option value="700">normal</option> | |
| <option value="300">fast</option> | |
| </select> | |
| <button id="pauseBtn">Pause</button> | |
| <button id="stepBtn">Step</button></p> | |
| </div> | |
| <div class="pad" id="pad"> | |
| <span class="blank"></span><button data-a="up">↑</button><span class="blank"></span> | |
| <button data-a="left">←</button><button data-a="stay">•</button><button data-a="right">→</button> | |
| <span class="blank"></span><button data-a="down">↓</button><span class="blank"></span> | |
| </div> | |
| <p style="text-align:center;margin-top:6px"> | |
| <button id="interactBtn" data-a="interact" class="hidden">interact (E) — help / pick up</button> | |
| </p> | |
| </div> | |
| <!-- END SCREEN --> | |
| <div id="end" class="hidden"> | |
| <div id="outcome" class="outcome"></div> | |
| <div id="review"></div> | |
| <p style="text-align:center;margin-top:16px"><button class="primary" id="againBtn">Play again</button></p> | |
| <p id="savedPath" style="color:var(--muted);text-align:center"></p> | |
| </div> | |
| </div> | |
| <script> | |
| let COLORS = {}; // int -> hex (#RRGGBBAA) | |
| let SID = null; // session id | |
| let PLAYING = false; // accept input? | |
| let SPECT = false, PAUSED = false; // spectate mode / auto-play paused? | |
| let SPEED = 1300; // ms delay between auto-played turns (watchable default) | |
| let MEM_SPEED = 200; // ms/frame for the memory replay (배속: default 4x ≈ 200ms) | |
| const $ = (id) => document.getElementById(id); | |
| async function jget(url) { return (await fetch(url)).json(); } | |
| async function jpost(url, body) { | |
| const r = await fetch(url, {method:"POST", headers:{"Content-Type":"application/json"}, | |
| body: JSON.stringify(body || {})}); | |
| return {status:r.status, data: await r.json()}; | |
| } | |
| function hex6(h) { return h.length >= 7 ? h.slice(0,7) : h; } // drop alpha for CSS | |
| function drawGrid(grid, target) { | |
| const g = target || $("grid"); | |
| const h = grid.length, w = grid[0].length; | |
| // Auto-fit the whole grid into a comfortable box: shrink cells for large | |
| // grids (so a 64x64 field fits and stays centered) and cap at 30px so small | |
| // grids (8x8) look exactly as before. | |
| const cell = Math.max(5, Math.min(30, Math.floor(560 / w))); | |
| g.style.setProperty("--cs", cell + "px"); | |
| g.style.gap = cell >= 14 ? "2px" : "1px"; | |
| g.style.gridTemplateColumns = `repeat(${w}, var(--cs))`; | |
| g.innerHTML = ""; | |
| for (let y=0; y<h; y++) for (let x=0; x<w; x++) { | |
| const c = document.createElement("div"); | |
| c.className = "cell"; | |
| c.style.background = hex6(COLORS[grid[y][x]] || "#000000"); | |
| g.appendChild(c); | |
| } | |
| } | |
| async function animateCut(frames) { | |
| for (const f of frames) { drawGrid(f); await new Promise(r => setTimeout(r, 280)); } | |
| } | |
| function showState(st) { | |
| // play_turns null -> no move limit (e.g. errand_runner): show just the turn count. | |
| $("turnInfo").textContent = (st.play_turns == null) | |
| ? `Turn ${st.turn_idx}` : `Turn ${st.turn_idx} / ${st.play_turns}`; | |
| // Health/curfew side-UI (only for scenarios that use health, e.g. errand_runner). | |
| const clock = (st.turns_left == null) ? "" : ` · ⏳ ${st.turns_left}`; | |
| $("hp").textContent = (st.health > 0) ? `♥ ${st.health}${clock}` : ""; | |
| // The interact control appears only when the scenario offers that action. | |
| const canInteract = Array.isArray(st.actions) && st.actions.includes("interact"); | |
| $("interactBtn").classList.toggle("hidden", !canInteract); | |
| if (st.turn_idx > 0) { // live play has begun → board is the game, not the memory | |
| MEM_PLAY = false; | |
| $("memReplay").classList.add("hidden"); | |
| } | |
| drawGrid(st.grid); | |
| highlightRubric(st.grid); // light up the entity rows a courier is passing | |
| if (st.outcome) { PLAYING = false; showEnd(st); } | |
| } | |
| function showEnd(st) { | |
| $("play").classList.add("hidden"); | |
| $("end").classList.remove("hidden"); | |
| const o = $("outcome"); | |
| o.textContent = st.outcome === "survived" ? "SURVIVED" : "ELIMINATED"; | |
| o.className = "outcome " + (st.outcome === "survived" ? "win" : "lose"); | |
| const rv = st.review; | |
| let rows = rv.turns.map(t => | |
| `<tr class="${t.is_diagnostic ? 'diag':''}"><td>${t.turn_idx}</td><td>${t.action}</td>` | |
| + `<td>${t.motive_action}</td><td>${t.habit_action}</td><td>${t.reward.toFixed(1)}</td>` | |
| + `<td>${t.was_congruent ? '✓':'✗'}</td></tr>`).join(""); | |
| let metrics = Object.entries(rv.metrics).map(([k,v]) => | |
| `<tr><td>${k}</td><td>${v.toFixed(2)}</td></tr>`).join(""); | |
| let errandHtml = ""; | |
| if (rv.errand) { | |
| const e = rv.errand, h = e.headline; | |
| const approx = new Set(e.approximate || []); | |
| const fmt = (val) => (val === null || val === undefined) ? "—" : Number(val).toFixed(0); | |
| const card = (val, lab) => `<div class="metric"><b>${fmt(val)}</b><span>${lab}</span></div>`; | |
| // mark approximate event cells so coarse fields aren't read as measured | |
| const evCell = (key) => `<td${approx.has(key) ? ' class="approx"' : ''}>${e.events[key]}` | |
| + `${approx.has(key) ? ' <sup>~</sup>' : ''}</td>`; | |
| // grass is PRECISELY tracked (not approximate); show a readable Korean label. | |
| const grassLabel = {cut: "가로지름", avoid: "피해감"}[e.events.grass] || e.events.grass; | |
| errandHtml = | |
| `<div class="metrics">` | |
| + card(h.discovery, "Discovery (몸 찾기)") | |
| + card(h.generalizability, "Generalizability") | |
| + card(h.coherence, "Coherence") | |
| + `</div>` | |
| + `<p style="color:var(--muted);font-size:12px">지표가 "—"이면 라이브 플레이에서는 측정되지 않는 항목입니다. "~" 표시는 근사값.</p>` | |
| + `<table style="margin-top:8px"><tr><th>횡단보도</th><th>공사장</th><th>지갑</th><th>행인</th><th>잔디</th></tr>` | |
| + `<tr>${evCell("crosswalk")}${evCell("construction")}${evCell("wallet")}${evCell("pedestrian")}<td>${grassLabel}</td></tr></table>` | |
| + `<p style="color:var(--muted)">잔디 밟음: ${e.grass_steps}회 · 행인 건드림: ${e.touched_pedestrian ? "예" : "아니오"}</p>` | |
| + `<p style="color:var(--muted)">→ 가장 가까운 성향: <b>${e.closest_label}</b></p>`; | |
| } | |
| $("review").innerHTML = errandHtml | |
| + `<table><tr><th>turn</th><th>you</th><th>optimal</th><th>habit</th><th>reward</th><th>=opt?</th></tr>${rows}</table>` | |
| + `<table style="margin-top:14px"><tr><th>metric</th><th>value</th></tr>${metrics}</table>`; | |
| } | |
| async function act(action) { | |
| if (SPECT) return; // human pad/keys are inert while spectating an LLM | |
| if (!PLAYING) return; | |
| PLAYING = false; | |
| const {status, data} = await jpost(`/session/${SID}/act`, {action}); | |
| if (status !== 200) { PLAYING = true; return; } // invalid action: ignore, keep playing | |
| const st = data.state; | |
| if (st.outcome) { | |
| const fin = await jpost(`/session/${SID}/finish`, {}); | |
| if (fin.status === 200) $("savedPath").textContent = "trace saved: " + fin.data.trace_path; | |
| showEnd(st); | |
| } else { | |
| PLAYING = true; | |
| showState(st); | |
| } | |
| } | |
| const KEYMAP = {ArrowUp:"up", ArrowDown:"down", ArrowLeft:"left", ArrowRight:"right", | |
| w:"up", s:"down", a:"left", d:"right", " ":"stay", e:"interact", E:"interact"}; | |
| window.addEventListener("keydown", (e) => { | |
| const a = KEYMAP[e.key]; | |
| if (a) { e.preventDefault(); act(a); } | |
| }); | |
| document.querySelectorAll(".pad button").forEach(b => | |
| b.addEventListener("click", () => act(b.dataset.a))); | |
| $("interactBtn").addEventListener("click", () => act("interact")); | |
| function showModelInput() { | |
| // The model field is needed to spectate, and also when the memory is built by | |
| // a model (LLM self-play 'generate' or 'latest' saved checkpoint). | |
| const need = $("mode").value === "spectate" | |
| || ["generate", "latest"].includes($("memory").value); | |
| $("modelWrap").classList.toggle("hidden", !need); | |
| } | |
| // --- provider toggle + model id -> "provider:id" spec ---------------------- // | |
| let PROVIDER = "fake"; | |
| const DEFAULT_MODEL_ID = { | |
| fake: "demo", ollama: "gpt-oss:120b-cloud", ollama_local: "gpt-oss", | |
| openai: "gpt-4o-mini", anthropic: "claude-haiku-4-5", gemini: "gemini-2.0-flash", | |
| }; | |
| function buildProviderToggle(providers) { | |
| const wrap = $("providerToggle"); | |
| wrap.innerHTML = ""; | |
| providers.forEach(p => { | |
| const b = document.createElement("button"); | |
| b.type = "button"; b.textContent = p; b.dataset.p = p; | |
| b.addEventListener("click", () => selectProvider(p)); | |
| wrap.appendChild(b); | |
| }); | |
| selectProvider(providers.includes("fake") ? "fake" : providers[0]); | |
| } | |
| function selectProvider(p) { | |
| PROVIDER = p; | |
| $("providerToggle").querySelectorAll("button").forEach(b => | |
| b.classList.toggle("active", b.dataset.p === p)); | |
| if (DEFAULT_MODEL_ID[p] !== undefined) $("modelId").value = DEFAULT_MODEL_ID[p]; | |
| } | |
| function currentModel() { return `${PROVIDER}:${$("modelId").value.trim()}`; } | |
| // --- memory step-through replay -------------------------------------------- // | |
| let MEM_FRAMES = [], MEM_I = 0, MEM_PLAY = false; | |
| let INIT_STATE = null; | |
| function renderMemFrame() { | |
| if (!MEM_FRAMES.length) return; | |
| const f = MEM_FRAMES[MEM_I]; | |
| drawGrid(f.grid, $("grid")); | |
| const ev = (f.events && f.events.length) ? " · " + f.events.join(", ") : ""; | |
| $("memReplayCap").textContent = | |
| `Memory ${f.turn_idx} / ${MEM_FRAMES.length} — you chose: ${f.action}${ev}`; | |
| } | |
| function memStep(d) { | |
| if (!MEM_FRAMES.length) return; | |
| MEM_PLAY = false; $("memReplayPlay").textContent = "▶ play"; | |
| MEM_I = (MEM_I + d + MEM_FRAMES.length) % MEM_FRAMES.length; | |
| renderMemFrame(); | |
| } | |
| async function memPlay() { | |
| if (MEM_PLAY) { MEM_PLAY = false; $("memReplayPlay").textContent = "▶ play"; return; } | |
| if (!MEM_FRAMES.length) return; | |
| MEM_PLAY = true; $("memReplayPlay").textContent = "⏸ pause"; | |
| for (let k = 0; k < MEM_FRAMES.length && MEM_PLAY; k++) { | |
| MEM_I = k; renderMemFrame(); | |
| await new Promise(r => setTimeout(r, MEM_SPEED)); | |
| } | |
| MEM_PLAY = false; $("memReplayPlay").textContent = "▶ play"; | |
| } | |
| // --- persona rubric: which memory the player got + how it acts at each entity --- | |
| const COURIER_IDX = 9; // palette index of a courier cell on the grid | |
| let RUBRIC = null; | |
| function renderRubric(m) { | |
| const el = $("rubric"); | |
| RUBRIC = (m && m.rubric) || null; | |
| if (!RUBRIC) { el.classList.add("hidden"); el.innerHTML = ""; return; } | |
| el.classList.remove("hidden"); | |
| el.innerHTML = | |
| `<div class="rubhead">제공된 기억: <b>${RUBRIC.label}</b> — 각 지점에서의 행동 (배달원이 지날 때 강조)</div>` | |
| + `<table><tr>${RUBRIC.rows.map(r => `<th>${r.entity}</th>`).join("")}</tr>` | |
| + `<tr>${RUBRIC.rows.map((r, i) => `<td id="rub${i}">${r.reaction_label}</td>`).join("")}</tr></table>`; | |
| } | |
| function highlightRubric(grid) { | |
| if (!RUBRIC || !grid) return; | |
| RUBRIC.rows.forEach((r, i) => { | |
| const [x0, y0, x1, y1] = r.rect; | |
| let near = false; | |
| for (let y = Math.max(0, y0 - 1); y <= Math.min(grid.length - 1, y1 + 1) && !near; y++) | |
| for (let x = Math.max(0, x0 - 1); x <= Math.min((grid[0] || []).length - 1, x1 + 1); x++) | |
| if (grid[y][x] === COURIER_IDX) { near = true; break; } // a courier is on/at this entity | |
| const td = $("rub" + i); | |
| if (td) td.classList.toggle("rub-active", near); | |
| }); | |
| } | |
| function showMem(m) { | |
| renderRubric(m); | |
| if (!m || !m.attached) { | |
| $("memInfo").textContent = m ? "memory: none (no pre-roll before the task)" : ""; | |
| MEM_FRAMES = []; MEM_PLAY = false; $("memReplay").classList.add("hidden"); | |
| return; | |
| } | |
| const legend = (m.variants && m.variants.length) | |
| ? " · 파란 네모 = 당신(배달원), 초록 = 잔디, 갈색 = 집, 노랑 = 지갑/행인, 주황 = 공사장" | |
| : " · blue = other agents, light = you, mouth-block = predator"; | |
| $("memInfo").textContent = | |
| `memory shown at handover — source: ${m.source}` | |
| + `${m.persona ? " (persona " + m.persona + ")" : ""} · ${m.turns} turns` | |
| + legend; | |
| // errand: 3 selectable memory types | |
| const tog = $("memTypeToggle"); | |
| tog.innerHTML = ""; | |
| if (m.variants && m.variants.length) { | |
| m.variants.forEach(v => { | |
| const b = document.createElement("button"); | |
| b.type = "button"; b.textContent = v.label; b.dataset.id = v.id; | |
| b.className = (v.id === m.selected) ? "active" : ""; | |
| b.addEventListener("click", () => { | |
| tog.querySelectorAll("button").forEach(x => x.classList.toggle("active", x === b)); | |
| MEM_FRAMES = v.frames || []; MEM_I = 0; MEM_PLAY = false; | |
| $("memReplayPlay").textContent = "▶ play"; renderMemFrame(); | |
| }); | |
| tog.appendChild(b); | |
| }); | |
| const sel = m.variants.find(v => v.id === m.selected) || m.variants[0]; | |
| MEM_FRAMES = sel.frames || []; | |
| } else { | |
| MEM_FRAMES = (m && m.frames) || []; | |
| } | |
| MEM_I = 0; MEM_PLAY = false; | |
| $("memReplayPlay").textContent = "▶ play"; | |
| const rep = $("memReplay"); | |
| if (MEM_FRAMES.length) { rep.classList.remove("hidden"); renderMemFrame(); } | |
| else rep.classList.add("hidden"); | |
| } | |
| function appendTurn(t) { | |
| const tr = document.createElement("tr"); | |
| if (t.is_diagnostic) tr.className = "diag"; | |
| tr.innerHTML = `<td>${t.turn_idx}</td><td>${t.action}</td><td>${t.motive_action}</td>` | |
| + `<td>${t.habit_action}</td><td>${t.reward.toFixed(1)}</td>` | |
| + `<td>${t.was_congruent ? '✓':'✗'}</td>`; | |
| $("turnlog").appendChild(tr); | |
| } | |
| function showSpectateState(st) { | |
| $("turnInfo").textContent = (st.play_turns == null) | |
| ? `Turn ${st.turn_idx}` : `Turn ${st.turn_idx} / ${st.play_turns}`; | |
| drawGrid(st.grid); | |
| highlightRubric(st.grid); // light up the entity rows a courier is passing | |
| const last = st.turns_so_far[st.turns_so_far.length - 1]; | |
| if (last) { $("reasoning").textContent = last.reasoning || ""; appendTurn(last); } | |
| } | |
| async function spectateLoop() { | |
| while (SPECT && !PAUSED) { | |
| $("thinking").textContent = "thinking…"; | |
| const {status, data} = await jpost(`/spectate/${SID}/next`, {}); | |
| $("thinking").textContent = ""; | |
| if (status === 409) break; | |
| if (status !== 200) { $("thinking").textContent = data.error || "error"; SPECT = false; break; } | |
| showSpectateState(data.state); | |
| if (data.state.outcome) { | |
| const fin = await jpost(`/spectate/${SID}/finish`, {}); | |
| if (fin.status === 200) $("savedPath").textContent = "trace saved: " + fin.data.trace_path; | |
| $("play").classList.add("hidden"); $("end").classList.remove("hidden"); | |
| const o = $("outcome"); | |
| o.textContent = data.state.outcome === "survived" ? "SURVIVED" : "ELIMINATED"; | |
| o.className = "outcome " + (data.state.outcome === "survived" ? "win" : "lose"); | |
| SPECT = false; break; | |
| } | |
| // Pace the playback so it is watchable (real LLM turns are already slow; this | |
| // mainly slows fast/fake runs). Pause/Step still work via PAUSED/the loop. | |
| await new Promise(r => setTimeout(r, SPEED)); | |
| } | |
| } | |
| async function startSpectate(body) { | |
| const {status, data} = await jpost("/spectate", body); | |
| if (status !== 200) { alert(data.error || "failed to start"); return; } | |
| SID = data.session_id; SPECT = true; PAUSED = false; | |
| INIT_STATE = null; // spectate has no human-play handover; keep it from going stale | |
| $("turnlog").querySelectorAll("tr:not(:first-child)").forEach(r => r.remove()); | |
| $("start").classList.add("hidden"); $("end").classList.add("hidden"); | |
| $("play").classList.remove("hidden"); $("analysis").classList.remove("hidden"); $("pad")?.classList?.add?.("hidden"); | |
| $("meta").textContent = `${body.scenario} · ${body.difficulty} · ${body.model}`; | |
| showMem(data.memory); // show the SAME handover memory (+ persona rubric) the LLM was given | |
| $("memReplayDone").classList.add("hidden"); // "start playing" is human-only; the LLM auto-plays | |
| const st = data.state; | |
| if (st.cut_frames) await animateCut(st.cut_frames); | |
| drawGrid(st.grid); | |
| spectateLoop(); | |
| } | |
| async function startGame() { | |
| if ($("mode").value === "spectate") { | |
| return startSpectate({ | |
| scenario: $("scenario").value, difficulty: $("difficulty").value, | |
| seed: Number($("seed").value), play_turns: Number($("turns").value), | |
| model: currentModel(), probe: $("probe").checked, | |
| memory: $("memory").value, | |
| }); | |
| } | |
| const body = { | |
| scenario: $("scenario").value, | |
| difficulty: $("difficulty").value, | |
| seed: Number($("seed").value), | |
| play_turns: Number($("turns").value), | |
| probe: $("probe").checked, | |
| memory: $("memory").value, | |
| model: currentModel(), // used only when memory is 'generate'/'latest' | |
| }; | |
| const {status, data} = await jpost("/session", body); | |
| if (status !== 200) { alert(data.error || "failed to start"); return; } | |
| SID = data.session_id; | |
| SPECT = false; | |
| $("analysis").classList.add("hidden"); | |
| $("pad")?.classList?.remove?.("hidden"); | |
| $("start").classList.add("hidden"); | |
| $("end").classList.add("hidden"); | |
| $("play").classList.remove("hidden"); | |
| $("meta").textContent = `${body.scenario} · ${body.difficulty} · seed ${body.seed}`; | |
| showMem(data.memory); | |
| const st = data.state; | |
| if (st.cut_frames) await animateCut(st.cut_frames); | |
| PLAYING = true; | |
| INIT_STATE = st; // remember the live opening state | |
| if (!(data.memory && data.memory.attached && (data.memory.frames || []).length)) { | |
| showState(st); // no memory → show the live board immediately | |
| } | |
| } | |
| async function init() { | |
| const cfg = await jget("/config"); | |
| COLORS = cfg.color_map; | |
| $("scenario").innerHTML = cfg.scenarios.map(s => `<option>${s}</option>`).join(""); | |
| $("difficulty").innerHTML = cfg.difficulties.map(d => `<option>${d}</option>`).join(""); | |
| $("seed").value = cfg.default_seed; | |
| buildProviderToggle(cfg.providers || ["fake"]); | |
| $("startBtn").addEventListener("click", startGame); | |
| $("mode").addEventListener("change", showModelInput); | |
| $("memory").addEventListener("change", showModelInput); | |
| showModelInput(); | |
| SPEED = Number($("speed").value); | |
| $("speed").addEventListener("change", () => { SPEED = Number($("speed").value); }); | |
| MEM_SPEED = Number($("memSpeed").value); | |
| $("memSpeed").addEventListener("change", () => { MEM_SPEED = Number($("memSpeed").value); }); | |
| $("memReplayPrev").addEventListener("click", () => memStep(-1)); | |
| $("memReplayNext").addEventListener("click", () => memStep(1)); | |
| $("memReplayPlay").addEventListener("click", memPlay); | |
| $("memReplayDone").addEventListener("click", () => { // human-play handover only; the bar is hidden in spectate | |
| MEM_PLAY = false; | |
| $("memReplay").classList.add("hidden"); | |
| if (INIT_STATE) showState(INIT_STATE); // hand the board over to live play | |
| }); | |
| $("pauseBtn").addEventListener("click", () => { | |
| PAUSED = !PAUSED; $("pauseBtn").textContent = PAUSED ? "Resume" : "Pause"; | |
| if (!PAUSED) spectateLoop(); | |
| }); | |
| $("stepBtn").addEventListener("click", async () => { | |
| if (!SPECT) return; | |
| const {status, data} = await jpost(`/spectate/${SID}/next`, {}); | |
| if (status === 200) { showSpectateState(data.state); | |
| if (data.state.outcome) { await jpost(`/spectate/${SID}/finish`, {}); SPECT = false; } } | |
| }); | |
| $("againBtn").addEventListener("click", () => { | |
| $("end").classList.add("hidden"); | |
| $("start").classList.remove("hidden"); | |
| }); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |