Spaces:
Sleeping
Sleeping
| 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 = '<div class="empty-state">No games loaded.</div>'; | |
| return; | |
| } | |
| games.forEach((game) => { | |
| const card = document.createElement("div"); | |
| card.className = "mini-card"; | |
| card.innerHTML = ` | |
| <strong>${game.title}</strong> | |
| <dl> | |
| <dt>Branch</dt><dd>${game.branch}</dd> | |
| <dt>Monthly Revenue</dt><dd>${formatMoney(game.monthlyRevenue)}</dd> | |
| <dt>Players</dt><dd>${Number(game.activePlayers || 0).toLocaleString()}</dd> | |
| <dt>Churn</dt><dd>${formatPercent(game.churnRate)} x${Number(game.churnMult || 1).toFixed(2)}</dd> | |
| </dl> | |
| `; | |
| 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 = ` | |
| <td> | |
| <div class="work-title"> | |
| <strong>${item.title}</strong> | |
| <span>${item.id}${item.crisis ? " | CRISIS" : ""}</span> | |
| </div> | |
| </td> | |
| <td>${item.gameId}</td> | |
| <td><span class="tag">${typeLabel(item.workType)}</span></td> | |
| <td>${severityLabel(item.severity)}</td> | |
| <td> | |
| <div class="progress" aria-label="${pct.toFixed(0)}% complete"> | |
| <span style="width: ${pct}%"></span> | |
| </div> | |
| <small>${Number(item.daysWorked || 0)} / ${item.effort} days, ${remaining} left</small> | |
| </td> | |
| <td> | |
| ${formatMoney(item.revenueImpact)} | |
| <br /><small>Delay ${item.impactDelay}, churn -${formatPercent(item.churnReduction)}</small> | |
| </td> | |
| <td> | |
| <input | |
| class="staff-input" | |
| data-item-id="${item.id}" | |
| type="number" | |
| min="0" | |
| step="1" | |
| value="0" | |
| aria-label="Staff for ${item.title}" | |
| /> | |
| </td> | |
| `; | |
| 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 = '<div class="empty-state">No completed items yet.</div>'; | |
| return; | |
| } | |
| items.forEach((item) => { | |
| const card = document.createElement("div"); | |
| card.className = "mini-card"; | |
| card.innerHTML = ` | |
| <strong>${item.title || item.id}</strong> | |
| <dl> | |
| <dt>Game</dt><dd>${item.gameId || "--"}</dd> | |
| <dt>Type</dt><dd>${typeLabel(item.workType)}</dd> | |
| <dt>Impact</dt><dd>${formatMoney(item.revenueImpact)}</dd> | |
| </dl> | |
| `; | |
| 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(); | |