Files
rack/src/ui.ts

353 lines
13 KiB
TypeScript
Raw Normal View History

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
}