From 6c1f672b765d7fec1cc9d4a6c9183e1472f3f06e Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 8 Jul 2026 22:59:34 +0200 Subject: [PATCH] Split UI render into full rebuild and passive refresh The old renderLoop rebuilt the whole DOM every tick and had to bail out entirely while a is focused - recreating it mid-render - // forces any open native dropdown to close immediately. - const active = document.activeElement - if (!(active instanceof HTMLSelectElement)) { - scheduleRender() - } + scheduleRefresh() setTimeout(renderLoop, RENDER_MS) } diff --git a/src/ui.ts b/src/ui.ts index 659ec10..faa741f 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -69,11 +69,12 @@ function hostLabel(state: GameState, hardwareId: string): string { return `${hw.nickname} (${def.name})` } +// Full structural rebuild - used for the initial mount and after any user +// action (deploy/uninstall/buy/fix/prestige), since those already change +// the shape of the DOM (new rows, new buttons) and are driven by a click +// that has already completed, so there's nothing left mid-interaction to +// wipe out. export function render(root: HTMLElement, state: GameState, onChange: () => void) { - const cap = capacity(state) - const rate = totalRate(state) - const inOutage = Date.now() < state.outageUntil - if (!selectedHardwareId || !state.hardware.find((h) => h.id === selectedHardwareId)) { selectedHardwareId = state.hardware[0]?.id ?? null } @@ -81,12 +82,12 @@ export function render(root: HTMLElement, state: GameState, onChange: () => void const prevLogScroll = root.querySelector('.log-list')?.scrollTop root.innerHTML = '' - root.appendChild(buildHeader(state, rate, inOutage)) + root.appendChild(buildHeader(state)) root.appendChild(buildRackArt(state)) const grid = document.createElement('div') grid.className = 'grid' - grid.appendChild(buildLeftColumn(state, cap, onChange)) + grid.appendChild(buildLeftColumn(state, onChange)) grid.appendChild(buildRightColumn(state, onChange)) root.appendChild(grid) @@ -102,22 +103,82 @@ export function render(root: HTMLElement, state: GameState, onChange: () => void save(state) } -function buildHeader(state: GameState, rate: number, inOutage: boolean): HTMLElement { +// Passive per-tick refresh - patches the values that drift on their own +// between user actions (points/rate ticking up, background crashes, new +// log lines) in place, instead of tearing the whole panel down. Never +// touches the deploy form or recreates any button, so it can't step on an +// in-flight click or reset a select the player is mid-choice on. +export function refresh(root: HTMLElement, state: GameState, onChange: () => void): boolean { + const header = root.querySelector('.topbar') + const grid = root.querySelector('.grid') + if (!header || !grid) return false // nothing built yet - caller should do a full render + + updateHeader(root, state) + updateRackArt(root, state) + updateHardwareList(root, state) + updateBuyButtons(root, state) + updateUpgradeList(root, state) + updateServiceList(root, state, onChange) + updateDeployOptions(root, state) + updateLog(root, state) + + const hasBanner = !!root.querySelector('.prestige-banner') + if (canPrestige(state) && !hasBanner) { + root.appendChild(buildPrestigeBanner(state, onChange)) + } else if (!canPrestige(state) && hasBanner) { + root.querySelector('.prestige-banner')!.remove() + } + + save(state) + return true +} + +function buildHeader(state: GameState): HTMLElement { const header = document.createElement('header') header.className = 'topbar' header.innerHTML = `
rack_
- ${inOutage ? '✗' : '▲'} - ${inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`} - ${fmt(state.points)} pts + + +
` header.querySelector('.help-btn')!.addEventListener('click', () => toggleHelp()) + updateHeader(header, state) return header } +function updateHeader(root: HTMLElement, state: GameState) { + const rate = totalRate(state) + const inOutage = Date.now() < state.outageUntil + const arrow = root.querySelector('.arrow') + const rateEl = root.querySelector('.rate') + const pointsEl = root.querySelector('.points') + if (arrow) arrow.textContent = inOutage ? '✗' : '▲' + if (rateEl) rateEl.textContent = inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec` + if (pointsEl) pointsEl.textContent = `${fmt(state.points)} pts` +} + +function rackArtLineHtml(state: GameState, hw: GameState['hardware'][number]): string { + const hostCap = hostCapacity(state, hw.id) + const badges = state.services + .filter((s) => s.hardwareId === hw.id) + .map((s) => { + const abbr = SERVICE_ABBR[s.defId] ?? '??' + const quirk = quirkDef(s.quirkId) + const cls = s.status === 'running' ? 'slot-running' : 'slot-crashed' + return `[${abbr}]` + }) + .join('') + + const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed) + const dots = '·'.repeat(Math.min(freeSlots, 20)) + + return `${hw.nickname.padEnd(16, ' ')}${badges}${dots}` +} + function buildRackArt(state: GameState): HTMLElement { const section = document.createElement('section') section.className = 'panel rack-art-panel' @@ -126,45 +187,41 @@ function buildRackArt(state: GameState): HTMLElement { pre.className = 'rack-art' for (const hw of state.hardware) { - const hostCap = hostCapacity(state, hw.id) const line = document.createElement('div') line.className = 'rack-art-line' - - const badges = state.services - .filter((s) => s.hardwareId === hw.id) - .map((s) => { - const abbr = SERVICE_ABBR[s.defId] ?? '??' - const quirk = quirkDef(s.quirkId) - const cls = s.status === 'running' ? 'slot-running' : 'slot-crashed' - return `[${abbr}]` - }) - .join('') - - const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed) - const dots = '·'.repeat(Math.min(freeSlots, 20)) - - line.innerHTML = `${hw.nickname.padEnd(16, ' ')}${badges}${dots}` + line.dataset.hw = hw.id + line.innerHTML = rackArtLineHtml(state, hw) pre.appendChild(line) } section.appendChild(pre) return section } -function buildLeftColumn(state: GameState, cap: ReturnType, onChange: () => void): HTMLElement { +// Same set of hosts still shown (hardware count only changes via buyHardware, +// which triggers a full render) - just patch each line's badges/free-slot +// dots, since crashes/fixes flip badge state every tick. +function updateRackArt(root: HTMLElement, state: GameState) { + for (const hw of state.hardware) { + const line = root.querySelector(`.rack-art-line[data-hw="${hw.id}"]`) + if (line) line.innerHTML = rackArtLineHtml(state, hw) + } +} + +function buildLeftColumn(state: GameState, onChange: () => void): HTMLElement { const col = document.createElement('div') col.className = 'col left' const hwSection = document.createElement('section') hwSection.className = 'panel' - hwSection.innerHTML = `

HARDWARE Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}

