Core loop (hardware tiers, deployable services, upgrades, random events, prestige), quirks, host nicknames, an ASCII rack view, in-app help/ changelog, and a Vitest suite covering engine logic and data balance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
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<string, string> = {
|
||
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 = `
|
||
<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>
|
||
<button class="help-btn" title="Help & changelog (?)">?</button>
|
||
</div>
|
||
`
|
||
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 = `<h2>RACK</h2>`
|
||
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 `<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)
|
||
}
|
||
section.appendChild(pre)
|
||
return section
|
||
}
|
||
|
||
function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, 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>`
|
||
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 = `<span class="name">${hostLabel(state, hw.id)}</span><span class="dim tiny">ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}</span>`
|
||
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 = `<h2>UPGRADES</h2>`
|
||
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 = `<span class="name">${up.name}</span><span class="upgrade-status">${
|
||
owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost)
|
||
}</span>`
|
||
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 = `<h2>SERVICES</h2>`
|
||
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)
|
||
}
|
||
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 = `<h2>LOG</h2>`
|
||
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)
|
||
}
|
||
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 = `<span>The rack has served its purpose. Decommission it for a permanent multiplier?</span>`
|
||
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
|
||
}
|