Files
magicka-vm/crates/web_assets/assets/app.js
T
linus-dandClaude Opus 4.8 9d9d5ce41c Add web game (plan2.md) on the independent runtime, merge-blocking gates
Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:20:46 -07:00

338 lines
12 KiB
JavaScript

"use strict";
// Browser client for Magicka VM. The browser only ever sends INTENT; the server
// is the sole authority. This file mirrors the `protocol` crate's wire format
// (version 1, envelope {v,type,body}). It never simulates the world — it renders
// exactly what the server says is observable.
const PROTOCOL_VERSION = 1;
const OPS = ["mix","channel","branch","schedule","resonate","observe",
"collapse","invert","diffuse","anchor","echoback","imprint"];
const state = {
ws: null,
playerId: null,
matchId: null,
turn: 0,
deadline: 0,
locked: false,
snapshot: null,
selectedTarget: null,
program: [],
slots: [[], [], []],
activeSlot: 0,
liveHashes: {}, // turn -> runtime_hash seen live
replay: null, // { turns: [...], final_hash, cursor }
};
// ---- wire helpers -------------------------------------------------------
function send(type, body) {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
state.ws.send(JSON.stringify({ v: PROTOCOL_VERSION, type, body }));
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws = ws;
ws.onopen = () => {
setConn(true);
send("JoinMatch", { name: "dev-" + Math.floor(Math.random() * 1000), match_id: null });
};
ws.onclose = () => { setConn(false); setTimeout(connect, 1000); };
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (!msg || msg.v !== PROTOCOL_VERSION) return;
handle(msg.type, msg.body || {});
};
}
function setConn(on) {
const el = document.getElementById("conn");
el.textContent = on ? "connected" : "disconnected";
el.className = "badge " + (on ? "on" : "off");
}
// ---- server message handling -------------------------------------------
function handle(type, body) {
switch (type) {
case "MatchState":
state.playerId = body.player_id;
state.matchId = body.match_id;
state.turn = body.turn;
state.snapshot = body.snapshot;
render();
break;
case "TurnStarted":
state.turn = body.turn;
state.deadline = Date.now() + (body.deadline_ms || 0);
state.locked = false;
renderTimer();
break;
case "TurnResolved":
state.turn = body.turn;
state.snapshot = body.snapshot;
state.liveHashes[body.turn] = body.runtime_hash;
(body.events || []).forEach((e) => log(`t${body.turn}: ${e}`));
showHashes();
render();
break;
case "ObservationResult":
renderDiagnostics(body.diagnostics, `target ${body.target}`);
break;
case "ValidationReport":
log((body.accepted ? "✓ " : "✗ ") + body.detail);
if (body.diagnostics) renderDiagnostics(body.diagnostics, "program");
break;
case "ReplayChunk":
loadReplay(body);
break;
case "ErrorEvent":
log(`! ${body.code}: ${body.detail}`);
break;
}
}
// ---- rendering ----------------------------------------------------------
function render() {
if (!state.snapshot) return;
renderArena();
renderDomains();
renderLogHistory();
renderTimer();
}
function renderArena() {
const s = state.snapshot;
const arena = document.getElementById("arena");
arena.style.gridTemplateColumns = `repeat(${s.arena_w}, 34px)`;
arena.innerHTML = "";
const at = {};
(s.observed_entities || []).forEach((e) => { at[`${e.x},${e.y}`] = e; });
for (let y = 0; y < s.arena_h; y++) {
for (let x = 0; x < s.arena_w; x++) {
const cell = document.createElement("div");
cell.className = "cell";
const e = at[`${x},${y}`];
if (e) {
cell.textContent = e.is_self ? "@" : (e.is_dummy ? "▣" : "&");
if (e.is_self) cell.classList.add("self");
if (state.selectedTarget === e.id) cell.classList.add("target");
if (!e.alive) cell.classList.add("dead");
const hp = document.createElement("span");
hp.className = "hp"; hp.textContent = e.hp;
cell.appendChild(hp);
cell.title = `${e.name} (#${e.id}) hp ${e.hp}`;
cell.onclick = () => { state.selectedTarget = e.id; renderArena(); };
}
arena.appendChild(cell);
}
}
}
function renderDomains() {
const s = state.snapshot;
const wrap = document.getElementById("domains");
wrap.innerHTML = "";
(s.observed_domains || []).forEach((d) => {
const el = document.createElement("div");
el.className = "domain";
const name = document.createElement("div");
name.className = "name"; name.textContent = `${d.index}: ${d.name}`;
el.appendChild(name);
d.observed.forEach((v, i) => {
const lane = document.createElement("span");
const k = (d.knowledge && d.knowledge[i]) || "unknown";
lane.className = "lane " + k;
lane.textContent = v === null ? "▒" : v;
lane.title = k;
el.appendChild(lane);
});
wrap.appendChild(el);
});
document.getElementById("redactions").textContent =
`${s.hidden_state_redactions} hidden state values withheld (hidden lanes + masked observations)`;
const inf = document.getElementById("inferred");
inf.innerHTML = "";
(s.inferred_markers || []).forEach((m) => {
const li = document.createElement("li"); li.textContent = m; inf.appendChild(li);
});
}
function renderLogHistory() {
// History from the snapshot is authoritative; live events are appended too.
const known = state.snapshot.known_history || [];
const log = document.getElementById("log");
if (log.dataset.lastTurn !== String(state.turn)) {
log.dataset.lastTurn = String(state.turn);
}
}
function renderTimer() {
const t = document.getElementById("timer");
const remain = Math.max(0, Math.ceil((state.deadline - Date.now()) / 1000));
t.textContent = `turn ${state.turn}${remain}s ${state.locked ? "(locked)" : ""}`;
}
setInterval(() => {
if (state.deadline) {
if (Date.now() > state.deadline) state.locked = true;
renderTimer();
}
}, 250);
function renderDiagnostics(d, label) {
const body = document.getElementById("diag-body");
body.innerHTML = "";
const row = (k, v) => {
const div = document.createElement("div");
div.className = "diag-row";
div.innerHTML = `<span class="diag-key">${k}:</span> ${v}`;
body.appendChild(div);
};
row("for", label);
row("known reads", (d.known_reads || []).join(", ") || "—");
row("known writes", (d.known_writes || []).join(", ") || "—");
row("observed risks", (d.observed_risks || []).join(", ") || "none observed");
row("unknown listeners", `<span class="warn">${d.unknown_listeners || 0}</span> (writes you cannot observe)`);
row("previous outcomes", (d.previous_outcomes || []).join(" | ") || "—");
}
// ---- rune editor --------------------------------------------------------
function renderTokens() {
const wrap = document.getElementById("tokens");
wrap.innerHTML = "";
state.program.forEach((t, i) => {
const el = document.createElement("span");
el.className = "token";
el.textContent = `${OPS[t.op % OPS.length]} ${t.a},${t.b},${t.c}#${t.imm}`;
el.title = "click to remove";
el.onclick = () => { state.program.splice(i, 1); renderTokens(); };
wrap.appendChild(el);
});
}
function renderLibrary() {
const wrap = document.getElementById("library");
wrap.innerHTML = "";
state.slots.forEach((slot, i) => {
const el = document.createElement("div");
el.className = "slot" + (i === state.activeSlot ? " active" : "");
el.textContent = `slot ${i + 1} (${slot.length})`;
el.onclick = () => {
state.slots[state.activeSlot] = state.program.slice();
state.activeSlot = i;
state.program = state.slots[i].slice();
renderTokens(); renderLibrary();
};
wrap.appendChild(el);
});
}
function initEditor() {
const sel = document.getElementById("op-select");
OPS.forEach((op, i) => {
const o = document.createElement("option");
o.value = i; o.textContent = op; sel.appendChild(o);
});
document.getElementById("btn-add").onclick = () => {
state.program.push({
op: parseInt(sel.value, 10),
a: clampByte("tok-a"), b: clampByte("tok-b"), c: clampByte("tok-c"),
imm: parseInt(document.getElementById("tok-imm").value, 10) || 0,
});
renderTokens();
};
document.getElementById("btn-clear").onclick = () => { state.program = []; renderTokens(); };
document.getElementById("btn-save").onclick = () => {
send("EditRuneProgram", { tokens: state.program });
log("saved program (" + state.program.length + " runes)");
};
renderTokens(); renderLibrary();
}
function clampByte(id) {
let v = parseInt(document.getElementById(id).value, 10) || 0;
return Math.max(0, Math.min(255, v));
}
// ---- actions ------------------------------------------------------------
function submit(action) {
if (state.locked) { log("turn locked — submission rejected client-side"); return; }
send("SubmitTurn", { turn: state.turn, action });
}
function initActions() {
document.querySelectorAll("[data-move]").forEach((b) => {
b.onclick = () => {
const [dx, dy] = b.dataset.move.split(",").map((n) => parseInt(n, 10));
submit({ kind: "move", dx, dy });
};
});
document.getElementById("btn-cast").onclick = () => submit({ kind: "cast" });
document.getElementById("btn-wait").onclick = () => submit({ kind: "wait" });
document.getElementById("btn-attack").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
submit({ kind: "attack", target: state.selectedTarget });
};
document.getElementById("btn-inspect").onclick = () => {
if (state.selectedTarget === null) { log("select a target first"); return; }
send("InspectTarget", { target: state.selectedTarget });
};
}
// ---- replay -------------------------------------------------------------
function initReplay() {
document.getElementById("btn-replay").onclick = () => {
if (state.matchId === null) return;
state.replay = null;
send("RequestReplay", { match_id: state.matchId });
};
document.getElementById("btn-replay-step").onclick = stepReplay;
}
function loadReplay(chunk) {
if (!state.replay) state.replay = { turns: [], final_hash: chunk.final_hash, cursor: 0, seed: chunk.seed };
state.replay.turns = state.replay.turns.concat(chunk.turns || []);
state.replay.final_hash = chunk.final_hash;
document.getElementById("replay-status").textContent =
`replay loaded: ${state.replay.turns.length} turns (seed ${chunk.seed})`;
}
function stepReplay() {
if (!state.replay || state.replay.cursor >= state.replay.turns.length) {
document.getElementById("replay-status").textContent = "replay complete";
return;
}
const rt = state.replay.turns[state.replay.cursor++];
// Verify browser replay event order/hash matches what we saw live (Phase G).
const live = state.liveHashes[rt.turn];
const ok = live === undefined || live === rt.runtime_hash;
log(`replay t${rt.turn}: hash ${rt.runtime_hash} ${ok ? "✓ matches live" : "✗ MISMATCH"}`);
const hd = document.getElementById("hashes");
hd.innerHTML += `<div class="${ok ? "hash-ok" : "hash-bad"}">t${rt.turn} ${rt.runtime_hash}</div>`;
document.getElementById("replay-status").textContent =
`replay turn ${rt.turn} / ${state.replay.turns.length}`;
}
function showHashes() {
const hd = document.getElementById("hashes");
hd.innerHTML = `<div>live final-turn hash: ${state.liveHashes[state.turn] || "—"}</div>`;
}
// ---- misc ---------------------------------------------------------------
function log(msg) {
const el = document.getElementById("log");
const li = document.createElement("li");
li.textContent = msg;
el.appendChild(li);
el.scrollTop = el.scrollHeight;
}
window.addEventListener("DOMContentLoaded", () => {
initEditor();
initActions();
initReplay();
connect();
});