` + hwSection.innerHTML = `

HARDWARE

` const hwList = document.createElement('div') hwList.className = 'list' + hwList.dataset.role = 'hw-list' for (const hw of state.hardware) { const def = HARDWARE.find((h) => h.id === hw.defId)! - const hostCap = hostCapacity(state, hw.id) const item = document.createElement('div') item.className = 'item hardware' + (hw.id === selectedHardwareId ? ' selected' : '') - item.innerHTML = `${hostLabel(state, hw.id)}ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}` + item.dataset.hw = hw.id item.title = `${def.flavour} (click to select as the deploy target)` item.onclick = () => { selectedHardwareId = hw.id @@ -174,21 +231,19 @@ function buildLeftColumn(state: GameState, cap: ReturnType, onC } hwSection.appendChild(hwList) - // Highest-tier hardware the player already owns - buying another unit of - // it is a horizontal bridge (more capacity now, at a rising price) for - // the often-long grind between one tier's cost and the next tier's. - const topOwnedHw = [...state.hardware] - .map((h) => HARDWARE.find((d) => d.id === h.defId)!) - .sort((a, b) => HARDWARE.indexOf(b) - HARDWARE.indexOf(a))[0] - if (topOwnedHw && topOwnedHw.cost > 0) { - const repeatCost = hardwareCost(state, topOwnedHw.id) + // Every tier the player already owns - buying another unit of any of them + // is a horizontal bridge (more capacity now, at a rising price) for the + // often-long grind between one tier's cost and the next tier's. + const ownedTiers = HARDWARE.filter( + (def) => def.cost > 0 && state.hardware.some((h) => h.defId === def.id), + ) + for (const tier of ownedTiers) { const repeatBtn = document.createElement('button') repeatBtn.className = 'buy-btn' - repeatBtn.textContent = `+ buy another ${topOwnedHw.name} (${fmt(repeatCost)})` - repeatBtn.disabled = state.points < repeatCost - repeatBtn.title = `Add a second ${topOwnedHw.name} as its own host, at a rising price per extra unit.` + repeatBtn.dataset.tier = tier.id + repeatBtn.title = `Add another ${tier.name} as its own host, at a rising price per extra unit.` repeatBtn.onclick = () => { - if (buyHardware(state, topOwnedHw.id)) onChange() + if (buyHardware(state, tier.id)) onChange() } hwSection.appendChild(repeatBtn) } @@ -197,6 +252,7 @@ function buildLeftColumn(state: GameState, cap: ReturnType, onC if (nextHw) { const buyBtn = document.createElement('button') buyBtn.className = 'buy-btn' + buyBtn.dataset.tier = 'next' buyBtn.textContent = `+ buy ${nextHw.name} (${fmt(nextHw.cost)})` buyBtn.disabled = state.points < nextHw.cost buyBtn.title = nextHw.flavour @@ -217,6 +273,7 @@ function buildLeftColumn(state: GameState, cap: ReturnType, onC const lockedByReq = up.requires ? !state.upgrades.includes(up.requires) : false const row = document.createElement('div') row.className = 'item upgrade' + (owned ? ' owned' : '') + (lockedByReq ? ' locked' : '') + row.dataset.up = up.id row.title = up.flavour row.innerHTML = `${up.name}${ owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost) @@ -232,9 +289,144 @@ function buildLeftColumn(state: GameState, cap: ReturnType, onC upSection.appendChild(upList) col.appendChild(upSection) + updateHardwareList(col, state) + updateBuyButtons(col, state) + updateUpgradeList(col, state) + return col } +function updateHardwareList(root: HTMLElement, state: GameState) { + const cap = capacity(state) + const summary = root.querySelector('.cap-summary') + if (summary) summary.textContent = `Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}` + + for (const hw of state.hardware) { + const item = root.querySelector(`[data-role="hw-list"] .item[data-hw="${hw.id}"]`) + if (!item) continue + const hostCap = hostCapacity(state, hw.id) + item.classList.toggle('selected', hw.id === selectedHardwareId) + item.innerHTML = `${hostLabel(state, hw.id)}ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}` + } +} + +function updateBuyButtons(root: HTMLElement, state: GameState) { + for (const tier of HARDWARE) { + const btn = root.querySelector(`.buy-btn[data-tier="${tier.id}"]`) + if (!btn) continue + const cost = hardwareCost(state, tier.id) + btn.textContent = `+ buy another ${tier.name} (${fmt(cost)})` + btn.disabled = state.points < cost + } + const nextBtn = root.querySelector('.buy-btn[data-tier="next"]') + if (nextBtn) { + const nextHw = HARDWARE.find((h) => !state.hardware.some((owned) => owned.defId === h.id)) + if (nextHw) nextBtn.disabled = state.points < nextHw.cost + } +} + +function updateUpgradeList(root: HTMLElement, state: GameState) { + for (const up of UPGRADES) { + const row = root.querySelector(`.item.upgrade[data-up="${up.id}"]`) + if (!row) continue + const owned = state.upgrades.includes(up.id) + if (!owned) row.classList.toggle('affordable', state.points >= up.cost) + } +} + +function serviceRowHtml(state: GameState, svc: GameState['services'][number]): string { + const def = SERVICES.find((s) => s.id === svc.defId)! + const showRates = state.upgrades.includes('monitoring') + const rate = ratePerSec(state, svc) + const badge = svc.redundant ? ' ×2' : '' + const quirk = quirkDef(svc.quirkId) + const quirkBadge = quirk ? ` ${quirk.name}` : '' + return ` + ${svc.status === 'running' ? '✓' : '✗'} + ${def.name}${badge}${quirkBadge} + ${hostLabel(state, svc.hardwareId)} + ${def.category} + ${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'} + ${showRates && svc.status === 'running' ? `risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr` : ''} + ` +} + +function buildServiceRow(state: GameState, svc: GameState['services'][number], onChange: () => void): HTMLElement { + const row = document.createElement('div') + row.dataset.svc = svc.id + row.className = 'item service ' + svc.status + row.innerHTML = serviceRowHtml(state, svc) + + if (svc.status === 'crashed') { + const fixBtn = document.createElement('button') + fixBtn.className = 'fix-btn' + fixBtn.textContent = canFix(svc) ? 'fix' : '...' + fixBtn.disabled = !canFix(svc) + fixBtn.onclick = (e) => { + e.stopPropagation() + fixService(state, svc.id) + onChange() + } + row.appendChild(fixBtn) + } + const def = SERVICES.find((s) => s.id === svc.defId)! + const uninstallBtn = document.createElement('button') + uninstallBtn.className = 'uninstall-btn' + uninstallBtn.textContent = 'rm' + uninstallBtn.title = `Uninstall ${def.name} (salvage ${Math.round(def.deployCost * 0.25) * (svc.redundant ? 2 : 1)} pts)` + uninstallBtn.onclick = (e) => { + e.stopPropagation() + uninstallService(state, svc.id) + onChange() + } + row.appendChild(uninstallBtn) + return row +} + +// Services are only added/removed via deploy/uninstall, which already go +// through a full render - so on a passive tick the set of rows is stable +// and only their status/rate/fix-button needs patching (crash/fix happen +// in the background tick loop). +function updateServiceList(root: HTMLElement, state: GameState, onChange: () => void) { + for (const svc of state.services) { + const row = root.querySelector(`.item.service[data-svc="${svc.id}"]`) + if (!row) continue + row.className = 'item service ' + svc.status + const fixBtn = row.querySelector('.fix-btn') + const uninstallBtn = row.querySelector('.uninstall-btn') + row.innerHTML = serviceRowHtml(state, svc) + if (svc.status === 'crashed') { + const btn = fixBtn ?? document.createElement('button') + btn.className = 'fix-btn' + btn.textContent = canFix(svc) ? 'fix' : '...' + btn.disabled = !canFix(svc) + if (!fixBtn) { + btn.onclick = (e) => { + e.stopPropagation() + fixService(state, svc.id) + onChange() + } + } + row.appendChild(btn) + } + if (uninstallBtn) row.appendChild(uninstallBtn) + } +} + +function updateDeployOptions(root: HTMLElement, state: GameState) { + const select = root.querySelector('.svc-select') + if (!select) return + const targetCap = selectedHardwareId ? hostCapacity(state, selectedHardwareId) : null + for (const opt of Array.from(select.options)) { + const def = SERVICES.find((s) => s.id === opt.value)! + const noRoom = !!( + targetCap && + (targetCap.ramUsed + def.ram > targetCap.ram || targetCap.powerUsed + def.power > targetCap.power) + ) + opt.disabled = noRoom + } +} + function buildRightColumn(state: GameState, onChange: () => void): HTMLElement { const col = document.createElement('div') col.className = 'col right' @@ -245,47 +437,8 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement { const svcList = document.createElement('div') svcList.className = 'list' - const showRates = state.upgrades.includes('monitoring') - for (const svc of state.services) { - const def = SERVICES.find((s) => s.id === svc.defId)! - const row = document.createElement('div') - row.className = 'item service ' + svc.status - const rate = ratePerSec(state, svc) - const badge = svc.redundant ? ' ×2' : '' - const quirk = quirkDef(svc.quirkId) - const quirkBadge = quirk ? ` ${quirk.name}` : '' - row.innerHTML = ` - ${svc.status === 'running' ? '✓' : '✗'} - ${def.name}${badge}${quirkBadge} - ${hostLabel(state, svc.hardwareId)} - ${def.category} - ${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'} - ${showRates && svc.status === 'running' ? `risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr` : ''} - ` - if (svc.status === 'crashed') { - const fixBtn = document.createElement('button') - fixBtn.className = 'fix-btn' - fixBtn.textContent = canFix(svc) ? 'fix' : '...' - fixBtn.disabled = !canFix(svc) - fixBtn.onclick = (e) => { - e.stopPropagation() - fixService(state, svc.id) - onChange() - } - row.appendChild(fixBtn) - } - const uninstallBtn = document.createElement('button') - uninstallBtn.className = 'uninstall-btn' - uninstallBtn.textContent = 'rm' - uninstallBtn.title = `Uninstall ${def.name} (salvage ${Math.round(def.deployCost * 0.25) * (svc.redundant ? 2 : 1)} pts)` - uninstallBtn.onclick = (e) => { - e.stopPropagation() - uninstallService(state, svc.id) - onChange() - } - row.appendChild(uninstallBtn) - svcList.appendChild(row) + svcList.appendChild(buildServiceRow(state, svc, onChange)) } svcSection.appendChild(svcList) @@ -323,10 +476,7 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement { const logList = document.createElement('div') logList.className = 'log-list' for (const entry of state.log) { - const line = document.createElement('div') - line.className = 'log-line' - line.innerHTML = `${timeAgo(entry.t)} ${entry.text}` - logList.appendChild(line) + logList.appendChild(buildLogLine(entry)) } logSection.appendChild(logList) col.appendChild(logSection) @@ -334,6 +484,25 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement { return col } +function buildLogLine(entry: GameState['log'][number]): HTMLElement { + const line = document.createElement('div') + line.className = 'log-line' + line.innerHTML = `${timeAgo(entry.t)} ${entry.text}` + return line +} + +// The log only ever grows (new entries pushed on crash/fix/purchase/etc) - +// append what's new rather than rebuilding the whole scrollable list, which +// would also reset the user's scroll position. +function updateLog(root: HTMLElement, state: GameState) { + const logList = root.querySelector('.log-list') + if (!logList) return + const rendered = logList.children.length + for (let i = rendered; i < state.log.length; i++) { + logList.appendChild(buildLogLine(state.log[i])) + } +} + function buildPrestigeBanner(state: GameState, onChange: () => void): HTMLElement { const banner = document.createElement('div') banner.className = 'prestige-banner'