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 './style.css'
|
||||||
import { load, newGame, save } from './state'
|
import { load, newGame, save } from './state'
|
||||||
import { tick } from './engine'
|
import { tick } from './engine'
|
||||||
import { render } from './ui'
|
import { render, refresh } from './ui'
|
||||||
import { toggleHelp } from './help'
|
import { toggleHelp } from './help'
|
||||||
|
|
||||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||||
const state = load() ?? newGame()
|
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() {
|
function scheduleRender() {
|
||||||
render(app, state, 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()
|
let last = performance.now()
|
||||||
// Passive UI refresh - user actions call scheduleRender() directly for
|
// Passive UI refresh - user actions call scheduleRender() directly for
|
||||||
// instant feedback, so this only needs to be fast enough to reflect the
|
// instant feedback, so this only needs to be fast enough to reflect the
|
||||||
@@ -25,12 +37,7 @@ function loop(now: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderLoop() {
|
function renderLoop() {
|
||||||
// Skip the rebuild while a <select> is focused - recreating it mid-render
|
scheduleRefresh()
|
||||||
// forces any open native dropdown to close immediately.
|
|
||||||
const active = document.activeElement
|
|
||||||
if (!(active instanceof HTMLSelectElement)) {
|
|
||||||
scheduleRender()
|
|
||||||
}
|
|
||||||
setTimeout(renderLoop, RENDER_MS)
|
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})`
|
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) {
|
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)) {
|
if (!selectedHardwareId || !state.hardware.find((h) => h.id === selectedHardwareId)) {
|
||||||
selectedHardwareId = state.hardware[0]?.id ?? null
|
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
|
const prevLogScroll = root.querySelector('.log-list')?.scrollTop
|
||||||
|
|
||||||
root.innerHTML = ''
|
root.innerHTML = ''
|
||||||
root.appendChild(buildHeader(state, rate, inOutage))
|
root.appendChild(buildHeader(state))
|
||||||
root.appendChild(buildRackArt(state))
|
root.appendChild(buildRackArt(state))
|
||||||
|
|
||||||
const grid = document.createElement('div')
|
const grid = document.createElement('div')
|
||||||
grid.className = 'grid'
|
grid.className = 'grid'
|
||||||
grid.appendChild(buildLeftColumn(state, cap, onChange))
|
grid.appendChild(buildLeftColumn(state, onChange))
|
||||||
grid.appendChild(buildRightColumn(state, onChange))
|
grid.appendChild(buildRightColumn(state, onChange))
|
||||||
root.appendChild(grid)
|
root.appendChild(grid)
|
||||||
|
|
||||||
@@ -102,22 +103,82 @@ export function render(root: HTMLElement, state: GameState, onChange: () => void
|
|||||||
save(state)
|
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')
|
const header = document.createElement('header')
|
||||||
header.className = 'topbar'
|
header.className = 'topbar'
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<div class="brand">rack<span class="cursor">_</span></div>
|
<div class="brand">rack<span class="cursor">_</span></div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="arrow">${inOutage ? '✗' : '▲'}</span>
|
<span class="arrow"></span>
|
||||||
<span class="rate">${inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`}</span>
|
<span class="rate"></span>
|
||||||
<span class="points">${fmt(state.points)} pts</span>
|
<span class="points"></span>
|
||||||
<button class="help-btn" title="Help & changelog (?)">?</button>
|
<button class="help-btn" title="Help & changelog (?)">?</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
header.querySelector('.help-btn')!.addEventListener('click', () => toggleHelp())
|
header.querySelector('.help-btn')!.addEventListener('click', () => toggleHelp())
|
||||||
|
updateHeader(header, state)
|
||||||
return header
|
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 {
|
function buildRackArt(state: GameState): HTMLElement {
|
||||||
const section = document.createElement('section')
|
const section = document.createElement('section')
|
||||||
section.className = 'panel rack-art-panel'
|
section.className = 'panel rack-art-panel'
|
||||||
@@ -126,45 +187,41 @@ function buildRackArt(state: GameState): HTMLElement {
|
|||||||
pre.className = 'rack-art'
|
pre.className = 'rack-art'
|
||||||
|
|
||||||
for (const hw of state.hardware) {
|
for (const hw of state.hardware) {
|
||||||
const hostCap = hostCapacity(state, hw.id)
|
|
||||||
const line = document.createElement('div')
|
const line = document.createElement('div')
|
||||||
line.className = 'rack-art-line'
|
line.className = 'rack-art-line'
|
||||||
|
line.dataset.hw = hw.id
|
||||||
const badges = state.services
|
line.innerHTML = rackArtLineHtml(state, hw)
|
||||||
.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>`
|
|
||||||
pre.appendChild(line)
|
pre.appendChild(line)
|
||||||
}
|
}
|
||||||
section.appendChild(pre)
|
section.appendChild(pre)
|
||||||
return section
|
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')
|
const col = document.createElement('div')
|
||||||
col.className = 'col left'
|
col.className = 'col left'
|
||||||
|
|
||||||
const hwSection = document.createElement('section')
|
const hwSection = document.createElement('section')
|
||||||
hwSection.className = 'panel'
|
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')
|
const hwList = document.createElement('div')
|
||||||
hwList.className = 'list'
|
hwList.className = 'list'
|
||||||
|
hwList.dataset.role = 'hw-list'
|
||||||
for (const hw of state.hardware) {
|
for (const hw of state.hardware) {
|
||||||
const def = HARDWARE.find((h) => h.id === hw.defId)!
|
const def = HARDWARE.find((h) => h.id === hw.defId)!
|
||||||
const hostCap = hostCapacity(state, hw.id)
|
|
||||||
const item = document.createElement('div')
|
const item = document.createElement('div')
|
||||||
item.className = 'item hardware' + (hw.id === selectedHardwareId ? ' selected' : '')
|
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.title = `${def.flavour} (click to select as the deploy target)`
|
||||||
item.onclick = () => {
|
item.onclick = () => {
|
||||||
selectedHardwareId = hw.id
|
selectedHardwareId = hw.id
|
||||||
@@ -174,21 +231,19 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
|
|||||||
}
|
}
|
||||||
hwSection.appendChild(hwList)
|
hwSection.appendChild(hwList)
|
||||||
|
|
||||||
// Highest-tier hardware the player already owns - buying another unit of
|
// Every tier the player already owns - buying another unit of any of them
|
||||||
// it is a horizontal bridge (more capacity now, at a rising price) for
|
// is a horizontal bridge (more capacity now, at a rising price) for the
|
||||||
// the often-long grind between one tier's cost and the next tier's.
|
// often-long grind between one tier's cost and the next tier's.
|
||||||
const topOwnedHw = [...state.hardware]
|
const ownedTiers = HARDWARE.filter(
|
||||||
.map((h) => HARDWARE.find((d) => d.id === h.defId)!)
|
(def) => def.cost > 0 && state.hardware.some((h) => h.defId === def.id),
|
||||||
.sort((a, b) => HARDWARE.indexOf(b) - HARDWARE.indexOf(a))[0]
|
)
|
||||||
if (topOwnedHw && topOwnedHw.cost > 0) {
|
for (const tier of ownedTiers) {
|
||||||
const repeatCost = hardwareCost(state, topOwnedHw.id)
|
|
||||||
const repeatBtn = document.createElement('button')
|
const repeatBtn = document.createElement('button')
|
||||||
repeatBtn.className = 'buy-btn'
|
repeatBtn.className = 'buy-btn'
|
||||||
repeatBtn.textContent = `+ buy another ${topOwnedHw.name} (${fmt(repeatCost)})`
|
repeatBtn.dataset.tier = tier.id
|
||||||
repeatBtn.disabled = state.points < repeatCost
|
repeatBtn.title = `Add another ${tier.name} as its own host, at a rising price per extra unit.`
|
||||||
repeatBtn.title = `Add a second ${topOwnedHw.name} as its own host, at a rising price per extra unit.`
|
|
||||||
repeatBtn.onclick = () => {
|
repeatBtn.onclick = () => {
|
||||||
if (buyHardware(state, topOwnedHw.id)) onChange()
|
if (buyHardware(state, tier.id)) onChange()
|
||||||
}
|
}
|
||||||
hwSection.appendChild(repeatBtn)
|
hwSection.appendChild(repeatBtn)
|
||||||
}
|
}
|
||||||
@@ -197,6 +252,7 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
|
|||||||
if (nextHw) {
|
if (nextHw) {
|
||||||
const buyBtn = document.createElement('button')
|
const buyBtn = document.createElement('button')
|
||||||
buyBtn.className = 'buy-btn'
|
buyBtn.className = 'buy-btn'
|
||||||
|
buyBtn.dataset.tier = 'next'
|
||||||
buyBtn.textContent = `+ buy ${nextHw.name} (${fmt(nextHw.cost)})`
|
buyBtn.textContent = `+ buy ${nextHw.name} (${fmt(nextHw.cost)})`
|
||||||
buyBtn.disabled = state.points < nextHw.cost
|
buyBtn.disabled = state.points < nextHw.cost
|
||||||
buyBtn.title = nextHw.flavour
|
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 lockedByReq = up.requires ? !state.upgrades.includes(up.requires) : false
|
||||||
const row = document.createElement('div')
|
const row = document.createElement('div')
|
||||||
row.className = 'item upgrade' + (owned ? ' owned' : '') + (lockedByReq ? ' locked' : '')
|
row.className = 'item upgrade' + (owned ? ' owned' : '') + (lockedByReq ? ' locked' : '')
|
||||||
|
row.dataset.up = up.id
|
||||||
row.title = up.flavour
|
row.title = up.flavour
|
||||||
row.innerHTML = `<span class="name">${up.name}</span><span class="upgrade-status">${
|
row.innerHTML = `<span class="name">${up.name}</span><span class="upgrade-status">${
|
||||||
owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost)
|
owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost)
|
||||||
@@ -232,9 +289,144 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
|
|||||||
upSection.appendChild(upList)
|
upSection.appendChild(upList)
|
||||||
col.appendChild(upSection)
|
col.appendChild(upSection)
|
||||||
|
|
||||||
|
updateHardwareList(col, state)
|
||||||
|
updateBuyButtons(col, state)
|
||||||
|
updateUpgradeList(col, state)
|
||||||
|
|
||||||
return col
|
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 {
|
function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
|
||||||
const col = document.createElement('div')
|
const col = document.createElement('div')
|
||||||
col.className = 'col right'
|
col.className = 'col right'
|
||||||
@@ -245,47 +437,8 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
|
|||||||
const svcList = document.createElement('div')
|
const svcList = document.createElement('div')
|
||||||
svcList.className = 'list'
|
svcList.className = 'list'
|
||||||
|
|
||||||
const showRates = state.upgrades.includes('monitoring')
|
|
||||||
|
|
||||||
for (const svc of state.services) {
|
for (const svc of state.services) {
|
||||||
const def = SERVICES.find((s) => s.id === svc.defId)!
|
svcList.appendChild(buildServiceRow(state, svc, onChange))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
svcSection.appendChild(svcList)
|
svcSection.appendChild(svcList)
|
||||||
|
|
||||||
@@ -323,10 +476,7 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
|
|||||||
const logList = document.createElement('div')
|
const logList = document.createElement('div')
|
||||||
logList.className = 'log-list'
|
logList.className = 'log-list'
|
||||||
for (const entry of state.log) {
|
for (const entry of state.log) {
|
||||||
const line = document.createElement('div')
|
logList.appendChild(buildLogLine(entry))
|
||||||
line.className = 'log-line'
|
|
||||||
line.innerHTML = `<span class="log-time">${timeAgo(entry.t)}</span> ${entry.text}`
|
|
||||||
logList.appendChild(line)
|
|
||||||
}
|
}
|
||||||
logSection.appendChild(logList)
|
logSection.appendChild(logList)
|
||||||
col.appendChild(logSection)
|
col.appendChild(logSection)
|
||||||
@@ -334,6 +484,25 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
|
|||||||
return col
|
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 {
|
function buildPrestigeBanner(state: GameState, onChange: () => void): HTMLElement {
|
||||||
const banner = document.createElement('div')
|
const banner = document.createElement('div')
|
||||||
banner.className = 'prestige-banner'
|
banner.className = 'prestige-banner'
|
||||||
|
|||||||
Reference in New Issue
Block a user