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
|
|||
|
|
}
|