import { HARDWARE } from './data/hardware' import { SERVICES } from './data/services' import { UPGRADES } from './data/upgrades' import type { GameState } from './types' import { buyHardware, buyUpgrade, canFix, canPrestige, capacity, deployService, fixService, hardwareCost, hostCapacity, prestige, quirkDef, ratePerSec, totalRate, uninstallService, } from './engine' import { save } from './state' import { toggleHelp } from './help' // Short codes for the ASCII rack view - kept distinct from data/services.ts // since they're purely a display concern, not part of a service's identity. const SERVICE_ABBR: Record = { nginx: 'NG', homepage: 'HP', pihole: 'PH', wireguard: 'WG', gitea: 'GT', nextcloud: 'NC', vaultwarden: 'VW', jellyfin: 'JF', uptimekuma: 'UK', grafana: 'GF', giteaactions: 'GA', immich: 'IM', synapse: 'MX', wastego: 'W8', } function fmt(n: number): string { if (n < 1000) return n.toFixed(1) const units = ['k', 'M', 'B', 'T', 'Q'] let u = -1 let v = n while (v >= 1000 && u < units.length - 1) { v /= 1000 u++ } return `${v.toFixed(2)}${units[u]}` } function timeAgo(t: number): string { const d = new Date(t) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } let selectedHardwareId: string | null = null // Every host gets a random nickname on purchase (src/data/hostnames.ts), // which doubles as disambiguation now that multiple hosts can share a tier // (e.g. two Old Desktops) - no need to number them. function hostLabel(state: GameState, hardwareId: string): string { const hw = state.hardware.find((h) => h.id === hardwareId) if (!hw) return 'unknown host' const def = HARDWARE.find((h) => h.id === hw.defId)! return `${hw.nickname} (${def.name})` } 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 } const prevLogScroll = root.querySelector('.log-list')?.scrollTop root.innerHTML = '' root.appendChild(buildHeader(state, rate, inOutage)) root.appendChild(buildRackArt(state)) const grid = document.createElement('div') grid.className = 'grid' grid.appendChild(buildLeftColumn(state, cap, onChange)) grid.appendChild(buildRightColumn(state, onChange)) root.appendChild(grid) if (prevLogScroll !== undefined) { const logList = root.querySelector('.log-list') if (logList) logList.scrollTop = prevLogScroll } if (canPrestige(state)) { root.appendChild(buildPrestigeBanner(state, onChange)) } save(state) } function buildHeader(state: GameState, rate: number, inOutage: boolean): 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()) return header } function buildRackArt(state: GameState): HTMLElement { const section = document.createElement('section') section.className = 'panel rack-art-panel' section.innerHTML = `

RACK

` const pre = document.createElement('pre') 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}` pre.appendChild(line) } section.appendChild(pre) return section } function buildLeftColumn(state: GameState, cap: ReturnType, 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}

` const hwList = document.createElement('div') hwList.className = '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.title = `${def.flavour} (click to select as the deploy target)` item.onclick = () => { selectedHardwareId = hw.id onChange() } hwList.appendChild(item) } 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) 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.onclick = () => { if (buyHardware(state, topOwnedHw.id)) onChange() } hwSection.appendChild(repeatBtn) } const nextHw = HARDWARE.find((h) => !state.hardware.some((owned) => owned.defId === h.id)) if (nextHw) { const buyBtn = document.createElement('button') buyBtn.className = 'buy-btn' buyBtn.textContent = `+ buy ${nextHw.name} (${fmt(nextHw.cost)})` buyBtn.disabled = state.points < nextHw.cost buyBtn.title = nextHw.flavour buyBtn.onclick = () => { if (buyHardware(state, nextHw.id)) onChange() } hwSection.appendChild(buyBtn) } col.appendChild(hwSection) const upSection = document.createElement('section') upSection.className = 'panel' upSection.innerHTML = `

UPGRADES

` const upList = document.createElement('div') upList.className = 'list' for (const up of UPGRADES) { const owned = state.upgrades.includes(up.id) const lockedByReq = up.requires ? !state.upgrades.includes(up.requires) : false const row = document.createElement('div') row.className = 'item upgrade' + (owned ? ' owned' : '') + (lockedByReq ? ' locked' : '') row.title = up.flavour row.innerHTML = `${up.name}${ owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost) }` if (!owned && !lockedByReq) { row.onclick = () => { if (buyUpgrade(state, up.id)) onChange() } row.classList.toggle('affordable', state.points >= up.cost) } upList.appendChild(row) } upSection.appendChild(upList) col.appendChild(upSection) return col } function buildRightColumn(state: GameState, onChange: () => void): HTMLElement { const col = document.createElement('div') col.className = 'col right' const svcSection = document.createElement('section') svcSection.className = 'panel' svcSection.innerHTML = `

SERVICES

` 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) } svcSection.appendChild(svcList) const deploySection = document.createElement('div') deploySection.className = 'deploy-row' const select = document.createElement('select') select.className = 'svc-select' const targetCap = selectedHardwareId ? hostCapacity(state, selectedHardwareId) : null for (const def of SERVICES) { const opt = document.createElement('option') opt.value = def.id opt.textContent = `${def.name} — ${fmt(def.deployCost)}pts · ram ${def.ram} · pwr ${def.power}` if (targetCap && (targetCap.ramUsed + def.ram > targetCap.ram || targetCap.powerUsed + def.power > targetCap.power)) { opt.textContent += ' (no room on selected host)' opt.disabled = true } select.appendChild(opt) } const deployBtn = document.createElement('button') deployBtn.className = 'buy-btn' deployBtn.textContent = '+ deploy service' deployBtn.onclick = () => { if (!selectedHardwareId) return if (deployService(state, select.value, selectedHardwareId)) onChange() } deploySection.appendChild(select) deploySection.appendChild(deployBtn) svcSection.appendChild(deploySection) col.appendChild(svcSection) const logSection = document.createElement('section') logSection.className = 'panel log-panel' logSection.innerHTML = `

LOG

` 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) } logSection.appendChild(logList) col.appendChild(logSection) return col } function buildPrestigeBanner(state: GameState, onChange: () => void): HTMLElement { const banner = document.createElement('div') banner.className = 'prestige-banner' banner.innerHTML = `The rack has served its purpose. Decommission it for a permanent multiplier?` const btn = document.createElement('button') btn.className = 'buy-btn prestige-btn' btn.textContent = 'nuke it and start over' btn.onclick = () => { if (!confirm('This wipes your current hardware and services. The multiplier stays. Proceed?')) return const fresh = prestige(state) Object.assign(state, fresh) onChange() } banner.appendChild(btn) return banner }