Split UI render into full rebuild and passive refresh
All checks were successful
Docker / build-and-push (push) Successful in 37s
All checks were successful
Docker / build-and-push (push) Successful in 37s
The old renderLoop rebuilt the whole DOM every tick and had to bail out entirely while a <select> was focused to avoid closing it. refresh() now patches only values that drift on their own (points/rate, crashes, log lines) in place, leaving the deploy form and buttons untouched, while render() still handles the structural rebuilds triggered by user actions.
This commit is contained in:
21
src/main.ts
21
src/main.ts
@@ -1,16 +1,28 @@
|
||||
import './style.css'
|
||||
import { load, newGame, save } from './state'
|
||||
import { tick } from './engine'
|
||||
import { render } from './ui'
|
||||
import { render, refresh } from './ui'
|
||||
import { toggleHelp } from './help'
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
const state = load() ?? newGame()
|
||||
|
||||
// User actions (deploy/uninstall/buy/fix/prestige) change the DOM's shape,
|
||||
// so they get a full rebuild for instant feedback.
|
||||
function scheduleRender() {
|
||||
render(app, state, scheduleRender)
|
||||
}
|
||||
|
||||
// The passive per-tick refresh only patches values that drift on their own
|
||||
// (points/rate, background crashes, new log lines) - it never recreates
|
||||
// the deploy form or any button, so it can't step on an in-flight click or
|
||||
// reset a select the player is mid-choice on.
|
||||
function scheduleRefresh() {
|
||||
if (!refresh(app, state, scheduleRender)) scheduleRender()
|
||||
}
|
||||
|
||||
scheduleRender()
|
||||
|
||||
let last = performance.now()
|
||||
// Passive UI refresh - user actions call scheduleRender() directly for
|
||||
// instant feedback, so this only needs to be fast enough to reflect the
|
||||
@@ -25,12 +37,7 @@ function loop(now: number) {
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
// Skip the rebuild while a <select> 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)
|
||||
}
|
||||
|
||||
|
||||
341
src/ui.ts
341
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 = `
|
||||
<div class="brand">rack<span class="cursor">_</span></div>
|
||||
<div class="stat">
|
||||
<span class="arrow">${inOutage ? '✗' : '▲'}</span>
|
||||
<span class="rate">${inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`}</span>
|
||||
<span class="points">${fmt(state.points)} pts</span>
|
||||
<span class="arrow"></span>
|
||||
<span class="rate"></span>
|
||||
<span class="points"></span>
|
||||
<button class="help-btn" title="Help & changelog (?)">?</button>
|
||||
</div>
|
||||
`
|
||||
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 `<span class="${cls}" title="${SERVICES.find((d) => d.id === s.defId)!.name}${quirk ? ` — ${quirk.name}` : ''}">[${abbr}]</span>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed)
|
||||
const dots = '·'.repeat(Math.min(freeSlots, 20))
|
||||
|
||||
return `<span class="rack-host-name">${hw.nickname.padEnd(16, ' ')}</span>${badges}<span class="dim">${dots}</span>`
|
||||
}
|
||||
|
||||
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 `<span class="${cls}" title="${SERVICES.find((d) => d.id === s.defId)!.name}${quirk ? ` — ${quirk.name}` : ''}">[${abbr}]</span>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed)
|
||||
const dots = '·'.repeat(Math.min(freeSlots, 20))
|
||||
|
||||
line.innerHTML = `<span class="rack-host-name">${hw.nickname.padEnd(16, ' ')}</span>${badges}<span class="dim">${dots}</span>`
|
||||
line.dataset.hw = hw.id
|
||||
line.innerHTML = rackArtLineHtml(state, hw)
|
||||
pre.appendChild(line)
|
||||
}
|
||||
section.appendChild(pre)
|
||||
return section
|
||||
}
|
||||
|
||||
function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, 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<HTMLElement>(`.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 = `<h2>HARDWARE <span class="dim">Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}</span></h2>`
|
||||
hwSection.innerHTML = `<h2>HARDWARE <span class="dim cap-summary"></span></h2>`
|
||||
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 = `<span class="name">${hostLabel(state, hw.id)}</span><span class="dim tiny">ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}</span>`
|
||||
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<typeof capacity>, 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<typeof capacity>, 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<typeof capacity>, 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 = `<span class="name">${up.name}</span><span class="upgrade-status">${
|
||||
owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost)
|
||||
@@ -232,9 +289,144 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, 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<HTMLElement>(`[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 = `<span class="name">${hostLabel(state, hw.id)}</span><span class="dim tiny">ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
function updateBuyButtons(root: HTMLElement, state: GameState) {
|
||||
for (const tier of HARDWARE) {
|
||||
const btn = root.querySelector<HTMLButtonElement>(`.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<HTMLButtonElement>('.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<HTMLElement>(`.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 ? ' <span class="redundant-badge">×2</span>' : ''
|
||||
const quirk = quirkDef(svc.quirkId)
|
||||
const quirkBadge = quirk ? ` <span class="quirk-badge" title="${quirk.flavour}">${quirk.name}</span>` : ''
|
||||
return `
|
||||
<span class="status-icon">${svc.status === 'running' ? '✓' : '✗'}</span>
|
||||
<span class="name">${def.name}${badge}${quirkBadge}</span>
|
||||
<span class="dim tiny">${hostLabel(state, svc.hardwareId)}</span>
|
||||
<span class="dim">${def.category}</span>
|
||||
<span class="value">${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'}</span>
|
||||
${showRates && svc.status === 'running' ? `<span class="dim tiny">risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr</span>` : ''}
|
||||
`
|
||||
}
|
||||
|
||||
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<HTMLElement>(`.item.service[data-svc="${svc.id}"]`)
|
||||
if (!row) continue
|
||||
row.className = 'item service ' + svc.status
|
||||
const fixBtn = row.querySelector<HTMLButtonElement>('.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<HTMLSelectElement>('.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 ? ' <span class="redundant-badge">×2</span>' : ''
|
||||
const quirk = quirkDef(svc.quirkId)
|
||||
const quirkBadge = quirk ? ` <span class="quirk-badge" title="${quirk.flavour}">${quirk.name}</span>` : ''
|
||||
row.innerHTML = `
|
||||
<span class="status-icon">${svc.status === 'running' ? '✓' : '✗'}</span>
|
||||
<span class="name">${def.name}${badge}${quirkBadge}</span>
|
||||
<span class="dim tiny">${hostLabel(state, svc.hardwareId)}</span>
|
||||
<span class="dim">${def.category}</span>
|
||||
<span class="value">${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'}</span>
|
||||
${showRates && svc.status === 'running' ? `<span class="dim tiny">risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr</span>` : ''}
|
||||
`
|
||||
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 = `<span class="log-time">${timeAgo(entry.t)}</span> ${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 = `<span class="log-time">${timeAgo(entry.t)}</span> ${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'
|
||||
|
||||
Reference in New Issue
Block a user