// Retro Alpha - Frontend with improved UX // NN/g heuristics: system status visibility, recognition > recall, // error prevention, user control & freedom, minimalist design (function () { const E = window.RetroEngine; const Ev = window.RetroEvents; const DISPLAY = E.ASSET_DISPLAY_NAMES; const TRADABLE_DISPLAY = E.TRADABLE_KEYS.map(function (k) { return DISPLAY[k]; }); // DOM refs function $(id) { return document.getElementById(id); } var els = { date: $("date-display"), llmStatus: $("llm-status"), llmBadge: $("llm-badge"), chatLlmBadge: $("chat-llm-badge"), watchBody: $("watch-body"), insightText: $("insight-text"), positionsBody: $("positions-body"), newsContent: $("news-content"), agentLog: $("agent-log"), chartTitle: $("chart-title"), chart: $("price-chart"), cashLine: $("cash-line"), netWorth: $("net-worth"), investedLine: $("invested-line"), pnlLine: $("pnl-line"), tradeForm: $("trade-form"), tradeBtn: $("trade-btn"), sideBuy: $("side-buy"), sideSell: $("side-sell"), actionInput: $("action"), amountRange: $("amount-range"), amountInput: $("amount"), advanceBtn: $("advance-btn"), mentorBtn: $("mentor-btn"), resetBtn: $("reset-btn"), statusLine: $("status-line"), modal: $("mentor-modal"), closeModal: $("close-modal"), mentorRoast: $("mentor-roast"), mentorLesson: $("mentor-lesson"), mentorSuggestion: $("mentor-suggestion"), chatLog: $("chat-log"), chatForm: $("chat-form"), chatInput: $("chat-input"), progressBar: $("progress-bar"), progressLabel: $("progress-label"), goalPct: $("goal-pct"), onboarding: $("onboarding-overlay"), onboardStart: $("onboard-start"), helpBtn: $("help-btn"), helpModal: $("help-modal"), closeHelp: $("close-help"), toastContainer: $("toast-container"), }; // State var state = E.newGame(); var chartMode = "networth"; var prevPrices = {}; for (var k in state.prices) prevPrices[k] = state.prices[k]; // Formatters var fmtMoney = function (n) { var neg = n < 0 ? "-" : ""; return neg + "\u20b9" + Math.abs(Math.round(n)).toLocaleString("en-IN"); }; var fmtPct = function (n) { return (n >= 0 ? "+" : "") + n.toFixed(2) + "%"; }; var chgClass = function (n) { return n > 0.001 ? "up" : n < -0.001 ? "down" : "flat"; }; // Toast notifications function toast(msg, type) { type = type || "info"; var div = document.createElement("div"); div.className = "toast " + type; div.textContent = msg; els.toastContainer.appendChild(div); setTimeout(function () { if (div.parentNode) div.parentNode.removeChild(div); }, 3000); } // API (LLM only) async function apiLLM(path, body) { try { var r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error("HTTP " + r.status); return await r.json(); } catch (e) { console.error("LLM API failed:", e); return null; } } async function fetchHealth() { try { var r = await fetch("/game-api/health"); return await r.json(); } catch (e) { return { llm: "error" }; } } // --- Rendering --- function render() { var s = state; var total = E.totalValue(s); var invested = E.investedValue(s); var pnl = E.totalPnl(s); var startVal = E.STARTING_CASH; var goalPct = (total / E.WIN_THRESHOLD) * 100; // Date & progress els.date.textContent = s.year + "-" + String(s.month).padStart(2, "0"); var monthsDone = s.months_elapsed; var pct = (monthsDone / E.GAME_LENGTH_MONTHS) * 100; els.progressBar.style.width = pct + "%"; els.progressBar.className = "progress-bar" + (pct > 80 ? " danger" : pct > 60 ? " warning" : ""); els.progressLabel.textContent = "Month " + monthsDone + "/" + E.GAME_LENGTH_MONTHS; els.goalPct.textContent = Math.round(goalPct) + "%"; els.goalPct.className = goalPct >= 100 ? "goal-pct up" : "goal-pct"; // Summary els.cashLine.textContent = fmtMoney(s.cash_balance); els.netWorth.textContent = fmtMoney(total); els.investedLine.textContent = fmtMoney(invested); els.pnlLine.textContent = fmtMoney(pnl); els.pnlLine.className = pnl >= 0 ? "up" : "down"; renderWatch(); renderPositions(); renderChart(); renderTradeBtn(); // Game over if (s.game_over) { els.advanceBtn.disabled = true; els.tradeBtn.disabled = true; if (s.won) { setStatus("YOU WIN! \u20b920L reached."); showGameOverBanner(true); } else { setStatus("GAME OVER - 10 years elapsed."); showGameOverBanner(false); } } } function renderTradeBtn() { var action = els.actionInput.value; els.tradeBtn.textContent = action === "buy" ? "BUY \u25b2" : "SELL \u25bc"; els.tradeBtn.className = action === "buy" ? "btn btn-buy" : "btn btn-sell"; } function showGameOverBanner(won) { var existing = document.querySelector(".game-over-banner"); if (existing) existing.remove(); var banner = document.createElement("div"); banner.className = "game-over-banner " + (won ? "win" : "lose"); banner.textContent = won ? "CONGRATULATIONS! You doubled your money. You beat the market." : "GAME OVER. 10 years passed. Reset to try again."; var summary = els.tradeForm.parentNode.querySelector(".summary"); if (summary) summary.insertAdjacentElement("afterend", banner); } function renderWatch() { var s = state; var rows = E.TRADABLE_KEYS.map(function (key) { var display = DISPLAY[key]; var price = s.prices[key]; var prev = prevPrices[key] !== undefined ? prevPrices[key] : price; var chg = (price - prev) / Math.max(prev, 1e-9) * 100; var absChg = price - prev; var cls = chgClass(chg); var active = chartMode === key ? " active" : ""; return "" + "" + display + "" + "" + fmtMoney(price) + "" + "" + (absChg >= 0 ? "+" : "") + absChg.toFixed(3) + "" + "" + fmtPct(chg) + "" + ""; }).join(""); els.watchBody.innerHTML = rows; els.watchBody.querySelectorAll("tr").forEach(function (tr) { tr.addEventListener("click", function () { var asset = tr.dataset.asset; chartMode = asset; els.chartTitle.textContent = DISPLAY[asset]; document.querySelectorAll(".chip").forEach(function (c) { c.classList.remove("active"); }); var chip = document.querySelector('.chip[data-chart="' + asset + '"]'); if (chip) chip.classList.add("active"); else document.querySelector('.chip[data-chart="networth"]').classList.add("active"); renderWatch(); renderChart(); }); }); } function renderPositions() { var s = state; var rows = []; for (var i = 0; i < E.TRADABLE_KEYS.length; i++) { var asset = E.TRADABLE_KEYS[i]; var qty = s.portfolio[asset]; if (qty <= 0.0001) continue; var price = s.prices[asset]; var basis = s.cost_basis[asset]; var current = qty * price; var pnl = current - basis; var pnlPct = basis > 0 ? (pnl / basis) * 100 : 0; var pnlCls = pnl >= 0 ? "pnl-pos" : "pnl-neg"; rows.push("" + "" + DISPLAY[asset] + "" + "" + qty.toFixed(4) + "" + "" + (basis > 0 ? fmtMoney(basis / qty) : "-") + "" + "" + fmtMoney(price) + "" + "" + fmtMoney(current) + "" + "" + fmtMoney(pnl) + " (" + fmtPct(pnlPct) + ")" + ""); } if (rows.length === 0) { els.positionsBody.innerHTML = 'Buy assets to see them here'; } else { els.positionsBody.innerHTML = rows.join(""); } } // Chart function renderChart() { var canvas = els.chart; var dpr = window.devicePixelRatio || 1; var cssW = canvas.clientWidth; var cssH = canvas.clientHeight; if (cssW <= 0 || cssH <= 0) return; canvas.width = cssW * dpr; canvas.height = cssH * dpr; var ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, cssW, cssH); var padL = 48, padR = 10, padT = 8, padB = 20; var plotW = cssW - padL - padR; var plotH = cssH - padT - padB; if (plotW <= 0 || plotH <= 0) return; var series; if (chartMode === "networth") { series = state.value_history; } else { series = (state.price_history || []).map(function (snap) { return snap[chartMode] || 0; }); } if (!series || series.length < 2) { ctx.fillStyle = "rgba(51,255,51,0.3)"; ctx.font = '12px "Share Tech Mono", monospace'; ctx.textAlign = "center"; ctx.fillText("Advance a month to build chart data", cssW / 2, cssH / 2); return; } var min = Infinity, max = -Infinity; for (var i = 0; i < series.length; i++) { if (series[i] < min) min = series[i]; if (series[i] > max) max = series[i]; } var range = (max - min) || 1; var yLo = min - range * 0.05; var yHi = max + range * 0.05; var yRange = yHi - yLo; var xStep = plotW / Math.max(series.length - 1, 1); // Grid ctx.strokeStyle = "rgba(51,255,51,0.06)"; ctx.lineWidth = 0.5; ctx.font = '10px "Share Tech Mono", monospace'; ctx.fillStyle = "rgba(51,255,51,0.35)"; for (var j = 0; j <= 4; j++) { var gy = padT + (plotH / 4) * j; ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(cssW - padR, gy); ctx.stroke(); var val = yHi - (yRange / 4) * j; var label = chartMode === "networth" ? fmtMoney(val).replace("\u20b9", "") : val.toFixed(3); ctx.textAlign = "right"; ctx.fillText(label, padL - 4, gy + 3); } // X labels var totalMonths = series.length; var labelEvery = Math.max(Math.floor(totalMonths / 6), 1); ctx.textAlign = "center"; for (var k = 0; k < totalMonths; k += labelEvery) { var lx = padL + k * xStep; var mIdx = state.months_elapsed - (totalMonths - 1 - k); var yStart = E.STARTING_YEAR; var absMonth = mIdx + E.STARTING_MONTH - 1; var yr = yStart + Math.floor(absMonth / 12); var mo = (absMonth % 12) + 1; ctx.fillText(String(mo).padStart(2, "0") + "/" + (yr % 100), lx, cssH - 4); } // Line var lineColor = chartMode === "networth" ? "#33ff33" : "#00e5ff"; ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.shadowColor = lineColor; ctx.shadowBlur = 6; ctx.beginPath(); for (var m = 0; m < series.length; m++) { var sx = padL + m * xStep; var sy = padT + plotH - ((series[m] - yLo) / yRange) * plotH; if (m === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); } ctx.stroke(); ctx.shadowBlur = 0; // Last dot var last = series[series.length - 1]; var lastX = padL + (series.length - 1) * xStep; var lastY = padT + plotH - ((last - yLo) / yRange) * plotH; ctx.fillStyle = lineColor; ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill(); if (chartMode === "networth") { els.chartTitle.textContent = "Net Worth"; } } // News & agents function addNews(headline, year, month) { if (!headline) return; var item = document.createElement("div"); item.className = "item"; item.innerHTML = '[' + year + '-' + String(month).padStart(2, "0") + ']' + headline; els.newsContent.prepend(item); var children = els.newsContent.children; while (children.length > 30) { children[children.length - 1].remove(); } } function addAgentActions(actions) { els.agentLog.innerHTML = ""; if (!actions || !actions.length) { els.agentLog.innerHTML = '
No agent activity this month.
'; return; } actions.forEach(function (a) { var t = (a.actions && a.actions[0]) || {}; var entry = document.createElement("div"); entry.className = "agent-entry"; entry.innerHTML = '' + a.agent + ' ' + '' + (t.action || "hold") + ' ' + (DISPLAY[t.asset] || t.asset || "") + '' + '' + (a.sentiment || "") + '' + '
' + (t.reason || "") + '
'; els.agentLog.appendChild(entry); }); } // --- Trade --- function handleTrade(e) { e.preventDefault(); if (state.game_over) { toast("Game is over. Reset to play again.", "warn"); return; } var assetDisplay = els.tradeForm.querySelector("#asset").value; var action = els.actionInput.value; var pct = parseFloat(els.amountInput.value) / 100; var key = Object.keys(DISPLAY).find(function (k) { return DISPLAY[k] === assetDisplay; }); if (!key) { toast("Invalid asset selection.", "error"); return; } if (pct <= 0 || pct > 1) { toast("Amount must be 1-100% of portfolio.", "error"); return; } try { if (action === "buy") { var total = E.totalValue(state); var cost = total * pct; if (cost > state.cash_balance) { toast("Insufficient cash. You need \u20b9" + Math.round(cost).toLocaleString("en-IN") + ".", "error"); return; } } else { var qty = state.portfolio[key]; if (qty <= 0.0001) { toast("You don't own any " + assetDisplay + ".", "error"); return; } } E.executePlayerTrade(state, key, action, pct); addNews("You " + action + " " + (pct * 100).toFixed(0) + "% " + assetDisplay, state.year, state.month); setStatus("Traded: " + action.toUpperCase() + " " + assetDisplay + " " + (pct * 100).toFixed(0) + "%"); toast(action.toUpperCase() + " " + assetDisplay + " (" + (pct * 100).toFixed(0) + "%)", "info"); render(); } catch (err) { toast("Trade failed: " + err.message, "error"); } } // --- Advance Month --- async function handleAdvance() { if (state.game_over) { toast("Game over. Reset to play again.", "warn"); return; } setStatus("Advancing..."); els.advanceBtn.disabled = true; els.advanceBtn.textContent = "..."; try { prevPrices = {}; for (var k in state.prices) prevPrices[k] = state.prices[k]; var nextY = state.year + (state.month === 12 ? 1 : 0); var nextM = state.month === 12 ? 1 : state.month + 1; var ev = Ev.eventForMonth(nextY, nextM); var snap = { month: state.month, year: state.year, prices: {}, portfolio: {}, cash: state.cash_balance, total_value: E.totalValue(state), unrealized_pnl: E.totalPnl(state), }; for (var k2 in state.prices) snap.prices[k2] = state.prices[k2]; for (var k3 in state.portfolio) snap.portfolio[k3] = state.portfolio[k3]; var agentActions = E.allLocalAgentsDecide(snap, ev); var news = { headline: ev.headline, regime: ev.regime, impact: {}, duration_months: ev.duration_months, year: ev.year, month: ev.month, }; for (var k4 in ev.impact) news.impact[k4] = ev.impact[k4]; E.advanceMonth(state, news, agentActions, ev); addNews(ev.headline, state.year, state.month); addAgentActions(agentActions); // Show deterministic insight (AI insight only on demand) var detInsight = fallbackInsight(ev, state); els.insightText.textContent = detInsight; els.insightText.className = "insight-text deterministic"; var monthLabel = state.year + "-" + String(state.month).padStart(2, "0"); setStatus("Month " + state.months_elapsed + "/" + E.GAME_LENGTH_MONTHS + " (" + monthLabel + ")"); if (state.game_over) { if (state.won) { toast("YOU WIN! \u20b920L reached. Congratulations!", "info"); } else { toast("GAME OVER. 10 years have passed.", "warn"); } } render(); } catch (e) { setStatus("Error: " + e.message, true); } finally { els.advanceBtn.textContent = "Advance Month \u23ce"; if (!state.game_over) els.advanceBtn.disabled = false; } } function fallbackInsight(ev, s) { var pnl = E.totalPnl(s); var regime = (ev.regime || "stagnation").replace(/_/g, " "); if (pnl < -50000) return "Cut losers in " + regime + " regimes. Rotate into defensives."; if (pnl > 50000) return "Book partial profits. " + regime + " trends rarely last."; return "Hold steady through this " + regime + " phase."; } async function generateInsight() { if (state.game_over) return; var ev = Ev.eventForMonth(state.year, state.month); els.insightText.textContent = "Generating..."; els.insightText.className = "insight-text"; els.llmBadge.textContent = "..."; els.llmBadge.className = "badge"; try { var r = await apiLLM("/game-api/insight", { event: { headline: ev.headline, regime: ev.regime }, snapshot: { unrealized_pnl: E.totalPnl(state), cash: state.cash_balance, total_value: E.totalValue(state), }, }); if (r && r.insight) { els.insightText.textContent = r.insight; els.insightText.className = "insight-text"; els.llmBadge.textContent = "LLM"; els.llmBadge.className = "badge live"; } else { els.insightText.textContent = fallbackInsight(ev, state); els.insightText.className = "insight-text deterministic"; els.llmBadge.textContent = "FALLBACK"; els.llmBadge.className = "badge fallback"; } } catch (e2) { els.insightText.textContent = fallbackInsight(ev, state); els.insightText.className = "insight-text deterministic"; els.llmBadge.textContent = "FALLBACK"; els.llmBadge.className = "badge fallback"; } } // --- Reset --- function handleReset() { if (state.game_over || state.months_elapsed > 0) { if (!confirm("Reset your game? All progress will be lost.")) return; } state = E.newGame(); prevPrices = {}; for (var k in state.prices) prevPrices[k] = state.prices[k]; els.chatLog.innerHTML = '
Welcome back. Ask me about your portfolio or strategy.
'; els.newsContent.innerHTML = '
System ready. Press Advance Month to begin.
'; els.agentLog.innerHTML = ""; els.insightText.textContent = "Click Generate to get AI market commentary."; els.insightText.className = "insight-text deterministic"; els.llmBadge.textContent = "OFF"; els.llmBadge.className = "badge fallback"; els.advanceBtn.disabled = false; els.advanceBtn.textContent = "Advance Month \u23ce"; els.tradeBtn.disabled = false; els.progressBar.style.width = "0%"; els.progressBar.className = "progress-bar"; els.progressLabel.textContent = "Month 0/" + E.GAME_LENGTH_MONTHS; var banner = document.querySelector(".game-over-banner"); if (banner) banner.remove(); setStatus("Game reset"); toast("New game started. Good luck!", "info"); render(); } // --- Mentor --- async function handleMentor() { var total = E.totalValue(state); var invested = E.investedValue(state); var pnl = E.totalPnl(state); // Ensure at least a year has passed if (state.months_elapsed < 1) { toast("Advance at least one month before getting a review.", "warn"); return; } setStatus("Generating review..."); var summary = { year: state.year, month: state.month, starting_value: E.STARTING_CASH, ending_value: total, invested_value: invested, cash: state.cash_balance, unrealized_pnl: pnl, max_drawdown: -0.25, sharpe_ratio: 0.0, allocations: computeAllocations(state), ledger: state.ledger.filter(function (t) { return t.year === state.year; }), }; var r = await apiLLM("/game-api/mentor", { summary: summary }); if (r && r.review) { els.mentorRoast.textContent = r.review.roast || "-"; els.mentorLesson.textContent = "LESSON: " + (r.review.lesson || ""); els.mentorSuggestion.textContent = "NEXT MOVE: " + (r.review.suggestion || ""); els.modal.classList.remove("hidden"); setStatus("Review ready"); } else { toast("Mentor unavailable. Try again.", "error"); } } function computeAllocations(s) { var total = E.totalValue(s); var out = {}; for (var i = 0; i < E.TRADABLE_KEYS.length; i++) { var a = E.TRADABLE_KEYS[i]; out[a] = total > 0 ? (s.portfolio[a] * s.prices[a]) / total : 0; } return out; } // --- Chat --- async function handleChat(e) { e.preventDefault(); var msg = els.chatInput.value.trim(); if (!msg) return; appendChat("user", msg); els.chatInput.value = ""; var snapshot = { cash: state.cash_balance, total_value: E.totalValue(state), unrealized_pnl: E.totalPnl(state), positions: E.TRADABLE_KEYS .filter(function (k) { return state.portfolio[k] > 0.0001; }) .map(function (k) { return { asset: DISPLAY[k], qty: state.portfolio[k], price: state.prices[k], value: state.portfolio[k] * state.prices[k], }; }), }; var warmDiv = appendChat("bot", "Thinking..."); var r = await apiLLM("/game-api/chat", { message: msg, snapshot: snapshot }); if (warmDiv && warmDiv.parentNode) warmDiv.remove(); if (r && r.reply) { var isFallback = r.reply.indexOf("trouble") >= 0 || r.reply.length < 20; appendChat("bot", r.reply, isFallback); } else { appendChat("bot", "Can't reach the advisor right now. Check your connection.", true); } } function appendChat(role, content, fallback) { var div = document.createElement("div"); div.className = "chat-msg " + role + (fallback ? " fallback" : ""); div.textContent = content; els.chatLog.appendChild(div); els.chatLog.scrollTop = els.chatLog.scrollHeight; return div; } function setStatus(text, isError) { els.statusLine.textContent = text; els.statusLine.style.color = isError ? "var(--red)" : "var(--phosphor-dim)"; } // --- Side toggle --- function setTradeSide(side) { els.actionInput.value = side; els.sideBuy.classList.toggle("active", side === "buy"); els.sideSell.classList.toggle("active", side === "sell"); renderTradeBtn(); } els.sideBuy.addEventListener("click", function () { setTradeSide("buy"); }); els.sideSell.addEventListener("click", function () { setTradeSide("sell"); }); // Amount slider sync els.amountRange.addEventListener("input", function () { els.amountInput.value = els.amountRange.value; }); els.amountInput.addEventListener("input", function () { var v = parseInt(els.amountInput.value) || 1; v = Math.max(1, Math.min(100, v)); els.amountRange.value = v; }); // --- Chart chips --- document.querySelectorAll(".chip").forEach(function (chip) { chip.addEventListener("click", function () { document.querySelectorAll(".chip").forEach(function (c) { c.classList.remove("active"); }); chip.classList.add("active"); chartMode = chip.dataset.chart; els.chartTitle.textContent = chartMode === "networth" ? "Net Worth" : DISPLAY[chartMode]; renderWatch(); renderChart(); }); }); // --- Keyboard shortcuts --- document.addEventListener("keydown", function (e) { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return; if (e.key === "Enter") { e.preventDefault(); if (els.onboarding && !els.onboarding.classList.contains("hidden")) { startGame(); } else if (!state.game_over) { handleAdvance(); } } }); // --- Onboarding --- function startGame() { els.onboarding.classList.add("hidden"); render(); setStatus("Ready - Trade or Advance"); } els.onboardStart.addEventListener("click", startGame); // --- Help modal --- els.helpBtn.addEventListener("click", function () { els.helpModal.classList.remove("hidden"); }); els.closeHelp.addEventListener("click", function () { els.helpModal.classList.add("hidden"); }); els.helpModal.addEventListener("click", function (e) { if (e.target === els.helpModal) els.helpModal.classList.add("hidden"); }); // --- Mentor modal close --- els.closeModal.addEventListener("click", function () { els.modal.classList.add("hidden"); }); els.modal.addEventListener("click", function (e) { if (e.target === els.modal) els.modal.classList.add("hidden"); }); // --- Event wiring --- els.tradeForm.addEventListener("submit", handleTrade); els.advanceBtn.addEventListener("click", handleAdvance); els.mentorBtn.addEventListener("click", handleMentor); els.resetBtn.addEventListener("click", handleReset); els.chatForm.addEventListener("submit", handleChat); window.addEventListener("resize", function () { requestAnimationFrame(renderChart); }); // Generate insight on demand document.getElementById("insight-generate-btn").addEventListener("click", generateInsight); // --- LLM status --- function applyLlmStatus(h) { var status = h.llm || "uninitialized"; if (status === "modal") { els.llmStatus.textContent = "LLM: CLOUD"; els.llmStatus.className = "llm-tag loaded"; els.llmBadge.className = "badge live"; els.llmBadge.textContent = "LLM"; els.chatLlmBadge.className = "badge live"; els.chatLlmBadge.textContent = "LLM"; setStatus("Ready (cloud GPU)"); } else if (status === "hf") { els.llmStatus.textContent = "LLM: HF API"; els.llmStatus.className = "llm-tag loaded"; els.llmBadge.className = "badge live"; els.llmBadge.textContent = "LLM"; els.chatLlmBadge.className = "badge live"; els.chatLlmBadge.textContent = "LLM"; setStatus("Ready (HF API)"); } else if (status === "mock") { els.llmStatus.textContent = "LLM: LOCAL"; els.llmStatus.className = "llm-tag mock"; els.llmBadge.className = "badge fallback"; els.llmBadge.textContent = "OFF"; els.chatLlmBadge.className = "badge fallback"; els.chatLlmBadge.textContent = "FALLBACK"; setStatus("Ready (local fallback mode)"); } else if (status === "loading") { els.llmStatus.textContent = "LLM: LOADING"; els.llmStatus.className = "llm-tag loading"; els.llmBadge.className = "badge fallback"; els.llmBadge.textContent = "OFF"; els.chatLlmBadge.className = "badge fallback"; els.chatLlmBadge.textContent = "FALLBACK"; setStatus("Loading LLM..."); } else { els.llmStatus.textContent = "LLM: LOCAL"; els.llmStatus.className = "llm-tag mock"; els.llmBadge.className = "badge fallback"; els.llmBadge.textContent = "OFF"; els.chatLlmBadge.className = "badge fallback"; els.chatLlmBadge.textContent = "FALLBACK"; setStatus("Ready (local fallback)"); } } var lastStatusKey = null; async function pollLlm() { try { var h = await fetchHealth(); var key = (h.llm || "?") + "|" + (h.llm_error || ""); if (key !== lastStatusKey) { lastStatusKey = key; applyLlmStatus(h); } } catch (e) {} } // Boot (async function boot() { await pollLlm(); // Show onboarding if (els.onboarding) { els.onboarding.classList.remove("hidden"); } // Poll LLM status var tick = setInterval(async function () { var h = await (await fetch("/game-api/health").catch(function () { return null; })); if (!h || !h.json) return; try { h = await h.json(); } catch (e) { return; } var cur = h.llm || "?"; if (cur !== (lastStatusKey || "").split("|")[0]) { applyLlmStatus(h); lastStatusKey = (h.llm || "?") + "|" + (h.llm_error || ""); } if (cur === "modal" || cur === "mock" || cur === "hf" || cur === "error") { clearInterval(tick); } }, 3000); })(); })();