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();