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>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"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();
|
||||
});
|
||||
Reference in New Issue
Block a user