"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 = `${k}: ${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", `${d.unknown_listeners || 0} (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 += `