const state = { socket: null, pending: [], observation: null, reward: 0, done: false, }; const els = { status: document.querySelector("#connection-status"), progress: document.querySelector("#episode-progress"), staff: document.querySelector("#staff-summary"), task: document.querySelector("#task-select"), seed: document.querySelector("#seed-input"), reset: document.querySelector("#reset-button"), reward: document.querySelector("#reward-pill"), goal: document.querySelector("#goal-text"), games: document.querySelector("#games-list"), queueEmpty: document.querySelector("#queue-empty"), workBody: document.querySelector("#work-body"), reasoning: document.querySelector("#reasoning-input"), allocation: document.querySelector("#allocation-summary"), step: document.querySelector("#step-button"), clear: document.querySelector("#clear-button"), auto: document.querySelector("#auto-button"), completed: document.querySelector("#completed-list"), log: document.querySelector("#event-log"), }; function formatMoney(value) { const amount = Number(value || 0); return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }).format(amount); } function formatPercent(value) { return `${(Number(value || 0) * 100).toFixed(1)}%`; } function wsUrl() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const path = new URL("../ws", window.location.href).pathname; return `${protocol}//${window.location.host}${path}`; } function setConnection(connected, label) { els.status.textContent = label || (connected ? "Connected" : "Disconnected"); els.status.classList.toggle("connected", connected); } function addLog(message) { const item = document.createElement("li"); item.textContent = message; els.log.prepend(item); } function connect() { if (state.socket && state.socket.readyState === WebSocket.OPEN) { return Promise.resolve(state.socket); } return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl()); state.socket = socket; setConnection(false, "Connecting"); socket.addEventListener("open", () => { setConnection(true, "Connected"); resolve(socket); }); socket.addEventListener("message", (event) => { const pending = state.pending.shift(); if (!pending) { return; } try { const payload = JSON.parse(event.data); if (payload.type === "error") { pending.reject(payload.data || payload); } else { pending.resolve(payload.data || payload); } } catch (error) { pending.reject(error); } }); socket.addEventListener("close", () => { setConnection(false, "Disconnected"); while (state.pending.length) { state.pending.shift().reject(new Error("WebSocket closed")); } }); socket.addEventListener("error", () => { setConnection(false, "Connection error"); reject(new Error("Could not connect to /ws")); }); }); } async function send(type, data = {}) { const socket = await connect(); return new Promise((resolve, reject) => { state.pending.push({ resolve, reject }); socket.send(JSON.stringify({ type, data })); }); } function readResult(payload) { const observation = payload.observation || {}; return { observation, reward: payload.reward ?? observation.reward ?? 0, done: payload.done ?? observation.done ?? false, }; } function setResult(payload) { const result = readResult(payload); state.observation = result.observation; state.reward = result.reward; state.done = result.done; render(); } function severityLabel(severity) { return ["", "Low", "Medium", "High", "Critical", "Blocker"][Number(severity)] || severity; } function typeLabel(type) { return String(type || "").toUpperCase(); } function currentAllocations() { return [...document.querySelectorAll(".staff-input")] .map((input) => ({ workItemID: input.dataset.itemId, staff: Math.max(0, Number.parseInt(input.value || "0", 10)), })) .filter((assignment) => assignment.staff > 0); } function allocatedStaff() { return currentAllocations().reduce((sum, item) => sum + item.staff, 0); } function updateAllocationSummary() { const total = state.observation?.staffPool?.total || 0; const allocated = allocatedStaff(); els.allocation.textContent = `Allocated ${allocated} / ${total} staff`; els.step.disabled = !state.observation || state.done || allocated <= 0 || allocated > total; } function renderGames(games) { els.games.innerHTML = ""; if (!games?.length) { els.games.innerHTML = '
No games loaded.
'; return; } games.forEach((game) => { const card = document.createElement("div"); card.className = "mini-card"; card.innerHTML = ` ${game.title}
Branch
${game.branch}
Monthly Revenue
${formatMoney(game.monthlyRevenue)}
Players
${Number(game.activePlayers || 0).toLocaleString()}
Churn
${formatPercent(game.churnRate)} x${Number(game.churnMult || 1).toFixed(2)}
`; els.games.appendChild(card); }); } function renderWorkQueue(items) { els.workBody.innerHTML = ""; els.queueEmpty.classList.toggle("hidden", Boolean(items?.length)); (items || []).forEach((item) => { const remaining = Math.max(0, Number(item.effort || 0) - Number(item.daysWorked || 0)); const pct = item.effort ? Math.min(100, (Number(item.daysWorked || 0) / item.effort) * 100) : 0; const row = document.createElement("tr"); row.innerHTML = `
${item.title} ${item.id}${item.crisis ? " | CRISIS" : ""}
${item.gameId} ${typeLabel(item.workType)} ${severityLabel(item.severity)}
${Number(item.daysWorked || 0)} / ${item.effort} days, ${remaining} left ${formatMoney(item.revenueImpact)}
Delay ${item.impactDelay}, churn -${formatPercent(item.churnReduction)} `; els.workBody.appendChild(row); }); document.querySelectorAll(".staff-input").forEach((input) => { input.addEventListener("input", updateAllocationSummary); }); } function renderCompleted(items) { els.completed.innerHTML = ""; if (!items?.length) { els.completed.innerHTML = '
No completed items yet.
'; return; } items.forEach((item) => { const card = document.createElement("div"); card.className = "mini-card"; card.innerHTML = ` ${item.title || item.id}
Game
${item.gameId || "--"}
Type
${typeLabel(item.workType)}
Impact
${formatMoney(item.revenueImpact)}
`; els.completed.appendChild(card); }); } function render() { const obs = state.observation; if (!obs) { updateAllocationSummary(); return; } els.progress.textContent = state.done ? `Complete: ${obs.currentStep} / ${obs.totalSteps}` : `Sprint ${obs.currentStep} / ${obs.totalSteps}`; els.staff.textContent = `Staff: ${obs.staffPool?.total ?? "--"}`; els.reward.textContent = `Reward ${Number(state.reward || 0).toFixed(2)}`; els.goal.textContent = obs.goal || "No goal provided."; const revenue = obs.metadata?.cumulativeRevenue; if (revenue !== undefined) { els.staff.textContent += ` | Revenue ${formatMoney(revenue)}`; } renderGames(obs.games); renderWorkQueue(obs.workQueue); renderCompleted(obs.metadata?.completedItems || []); updateAllocationSummary(); } async function resetEpisode() { els.reset.disabled = true; els.step.disabled = true; try { const payload = await send("reset", { task_id: Number(els.task.value), seed: Number(els.seed.value || 42), }); setResult(payload); els.reasoning.value = ""; addLog(`Reset task ${els.task.value} with seed ${els.seed.value || 42}.`); } catch (error) { addLog(`Reset failed: ${error.message || JSON.stringify(error)}`); } finally { els.reset.disabled = false; updateAllocationSummary(); } } async function runSprint() { const assignments = currentAllocations(); if (!assignments.length) { return; } els.step.disabled = true; try { const payload = await send("step", { assignments, reasoning: els.reasoning.value.trim(), }); setResult(payload); const obs = state.observation; const summary = obs.metadata?.sprint_summary || obs.metadata?.message; addLog(summary || `Sprint ${obs.currentStep} complete.`); if (state.done) { addLog("Episode complete."); } } catch (error) { addLog(`Sprint failed: ${error.message || JSON.stringify(error)}`); } finally { updateAllocationSummary(); } } function clearAllocations() { document.querySelectorAll(".staff-input").forEach((input) => { input.value = "0"; }); updateAllocationSummary(); } function autofillCritical() { const obs = state.observation; if (!obs?.workQueue?.length) { return; } clearAllocations(); let remainingStaff = obs.staffPool?.total || 0; const sorted = [...obs.workQueue].sort((a, b) => { const crisisDelta = Number(Boolean(b.crisis)) - Number(Boolean(a.crisis)); if (crisisDelta) return crisisDelta; const severityDelta = Number(b.severity || 0) - Number(a.severity || 0); if (severityDelta) return severityDelta; return Number(a.impactDelay || 0) - Number(b.impactDelay || 0); }); sorted.forEach((item) => { if (remainingStaff <= 0) { return; } const input = document.querySelector(`.staff-input[data-item-id="${item.id}"]`); if (!input) { return; } const daysLeft = Math.max(0, Number(item.effort || 0) - Number(item.daysWorked || 0)); const needed = Math.max(1, Math.ceil(daysLeft / 10)); const staff = Math.min(needed, remainingStaff); input.value = String(staff); remainingStaff -= staff; }); updateAllocationSummary(); } els.reset.addEventListener("click", resetEpisode); els.step.addEventListener("click", runSprint); els.clear.addEventListener("click", clearAllocations); els.auto.addEventListener("click", autofillCritical); resetEpisode();