Files
rack/src/ui.ts
Fredrik Johansson f0314e7492 Initial commit: rack, a homelab idle game
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>
2026-07-02 21:55:54 +02:00

353 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}