irregular6612's picture
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
Raw
History Blame Contribute Delete
26.3 kB
<!doctype html>
<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"></option>
<option value="400"></option>
<option value="200" selected></option>
<option value="100"></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>