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>
338 lines
12 KiB
JavaScript
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();
|
|
});
|