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>
This commit is contained in:
17
src/data/events.ts
Normal file
17
src/data/events.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const FAN_NOISE_LINES = [
|
||||
'The neighbours have left another note. It is not friendly.',
|
||||
'A faint hum has become a personality trait of this room.',
|
||||
'Someone asks if you are "mining crypto or something."',
|
||||
'The cat has stopped sleeping near the rack. Wise cat.',
|
||||
]
|
||||
|
||||
export const UPDATE_AVAILABLE_LINES = [
|
||||
'A changelog nobody will read is now available.',
|
||||
'Security patch available. Also, six unrelated dependency bumps.',
|
||||
'Update available. Release notes say "misc fixes." Ominous.',
|
||||
]
|
||||
|
||||
export const RM_RF_LINES = [
|
||||
'Tab completion did not save you this time.',
|
||||
'You meant to delete the logs directory.',
|
||||
]
|
||||
44
src/data/hardware.ts
Normal file
44
src/data/hardware.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { HardwareDef } from '../types'
|
||||
|
||||
export const HARDWARE: HardwareDef[] = [
|
||||
{
|
||||
id: 'pi4',
|
||||
name: 'Raspberry Pi 4',
|
||||
flavour: "It's running. Somehow.",
|
||||
cost: 0,
|
||||
ramSlots: 2,
|
||||
powerBudget: 3,
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
name: 'Old Desktop',
|
||||
flavour: 'Found it on Facebook Marketplace for €20.',
|
||||
cost: 150,
|
||||
ramSlots: 5,
|
||||
powerBudget: 8,
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
name: 'Refurbished Server',
|
||||
flavour: '1U. Loud. The neighbours have complained.',
|
||||
cost: 2200,
|
||||
ramSlots: 12,
|
||||
powerBudget: 20,
|
||||
},
|
||||
{
|
||||
id: 'rack',
|
||||
name: 'Full Rack',
|
||||
flavour: "You've stopped justifying this to anyone.",
|
||||
cost: 30000,
|
||||
ramSlots: 30,
|
||||
powerBudget: 60,
|
||||
},
|
||||
{
|
||||
id: 'colo',
|
||||
name: 'Colocation Slot',
|
||||
flavour: 'You pay someone else to hear the fans.',
|
||||
cost: 400000,
|
||||
ramSlots: 80,
|
||||
powerBudget: 200,
|
||||
},
|
||||
]
|
||||
71
src/data/hostnames.ts
Normal file
71
src/data/hostnames.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Assigned once per hardware unit on purchase, purely cosmetic. Sourced
|
||||
// from the usual homelab naming traditions: sci-fi AIs, mythology, and the
|
||||
// self-deprecating sysadmin-humour school of hostnames.
|
||||
export const HOSTNAMES = [
|
||||
'hal9000',
|
||||
'glados',
|
||||
'skynet-jr',
|
||||
'wintermute',
|
||||
'shodan',
|
||||
'multivac',
|
||||
'deep-thought',
|
||||
'marvin',
|
||||
'tars',
|
||||
'case',
|
||||
'cortana',
|
||||
'jarvis',
|
||||
'friday',
|
||||
'ultron-lite',
|
||||
'colossus',
|
||||
'agent-smith',
|
||||
'mother',
|
||||
'ava',
|
||||
'samantha',
|
||||
'clu',
|
||||
'morpheus',
|
||||
'neuromancer',
|
||||
'prometheus',
|
||||
'icarus',
|
||||
'daedalus',
|
||||
'hephaestus',
|
||||
'loki',
|
||||
'cerberus',
|
||||
'chronos',
|
||||
'atlas',
|
||||
'gestalt',
|
||||
'yggdrasil',
|
||||
'basilisk',
|
||||
'leviathan',
|
||||
'kraken',
|
||||
'the-basement',
|
||||
'shed-server',
|
||||
'closet-pi',
|
||||
'do-not-touch',
|
||||
'prod-shh',
|
||||
'definitely-backed-up',
|
||||
'works-on-my-machine',
|
||||
'temp-fix-2019',
|
||||
'the-crime-scene',
|
||||
'box-of-regret',
|
||||
'toaster',
|
||||
'lawnmower',
|
||||
'spare-brick',
|
||||
'thicc-boi',
|
||||
'humming-bird',
|
||||
'lil-guy',
|
||||
'big-guy',
|
||||
'unit-731',
|
||||
'the-microwave',
|
||||
]
|
||||
|
||||
export function pickHostname(taken: Set<string>): string {
|
||||
const available = HOSTNAMES.filter((n) => !taken.has(n))
|
||||
if (available.length === 0) {
|
||||
// Pool exhausted (unlikely - there are 5 hardware tiers) - fall back to
|
||||
// a numbered variant rather than crashing or repeating a name.
|
||||
let n = 1
|
||||
while (taken.has(`unit-${n}`)) n++
|
||||
return `unit-${n}`
|
||||
}
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
51
src/data/quirks.ts
Normal file
51
src/data/quirks.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { QuirkDef } from '../types'
|
||||
|
||||
// Rolled once per deploy with QUIRK_CHANCE odds (see engine.ts). Purely a
|
||||
// per-instance modifier on top of the service's own base numbers - the
|
||||
// service definition itself never changes.
|
||||
export const QUIRKS: QuirkDef[] = [
|
||||
{
|
||||
id: 'overclocked',
|
||||
name: 'Overclocked',
|
||||
flavour: 'Running hotter than recommended. Line goes up faster.',
|
||||
rateMult: 1.35,
|
||||
crashMult: 1.6,
|
||||
},
|
||||
{
|
||||
id: 'chatty',
|
||||
name: 'Chatty',
|
||||
flavour: 'Logs everything, loudly, to a disk that will eventually fill.',
|
||||
rateMult: 1.15,
|
||||
crashMult: 1.3,
|
||||
},
|
||||
{
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
flavour: "Older than the intern. Nobody wants to touch it, so nobody does.",
|
||||
rateMult: 0.8,
|
||||
crashMult: 0.6,
|
||||
},
|
||||
{
|
||||
id: 'well-documented',
|
||||
name: 'Well-documented',
|
||||
flavour: 'Someone actually wrote a README. Unclear who.',
|
||||
rateMult: 1.0,
|
||||
crashMult: 0.55,
|
||||
},
|
||||
{
|
||||
id: 'flaky',
|
||||
name: 'Flaky',
|
||||
flavour: 'Works on my machine. This is also your machine.',
|
||||
rateMult: 1.2,
|
||||
crashMult: 1.9,
|
||||
},
|
||||
{
|
||||
id: 'artisanal',
|
||||
name: 'Artisanal',
|
||||
flavour: 'Hand-compiled from source. You are very proud of this.',
|
||||
rateMult: 1.1,
|
||||
crashMult: 1.0,
|
||||
},
|
||||
]
|
||||
|
||||
export const QUIRK_CHANCE = 0.3
|
||||
172
src/data/services.ts
Normal file
172
src/data/services.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ServiceDef } from '../types'
|
||||
|
||||
export const SERVICES: ServiceDef[] = [
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'Nginx',
|
||||
category: 'Networking',
|
||||
deployCost: 10,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 0.6,
|
||||
baseCrashPerSec: 0.0006,
|
||||
flavourDeploy: 'Reverse proxy online. Everything now 502s uniformly.',
|
||||
flavourCrash: '502. Classic.',
|
||||
},
|
||||
{
|
||||
id: 'homepage',
|
||||
name: 'Homepage',
|
||||
category: 'Dashboard',
|
||||
deployCost: 15,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 0.5,
|
||||
baseCrashPerSec: 0.0005,
|
||||
flavourDeploy: 'A dashboard of links to services that are, at this moment, all still running.',
|
||||
flavourCrash: 'The dashboard for everything else is itself now a 404.',
|
||||
},
|
||||
{
|
||||
id: 'pihole',
|
||||
name: 'Pi-hole',
|
||||
category: 'Networking',
|
||||
deployCost: 25,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 1.1,
|
||||
baseCrashPerSec: 0.0009,
|
||||
flavourDeploy: 'Ads begin dying. Family group chat unaffected.',
|
||||
flavourCrash: "DNS is down. Nothing works. You did this.",
|
||||
},
|
||||
{
|
||||
id: 'wireguard',
|
||||
name: 'Wireguard',
|
||||
category: 'Networking',
|
||||
deployCost: 40,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 1.4,
|
||||
baseCrashPerSec: 0.0007,
|
||||
flavourDeploy: 'Tunnel established. You can now SSH in from the toilet.',
|
||||
flavourCrash: 'Handshake failed. Check your firewall.',
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
name: 'Gitea',
|
||||
category: 'Self-hosting',
|
||||
deployCost: 80,
|
||||
ram: 2,
|
||||
power: 2,
|
||||
baseRate: 2.4,
|
||||
baseCrashPerSec: 0.0012,
|
||||
flavourDeploy: 'Repos migrated off GitHub. Mostly out of spite.',
|
||||
flavourCrash: 'Push rejected. Server is on the floor.',
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
name: 'Nextcloud',
|
||||
category: 'Self-hosting',
|
||||
deployCost: 160,
|
||||
ram: 3,
|
||||
power: 2,
|
||||
baseRate: 3.8,
|
||||
baseCrashPerSec: 0.0015,
|
||||
flavourDeploy: 'Sync client installed on four devices. None agree on file count.',
|
||||
flavourCrash: 'Syncing. Always syncing. Never done.',
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
name: 'Vaultwarden',
|
||||
category: 'Security',
|
||||
deployCost: 260,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 4.5,
|
||||
baseCrashPerSec: 0.0008,
|
||||
flavourDeploy: 'Passwords centralized. One point of failure, tastefully hidden.',
|
||||
flavourCrash: 'Your passwords are safe. Probably.',
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
name: 'Jellyfin',
|
||||
category: 'Media',
|
||||
deployCost: 420,
|
||||
ram: 4,
|
||||
power: 4,
|
||||
baseRate: 6.5,
|
||||
baseCrashPerSec: 0.0018,
|
||||
flavourDeploy: 'Media server live. Library: 3 films you already own on disc.',
|
||||
flavourCrash: "Transcoding at 0.3x. It's trying.",
|
||||
},
|
||||
{
|
||||
id: 'uptimekuma',
|
||||
name: 'Uptime Kuma',
|
||||
category: 'Monitoring',
|
||||
deployCost: 600,
|
||||
ram: 1,
|
||||
power: 1,
|
||||
baseRate: 7.5,
|
||||
baseCrashPerSec: 0.0006,
|
||||
flavourDeploy: 'Now monitoring everything, including itself.',
|
||||
flavourCrash: 'Ironically, it is down.',
|
||||
},
|
||||
{
|
||||
id: 'grafana',
|
||||
name: 'Grafana',
|
||||
category: 'Monitoring',
|
||||
deployCost: 750,
|
||||
ram: 2,
|
||||
power: 2,
|
||||
baseRate: 11,
|
||||
baseCrashPerSec: 0.0013,
|
||||
flavourDeploy: 'Dashboards deployed. Seventeen panels, four of them meaningful.',
|
||||
flavourCrash: 'Query timeout. The graph about downtime is, itself, down.',
|
||||
},
|
||||
{
|
||||
id: 'giteaactions',
|
||||
name: 'Gitea Actions',
|
||||
category: 'CI',
|
||||
deployCost: 900,
|
||||
ram: 3,
|
||||
power: 3,
|
||||
baseRate: 10,
|
||||
baseCrashPerSec: 0.002,
|
||||
flavourDeploy: 'CI runner registered. It will judge your commits now.',
|
||||
flavourCrash: 'The runner has been running for 4 hours.',
|
||||
},
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
category: 'Media',
|
||||
deployCost: 1400,
|
||||
ram: 5,
|
||||
power: 4,
|
||||
baseRate: 14,
|
||||
baseCrashPerSec: 0.0022,
|
||||
flavourDeploy: 'Photo library import started. Regret not far behind.',
|
||||
flavourCrash: 'Indexing 40,000 photos. ETA: Thursday.',
|
||||
},
|
||||
{
|
||||
id: 'synapse',
|
||||
name: 'Matrix Synapse',
|
||||
category: 'Chat',
|
||||
deployCost: 1800,
|
||||
ram: 4,
|
||||
power: 3,
|
||||
baseRate: 24,
|
||||
baseCrashPerSec: 0.0021,
|
||||
flavourDeploy: 'Federated chat online. You are its entire federation.',
|
||||
flavourCrash: 'Room state resolution failed. Everyone is shouting into a void, again.',
|
||||
},
|
||||
{
|
||||
id: 'wastego',
|
||||
name: 'waste-go',
|
||||
category: 'Fun',
|
||||
deployCost: 2200,
|
||||
ram: 2,
|
||||
power: 2,
|
||||
baseRate: 20,
|
||||
baseCrashPerSec: 0.0025,
|
||||
flavourDeploy: 'Deployed for morale. Production value: nonzero.',
|
||||
flavourCrash: 'The anchor is up. No one is online.',
|
||||
},
|
||||
]
|
||||
60
src/data/upgrades.ts
Normal file
60
src/data/upgrades.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { UpgradeDef } from '../types'
|
||||
|
||||
export const UPGRADES: UpgradeDef[] = [
|
||||
{
|
||||
id: 'ups',
|
||||
name: 'UPS',
|
||||
cost: 300,
|
||||
flavour: 'A battery between you and the dark. Outages now shorter.',
|
||||
},
|
||||
{
|
||||
id: 'backups1',
|
||||
name: 'Backups I',
|
||||
cost: 500,
|
||||
flavour: 'Nightly cron job. You have never tested a restore.',
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
name: 'Monitoring',
|
||||
cost: 700,
|
||||
flavour: 'Crash odds now visible per service. Ignorance was cheaper.',
|
||||
},
|
||||
{
|
||||
id: 'cooling',
|
||||
name: 'Better Cooling',
|
||||
cost: 1200,
|
||||
flavour: 'Fans upgraded. Neighbours file a new complaint, on principle.',
|
||||
},
|
||||
{
|
||||
id: 'backups2',
|
||||
name: 'Backups II',
|
||||
cost: 2500,
|
||||
flavour: 'Offsite replication. Slightly less doomed.',
|
||||
requires: 'backups1',
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
name: 'Documentation',
|
||||
cost: 3500,
|
||||
flavour: 'You wrote it down. Future you is grateful.',
|
||||
},
|
||||
{
|
||||
id: 'redundancy',
|
||||
name: 'Redundancy',
|
||||
cost: 6000,
|
||||
flavour: 'Every service now has a twin. Twice the surface area, half the panic.',
|
||||
},
|
||||
{
|
||||
id: 'ansible',
|
||||
name: 'Ansible',
|
||||
cost: 9000,
|
||||
flavour: 'One command to deploy everything. You still read the diff first. Sometimes.',
|
||||
},
|
||||
{
|
||||
id: 'backups3',
|
||||
name: 'Backups III',
|
||||
cost: 15000,
|
||||
flavour: 'Auto-recovery enabled. Crashes now fix themselves, eventually.',
|
||||
requires: 'backups2',
|
||||
},
|
||||
]
|
||||
71
src/data/version.ts
Normal file
71
src/data/version.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface ChangelogEntry {
|
||||
version: string
|
||||
date: string
|
||||
changes: string[]
|
||||
}
|
||||
|
||||
export const VERSION = '0.4.0'
|
||||
|
||||
// Mirrors CHANGELOG.md at the repo root - that file has the full
|
||||
// reasoning/decisions behind each entry, this is the condensed in-app view.
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.4.0',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'Every host now gets a random nerdy/quirky nickname on purchase (hal9000, glados, the-basement, works-on-my-machine, ...) instead of a generic "Old Desktop #2".',
|
||||
'Nicknames replace the old numbering scheme everywhere a host is named - the rack view, hardware panel, service rows, and deploy log.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.3.0',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'The service dropdown now shows RAM/power cost per service, and greys out anything that would not fit on the currently selected host.',
|
||||
'You can now buy a second (third, fourth...) unit of hardware you already own, not just upgrade to the next tier - each extra unit of a tier costs 40% more than the last. Meant as a bridge across the long early-game gap between tiers.',
|
||||
'Hardware/service labels now number duplicate tiers ("Old Desktop #2") since more than one host of the same spec can now exist.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.2.0',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'Added service quirks - a random per-instance trait rolled on deploy (Overclocked, Chatty, Legacy, Well-documented, Flaky, Artisanal) that shifts production and crash risk.',
|
||||
'Added 3 services: Homepage (Dashboard), Grafana (Monitoring), Matrix Synapse (Chat).',
|
||||
'Added the ASCII rack view and this help/changelog overlay.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.1.4',
|
||||
date: '2026-07-02',
|
||||
changes: ['Added a Vitest suite covering the core loop and data-balance invariants.'],
|
||||
},
|
||||
{
|
||||
version: '0.1.3',
|
||||
date: '2026-07-02',
|
||||
changes: ['Added service uninstall (`rm` button) - frees host capacity, refunds 25% of deploy cost.'],
|
||||
},
|
||||
{
|
||||
version: '0.1.2',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'Fixed: RAM/power capacity was one shared pool across all hardware, ignoring which host you selected. Now enforced per-host.',
|
||||
'Service rows and hardware rows now show host name and per-host usage.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.1.1',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'Fixed a soft-lock: the crash-fix cooldown was global across all services. Two crashes at once could strand you with zero income. Cooldown is now per-service.',
|
||||
'Fixed the service dropdown closing itself - the whole UI was rebuilding every 250ms.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.1.0',
|
||||
date: '2026-07-02',
|
||||
changes: [
|
||||
'Initial build: hardware tiers, 11 services, 9 upgrades, random events, and prestige.',
|
||||
],
|
||||
},
|
||||
]
|
||||
493
src/engine.test.ts
Normal file
493
src/engine.test.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { HARDWARE } from './data/hardware'
|
||||
import { SERVICES } from './data/services'
|
||||
import { UPGRADES } from './data/upgrades'
|
||||
import { QUIRKS, QUIRK_CHANCE } from './data/quirks'
|
||||
import { HOSTNAMES, pickHostname } from './data/hostnames'
|
||||
import { newGame } from './state'
|
||||
import {
|
||||
buyHardware,
|
||||
buyUpgrade,
|
||||
canFix,
|
||||
canPrestige,
|
||||
capacity,
|
||||
deployService,
|
||||
fixService,
|
||||
hardwareCost,
|
||||
hostCapacity,
|
||||
prestige,
|
||||
quirkDef,
|
||||
ratePerSec,
|
||||
tick,
|
||||
totalRate,
|
||||
uninstallService,
|
||||
} from './engine'
|
||||
|
||||
describe('bootstrap', () => {
|
||||
it('starts with enough points to deploy the cheapest service immediately', () => {
|
||||
const state = newGame()
|
||||
const cheapest = Math.min(...SERVICES.map((s) => s.deployCost))
|
||||
expect(state.points).toBeGreaterThanOrEqual(cheapest)
|
||||
})
|
||||
|
||||
it('starts with one Pi 4 and no services', () => {
|
||||
const state = newGame()
|
||||
expect(state.hardware).toHaveLength(1)
|
||||
expect(state.hardware[0].defId).toBe('pi4')
|
||||
expect(state.services).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deployService', () => {
|
||||
it('deploys onto the selected host and deducts points', () => {
|
||||
const state = newGame()
|
||||
const before = state.points
|
||||
const ok = deployService(state, 'nginx', state.hardware[0].id)
|
||||
expect(ok).toBe(true)
|
||||
expect(state.services).toHaveLength(1)
|
||||
expect(state.services[0].hardwareId).toBe(state.hardware[0].id)
|
||||
expect(state.points).toBe(before - SERVICES.find((s) => s.id === 'nginx')!.deployCost)
|
||||
})
|
||||
|
||||
it('refuses to deploy without enough points', () => {
|
||||
const state = newGame()
|
||||
state.points = 0
|
||||
expect(deployService(state, 'nginx', state.hardware[0].id)).toBe(false)
|
||||
expect(state.services).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enforces capacity against the specific host, not a shared pool', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
state.hardware.push({ id: 'hw-2', defId: 'desktop', nickname: 'test-desktop' })
|
||||
|
||||
// Pi 4 has 2 RAM slots - fill it with two 1-RAM services.
|
||||
expect(deployService(state, 'nginx', state.hardware[0].id)).toBe(true)
|
||||
expect(deployService(state, 'pihole', state.hardware[0].id)).toBe(true)
|
||||
// Third 1-RAM service should not fit on the now-full Pi...
|
||||
expect(deployService(state, 'wireguard', state.hardware[0].id)).toBe(false)
|
||||
// ...but should fit fine on the Desktop, which has its own budget.
|
||||
expect(deployService(state, 'wireguard', 'hw-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects deploying to a host without enough power budget', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
// Pi 4 has a 3-power budget; jellyfin alone costs 4 power.
|
||||
expect(deployService(state, 'jellyfin', state.hardware[0].id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostCapacity vs capacity', () => {
|
||||
it('capacity() aggregates every host; hostCapacity() isolates one', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
state.hardware.push({ id: 'hw-2', defId: 'desktop', nickname: 'test-desktop' })
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
deployService(state, 'wireguard', 'hw-2')
|
||||
|
||||
const agg = capacity(state)
|
||||
expect(agg.ramUsed).toBe(2)
|
||||
|
||||
const piCap = hostCapacity(state, state.hardware[0].id)
|
||||
expect(piCap.ramUsed).toBe(1)
|
||||
const desktopCap = hostCapacity(state, 'hw-2')
|
||||
expect(desktopCap.ramUsed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buyHardware', () => {
|
||||
it('deducts cost and adds the hardware', () => {
|
||||
const state = newGame()
|
||||
state.points = 1000
|
||||
expect(buyHardware(state, 'desktop')).toBe(true)
|
||||
expect(state.hardware).toHaveLength(2)
|
||||
expect(state.points).toBe(1000 - HARDWARE.find((h) => h.id === 'desktop')!.cost)
|
||||
})
|
||||
|
||||
it('refuses when the player cannot afford it', () => {
|
||||
const state = newGame()
|
||||
state.points = 0
|
||||
expect(buyHardware(state, 'desktop')).toBe(false)
|
||||
expect(state.hardware).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('allows buying a second unit of an already-owned tier, as a separate host', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
buyHardware(state, 'desktop')
|
||||
expect(buyHardware(state, 'desktop')).toBe(true)
|
||||
expect(state.hardware.filter((h) => h.defId === 'desktop')).toHaveLength(2)
|
||||
expect(state.hardware[1].id).not.toBe(state.hardware[2].id)
|
||||
})
|
||||
|
||||
it('each extra unit of the same tier costs more than the last', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
const first = hardwareCost(state, 'desktop')
|
||||
buyHardware(state, 'desktop')
|
||||
const second = hardwareCost(state, 'desktop')
|
||||
buyHardware(state, 'desktop')
|
||||
const third = hardwareCost(state, 'desktop')
|
||||
|
||||
expect(second).toBeGreaterThan(first)
|
||||
expect(third).toBeGreaterThan(second)
|
||||
})
|
||||
|
||||
it('refuses to rebuy the free starter Pi', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
expect(hardwareCost(state, 'pi4')).toBe(0)
|
||||
expect(buyHardware(state, 'pi4')).toBe(false)
|
||||
expect(state.hardware).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buyUpgrade', () => {
|
||||
it('respects prerequisite chains', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
expect(buyUpgrade(state, 'backups2')).toBe(false) // requires backups1
|
||||
expect(buyUpgrade(state, 'backups1')).toBe(true)
|
||||
expect(buyUpgrade(state, 'backups2')).toBe(true)
|
||||
})
|
||||
|
||||
it('will not sell the same upgrade twice', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
expect(buyUpgrade(state, 'ups')).toBe(true)
|
||||
expect(buyUpgrade(state, 'ups')).toBe(false)
|
||||
})
|
||||
|
||||
it('redundancy upgrade doubles every currently deployed service', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
expect(state.services[0].redundant).toBe(false)
|
||||
buyUpgrade(state, 'redundancy')
|
||||
expect(state.services[0].redundant).toBe(true)
|
||||
})
|
||||
|
||||
it('every upgrade id used as a `requires` target actually exists', () => {
|
||||
const ids = new Set(UPGRADES.map((u) => u.id))
|
||||
for (const u of UPGRADES) {
|
||||
if (u.requires) expect(ids.has(u.requires)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('fixService / crash cooldown', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('fixes a crashed service and restarts its age', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
const svc = state.services[0]
|
||||
svc.status = 'crashed'
|
||||
svc.age = 500
|
||||
|
||||
fixService(state, svc.id)
|
||||
expect(svc.status).toBe('running')
|
||||
expect(svc.age).toBe(0)
|
||||
})
|
||||
|
||||
it('cannot be fixed again until its own cooldown expires', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
const svc = state.services[0]
|
||||
svc.status = 'crashed'
|
||||
fixService(state, svc.id)
|
||||
expect(canFix(svc)).toBe(false)
|
||||
|
||||
svc.status = 'crashed'
|
||||
fixService(state, svc.id) // blocked by cooldown
|
||||
expect(svc.status).toBe('crashed')
|
||||
|
||||
vi.advanceTimersByTime(20_001)
|
||||
expect(canFix(svc)).toBe(true)
|
||||
fixService(state, svc.id)
|
||||
expect(svc.status).toBe('running')
|
||||
})
|
||||
|
||||
it('a cooldown on one service does not block fixing a different crashed service', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
deployService(state, 'pihole', state.hardware[0].id)
|
||||
const [a, b] = state.services
|
||||
a.status = 'crashed'
|
||||
b.status = 'crashed'
|
||||
|
||||
fixService(state, a.id)
|
||||
expect(a.status).toBe('running')
|
||||
// b must still be fixable immediately - this was the soft-lock bug.
|
||||
expect(canFix(b)).toBe(true)
|
||||
fixService(state, b.id)
|
||||
expect(b.status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uninstallService', () => {
|
||||
it('removes the service, frees capacity, and refunds partial value', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
const svc = state.services[0]
|
||||
const beforePoints = state.points
|
||||
const beforeCap = hostCapacity(state, state.hardware[0].id)
|
||||
|
||||
uninstallService(state, svc.id)
|
||||
|
||||
expect(state.services).toHaveLength(0)
|
||||
expect(state.points).toBeGreaterThan(beforePoints)
|
||||
const afterCap = hostCapacity(state, state.hardware[0].id)
|
||||
expect(afterCap.ramUsed).toBeLessThan(beforeCap.ramUsed)
|
||||
})
|
||||
|
||||
it('refunds double for a redundant instance', () => {
|
||||
const single = newGame()
|
||||
single.points = 100000
|
||||
deployService(single, 'nginx', single.hardware[0].id)
|
||||
const singleBefore = single.points
|
||||
uninstallService(single, single.services[0].id)
|
||||
const singleRefund = single.points - singleBefore
|
||||
|
||||
const redundant = newGame()
|
||||
redundant.points = 100000
|
||||
deployService(redundant, 'nginx', redundant.hardware[0].id)
|
||||
redundant.services[0].redundant = true
|
||||
const redundantBefore = redundant.points
|
||||
uninstallService(redundant, redundant.services[0].id)
|
||||
const redundantRefund = redundant.points - redundantBefore
|
||||
|
||||
expect(redundantRefund).toBe(singleRefund * 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production / tick', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('accrues points over time proportional to the running rate', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
const rate = totalRate(state)
|
||||
expect(rate).toBeGreaterThan(0)
|
||||
|
||||
const before = state.points
|
||||
tick(state, 10)
|
||||
expect(state.points).toBeCloseTo(before + rate * 10, 5)
|
||||
expect(state.totalEarned).toBeCloseTo(rate * 10, 5)
|
||||
})
|
||||
|
||||
it('produces nothing during an outage', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
state.outageUntil = Date.now() + 10_000
|
||||
const before = state.points
|
||||
tick(state, 5)
|
||||
expect(state.points).toBe(before)
|
||||
})
|
||||
|
||||
it('a crashed service produces zero even if others run', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
deployService(state, 'pihole', state.hardware[0].id)
|
||||
state.services[0].status = 'crashed'
|
||||
expect(ratePerSec(state, state.services[0])).toBe(0)
|
||||
expect(totalRate(state)).toBe(ratePerSec(state, state.services[1]))
|
||||
})
|
||||
|
||||
it('service age increases only while running', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
state.services[0].status = 'crashed'
|
||||
tick(state, 100)
|
||||
expect(state.services[0].age).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prestige', () => {
|
||||
it('is not available before the earnings threshold', () => {
|
||||
const state = newGame()
|
||||
state.totalEarned = 49999
|
||||
expect(canPrestige(state)).toBe(false)
|
||||
state.totalEarned = 50000
|
||||
expect(canPrestige(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('resets progress but carries forward a strictly higher multiplier', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
state.totalEarned = 50000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
buyUpgrade(state, 'ups')
|
||||
const prevMultiplier = state.prestigeMultiplier
|
||||
|
||||
const fresh = prestige(state)
|
||||
|
||||
expect(fresh.prestigeMultiplier).toBeGreaterThan(prevMultiplier)
|
||||
expect(fresh.services).toHaveLength(0)
|
||||
expect(fresh.upgrades).toHaveLength(0)
|
||||
expect(fresh.hardware).toHaveLength(1)
|
||||
expect(fresh.prestigeCount).toBe(state.prestigeCount + 1)
|
||||
})
|
||||
|
||||
it('the carried multiplier actually boosts future production', () => {
|
||||
// deployService can roll a random quirk (see data/quirks.ts) that also
|
||||
// scales the rate, so pin Math.random to 1 (just under the ceiling) to
|
||||
// guarantee no quirk is assigned to either deploy - otherwise this
|
||||
// comparison is flaky whenever the two independent rolls disagree.
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.999)
|
||||
|
||||
const base = newGame()
|
||||
base.points = 100000
|
||||
deployService(base, 'nginx', base.hardware[0].id)
|
||||
const baseRate = ratePerSec(base, base.services[0])
|
||||
|
||||
const boosted = newGame()
|
||||
boosted.prestigeMultiplier = 2
|
||||
boosted.points = 100000
|
||||
deployService(boosted, 'nginx', boosted.hardware[0].id)
|
||||
const boostedRate = ratePerSec(boosted, boosted.services[0])
|
||||
|
||||
randomSpy.mockRestore()
|
||||
|
||||
expect(boostedRate).toBeCloseTo(baseRate * 2, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('balance sanity', () => {
|
||||
it('hardware costs strictly increase with tier, matching the RAM/power increase', () => {
|
||||
for (let i = 1; i < HARDWARE.length; i++) {
|
||||
expect(HARDWARE[i].cost).toBeGreaterThan(HARDWARE[i - 1].cost)
|
||||
expect(HARDWARE[i].ramSlots).toBeGreaterThan(HARDWARE[i - 1].ramSlots)
|
||||
expect(HARDWARE[i].powerBudget).toBeGreaterThan(HARDWARE[i - 1].powerBudget)
|
||||
}
|
||||
})
|
||||
|
||||
it('every service pays back its deploy cost in well under an hour at base rate', () => {
|
||||
for (const svc of SERVICES) {
|
||||
const paybackSeconds = svc.deployCost / svc.baseRate
|
||||
expect(paybackSeconds).toBeLessThan(3600)
|
||||
}
|
||||
})
|
||||
|
||||
it('pricier services are not worse value than cheaper ones (payback time roughly non-decreasing)', () => {
|
||||
const sorted = [...SERVICES].sort((a, b) => a.deployCost - b.deployCost)
|
||||
for (const svc of sorted) {
|
||||
const paybackSeconds = svc.deployCost / svc.baseRate
|
||||
// Generous ceiling per PLAN.md's "first 10 minutes should feel good" -
|
||||
// catches a service that's badly mispriced relative to its own cost.
|
||||
expect(paybackSeconds).toBeLessThan(300)
|
||||
}
|
||||
})
|
||||
|
||||
it('crash risk stays low enough that a fresh service is unlikely to crash in its first minute', () => {
|
||||
// Highest configured baseCrashPerSec is waste-go's 0.0025/s = 15%/min -
|
||||
// this guards against a future service definition creeping past that
|
||||
// ceiling by accident, not an assertion that risk must be near-zero.
|
||||
for (const svc of SERVICES) {
|
||||
const riskFirstMinute = svc.baseCrashPerSec * 60
|
||||
expect(riskFirstMinute).toBeLessThan(0.16)
|
||||
}
|
||||
})
|
||||
|
||||
it('the starting balance can afford the cheapest service but not a second hardware tier', () => {
|
||||
const state = newGame()
|
||||
const cheapestService = Math.min(...SERVICES.map((s) => s.deployCost))
|
||||
const cheapestHardware = Math.min(...HARDWARE.filter((h) => h.id !== 'pi4').map((h) => h.cost))
|
||||
expect(state.points).toBeGreaterThanOrEqual(cheapestService)
|
||||
expect(state.points).toBeLessThan(cheapestHardware)
|
||||
})
|
||||
|
||||
it('the Pi 4 alone can host at least two of the cheapest services', () => {
|
||||
const pi4 = HARDWARE.find((h) => h.id === 'pi4')!
|
||||
const cheapest = [...SERVICES].sort((a, b) => a.deployCost - b.deployCost)[0]
|
||||
expect(Math.floor(pi4.ramSlots / cheapest.ram)).toBeGreaterThanOrEqual(2)
|
||||
expect(Math.floor(pi4.powerBudget / cheapest.power)).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quirks', () => {
|
||||
it('never assigns a quirk when the roll lands above QUIRK_CHANCE', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
vi.spyOn(Math, 'random').mockReturnValue(QUIRK_CHANCE + 0.001)
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
expect(state.services[0].quirkId).toBeNull()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('always assigns a quirk when the roll lands under QUIRK_CHANCE', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
expect(state.services[0].quirkId).not.toBeNull()
|
||||
expect(QUIRKS.some((q) => q.id === state.services[0].quirkId)).toBe(true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('a quirk\'s rateMult actually scales ratePerSec', () => {
|
||||
const state = newGame()
|
||||
state.points = 100000
|
||||
deployService(state, 'nginx', state.hardware[0].id)
|
||||
const svc = state.services[0]
|
||||
svc.quirkId = null
|
||||
const unquirked = ratePerSec(state, svc)
|
||||
|
||||
const overclocked = QUIRKS.find((q) => q.id === 'overclocked')!
|
||||
svc.quirkId = 'overclocked'
|
||||
const quirked = ratePerSec(state, svc)
|
||||
|
||||
expect(quirked).toBeCloseTo(unquirked * overclocked.rateMult, 5)
|
||||
})
|
||||
|
||||
it('quirkDef() is null for both no quirk and an unknown id', () => {
|
||||
expect(quirkDef(null)).toBeNull()
|
||||
expect(quirkDef('not-a-real-quirk')).toBeNull()
|
||||
})
|
||||
|
||||
it('every quirk is a real modifier, not a no-op dressed up in flavour text', () => {
|
||||
for (const q of QUIRKS) {
|
||||
expect(q.rateMult !== 1 || q.crashMult !== 1).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostnames', () => {
|
||||
it('the starter Pi gets a nickname from the pool', () => {
|
||||
const state = newGame()
|
||||
expect(state.hardware[0].nickname).toBeTruthy()
|
||||
expect(HOSTNAMES).toContain(state.hardware[0].nickname)
|
||||
})
|
||||
|
||||
it('every purchased host gets its own nickname, never reusing one already taken', () => {
|
||||
const state = newGame()
|
||||
state.points = 1_000_000
|
||||
for (let i = 0; i < 5; i++) buyHardware(state, 'desktop')
|
||||
|
||||
const nicknames = state.hardware.map((h) => h.nickname)
|
||||
expect(new Set(nicknames).size).toBe(nicknames.length)
|
||||
})
|
||||
|
||||
it('pickHostname never returns a name already in the taken set', () => {
|
||||
const taken = new Set(HOSTNAMES.slice(0, HOSTNAMES.length - 1))
|
||||
const picked = pickHostname(taken)
|
||||
expect(taken.has(picked)).toBe(false)
|
||||
})
|
||||
|
||||
it('pickHostname falls back to a numbered name once the pool is exhausted', () => {
|
||||
const taken = new Set(HOSTNAMES)
|
||||
const picked = pickHostname(taken)
|
||||
expect(picked).toMatch(/^unit-\d+$/)
|
||||
})
|
||||
})
|
||||
322
src/engine.ts
Normal file
322
src/engine.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { HARDWARE } from './data/hardware'
|
||||
import { SERVICES } from './data/services'
|
||||
import { UPGRADES } from './data/upgrades'
|
||||
import { QUIRKS, QUIRK_CHANCE } from './data/quirks'
|
||||
import { pickHostname } from './data/hostnames'
|
||||
import { FAN_NOISE_LINES, UPDATE_AVAILABLE_LINES, RM_RF_LINES } from './data/events'
|
||||
import type { GameState, DeployedService } from './types'
|
||||
import { nextId } from './state'
|
||||
|
||||
const UPGRADE_LOOKUP: Record<string, (typeof UPGRADES)[number]> = Object.fromEntries(
|
||||
UPGRADES.map((u) => [u.id, u]),
|
||||
)
|
||||
const QUIRK_LOOKUP: Record<string, (typeof QUIRKS)[number]> = Object.fromEntries(
|
||||
QUIRKS.map((q) => [q.id, q]),
|
||||
)
|
||||
|
||||
const MAX_LOG = 60
|
||||
|
||||
function hwDef(defId: string) {
|
||||
return HARDWARE.find((h) => h.id === defId)!
|
||||
}
|
||||
function svcDef(defId: string) {
|
||||
return SERVICES.find((s) => s.id === defId)!
|
||||
}
|
||||
export function quirkDef(quirkId: string | null) {
|
||||
return quirkId ? QUIRK_LOOKUP[quirkId] ?? null : null
|
||||
}
|
||||
|
||||
export function log(state: GameState, text: string) {
|
||||
state.log.unshift({ t: Date.now(), text })
|
||||
if (state.log.length > MAX_LOG) state.log.length = MAX_LOG
|
||||
}
|
||||
|
||||
export function capacity(state: GameState) {
|
||||
let ram = 0
|
||||
let power = 0
|
||||
for (const hw of state.hardware) {
|
||||
const def = hwDef(hw.defId)
|
||||
ram += def.ramSlots
|
||||
power += def.powerBudget
|
||||
}
|
||||
let ramUsed = 0
|
||||
let powerUsed = 0
|
||||
for (const svc of state.services) {
|
||||
const def = svcDef(svc.defId)
|
||||
const mult = svc.redundant ? 2 : 1
|
||||
ramUsed += def.ram * mult
|
||||
powerUsed += def.power * mult
|
||||
}
|
||||
return { ram, power, ramUsed, powerUsed }
|
||||
}
|
||||
|
||||
// Capacity is enforced per host, not as one shared pool - a service
|
||||
// deployed "on the Pi" only draws from the Pi's own RAM/power budget.
|
||||
export function hostCapacity(state: GameState, hardwareId: string) {
|
||||
const hw = state.hardware.find((h) => h.id === hardwareId)!
|
||||
const def = hwDef(hw.defId)
|
||||
let ramUsed = 0
|
||||
let powerUsed = 0
|
||||
for (const svc of state.services) {
|
||||
if (svc.hardwareId !== hardwareId) continue
|
||||
const svcDefinition = svcDef(svc.defId)
|
||||
const mult = svc.redundant ? 2 : 1
|
||||
ramUsed += svcDefinition.ram * mult
|
||||
powerUsed += svcDefinition.power * mult
|
||||
}
|
||||
return { ram: def.ramSlots, power: def.powerBudget, ramUsed, powerUsed }
|
||||
}
|
||||
|
||||
export function coolingBonus(state: GameState) {
|
||||
return state.upgrades.includes('cooling') ? 0.6 : 1
|
||||
}
|
||||
|
||||
export function docBonus(state: GameState) {
|
||||
return state.upgrades.includes('documentation') ? 1.05 : 1
|
||||
}
|
||||
|
||||
export function ratePerSec(state: GameState, svc: DeployedService): number {
|
||||
if (svc.status !== 'running') return 0
|
||||
const def = svcDef(svc.defId)
|
||||
const ageBonus = Math.min(1.5, 1 + svc.age / 1800)
|
||||
const redundantBonus = svc.redundant ? 1.5 : 1
|
||||
const quirkBonus = quirkDef(svc.quirkId)?.rateMult ?? 1
|
||||
return def.baseRate * ageBonus * redundantBonus * quirkBonus * docBonus(state) * state.prestigeMultiplier
|
||||
}
|
||||
|
||||
export function totalRate(state: GameState): number {
|
||||
if (Date.now() < state.outageUntil) return 0
|
||||
return state.services.reduce((sum, s) => sum + ratePerSec(state, s), 0)
|
||||
}
|
||||
|
||||
const FIX_COOLDOWN_MS = 20_000
|
||||
|
||||
export function canFix(svc: DeployedService): boolean {
|
||||
return Date.now() >= svc.fixCooldownUntil
|
||||
}
|
||||
|
||||
export function fixService(state: GameState, serviceId: string) {
|
||||
const svc = state.services.find((s) => s.id === serviceId)
|
||||
if (!svc || svc.status !== 'crashed') return
|
||||
if (!canFix(svc)) return
|
||||
svc.status = 'running'
|
||||
svc.age = 0
|
||||
svc.fixCooldownUntil = Date.now() + FIX_COOLDOWN_MS
|
||||
log(state, `Turned ${svcDef(svc.defId).name} off and on again. It worked. Nobody knows why.`)
|
||||
}
|
||||
|
||||
export function uninstallService(state: GameState, serviceId: string) {
|
||||
const idx = state.services.findIndex((s) => s.id === serviceId)
|
||||
if (idx === -1) return
|
||||
const svc = state.services[idx]
|
||||
const def = svcDef(svc.defId)
|
||||
const refund = Math.round(def.deployCost * 0.25) * (svc.redundant ? 2 : 1)
|
||||
state.points += refund
|
||||
state.services.splice(idx, 1)
|
||||
log(state, `${def.name} uninstalled. Salvaged ${refund} pts in parts.`)
|
||||
}
|
||||
|
||||
function ownedCount(state: GameState, defId: string): number {
|
||||
return state.hardware.filter((h) => h.defId === defId).length
|
||||
}
|
||||
|
||||
// Buying a second unit of a tier you already own is a horizontal-scaling
|
||||
// bridge (buy another Old Desktop instead of grinding for a Server), not a
|
||||
// free replacement for the vertical upgrade path - each additional unit of
|
||||
// the same tier costs 40% more than the last.
|
||||
export function hardwareCost(state: GameState, defId: string): number {
|
||||
const def = HARDWARE.find((h) => h.id === defId)!
|
||||
return Math.round(def.cost * Math.pow(1.4, ownedCount(state, defId)))
|
||||
}
|
||||
|
||||
export function buyHardware(state: GameState, defId: string): boolean {
|
||||
const def = HARDWARE.find((h) => h.id === defId)
|
||||
if (!def) return false
|
||||
const count = ownedCount(state, defId)
|
||||
// The starter Pi is a one-time freebie (cost 0) - letting it be rebought
|
||||
// for free would let RAM/power be farmed for nothing.
|
||||
if (def.cost === 0 && count >= 1) return false
|
||||
const cost = hardwareCost(state, defId)
|
||||
if (state.points < cost) return false
|
||||
state.points -= cost
|
||||
const taken = new Set(state.hardware.map((h) => h.nickname))
|
||||
const nickname = pickHostname(taken)
|
||||
state.hardware.push({ id: nextId(state, 'hw'), defId, nickname })
|
||||
log(
|
||||
state,
|
||||
count > 0
|
||||
? `Another ${def.name} acquired, christened "${nickname}". ${def.flavour}`
|
||||
: `${def.name} acquired, christened "${nickname}". ${def.flavour}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export function deployService(state: GameState, defId: string, hardwareId: string): boolean {
|
||||
const def = SERVICES.find((s) => s.id === defId)
|
||||
const hw = state.hardware.find((h) => h.id === hardwareId)
|
||||
if (!def || !hw) return false
|
||||
if (state.points < def.deployCost) return false
|
||||
const cap = hostCapacity(state, hardwareId)
|
||||
if (cap.ramUsed + def.ram > cap.ram) return false
|
||||
if (cap.powerUsed + def.power > cap.power) return false
|
||||
state.points -= def.deployCost
|
||||
const quirk = Math.random() < QUIRK_CHANCE ? QUIRKS[Math.floor(Math.random() * QUIRKS.length)] : null
|
||||
state.services.push({
|
||||
id: nextId(state, 'svc'),
|
||||
defId,
|
||||
hardwareId,
|
||||
status: 'running',
|
||||
crashReason: '',
|
||||
age: 0,
|
||||
redundant: false,
|
||||
fixCooldownUntil: 0,
|
||||
quirkId: quirk?.id ?? null,
|
||||
})
|
||||
log(
|
||||
state,
|
||||
quirk
|
||||
? `${def.name} deployed. ${def.flavourDeploy} (${quirk.name}: ${quirk.flavour})`
|
||||
: `${def.name} deployed. ${def.flavourDeploy}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export function buyUpgrade(state: GameState, id: string): boolean {
|
||||
if (state.upgrades.includes(id)) return false
|
||||
const upgrade = UPGRADE_LOOKUP[id]
|
||||
if (!upgrade) return false
|
||||
if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false
|
||||
if (state.points < upgrade.cost) return false
|
||||
state.points -= upgrade.cost
|
||||
state.upgrades.push(id)
|
||||
log(state, `${upgrade.name} online. ${upgrade.flavour}`)
|
||||
if (id === 'redundancy') {
|
||||
for (const svc of state.services) svc.redundant = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const CRASH_REASONS = ['disk full', 'OOM killed', 'bad config push', 'dependency broke', 'cosmic ray, probably']
|
||||
|
||||
function crashChance(state: GameState, svc: DeployedService): number {
|
||||
const def = svcDef(svc.defId)
|
||||
const ageFactor = 1 + svc.age / 1200
|
||||
const quirkMult = quirkDef(svc.quirkId)?.crashMult ?? 1
|
||||
return def.baseCrashPerSec * ageFactor * quirkMult * coolingBonus(state)
|
||||
}
|
||||
|
||||
let poissonAccumulator = 0
|
||||
|
||||
export function tick(state: GameState, dtSeconds: number) {
|
||||
const inOutage = Date.now() < state.outageUntil
|
||||
state.gameTime += dtSeconds
|
||||
|
||||
if (!inOutage) {
|
||||
state.points += totalRate(state) * dtSeconds
|
||||
state.totalEarned += totalRate(state) * dtSeconds
|
||||
}
|
||||
|
||||
for (const svc of state.services) {
|
||||
if (svc.status !== 'running') continue
|
||||
svc.age += dtSeconds
|
||||
if (inOutage) continue
|
||||
const p = crashChance(state, svc) * dtSeconds
|
||||
if (Math.random() < p) {
|
||||
const isRedundant = svc.redundant
|
||||
const reason = CRASH_REASONS[Math.floor(Math.random() * CRASH_REASONS.length)]
|
||||
if (isRedundant && state.upgrades.includes('redundancy')) {
|
||||
log(state, `${svcDef(svc.defId).name} instance failed (${reason}). The other one picked up the slack.`)
|
||||
svc.redundant = false
|
||||
continue
|
||||
}
|
||||
svc.status = 'crashed'
|
||||
svc.crashReason = reason
|
||||
log(state, `${svcDef(svc.defId).name} crashed. ${svcDef(svc.defId).flavourCrash}`)
|
||||
if (state.upgrades.includes('backups3') && Math.random() < 0.35) {
|
||||
svc.status = 'running'
|
||||
svc.age = 0
|
||||
log(state, `${svcDef(svc.defId).name} auto-recovered (Backups III).`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poissonAccumulator += dtSeconds
|
||||
const eventInterval = Math.max(25, 90 - state.gameTime / 120)
|
||||
if (poissonAccumulator > eventInterval) {
|
||||
poissonAccumulator = 0
|
||||
rollRandomEvent(state)
|
||||
}
|
||||
}
|
||||
|
||||
function rollRandomEvent(state: GameState) {
|
||||
const running = state.services.filter((s) => s.status === 'running')
|
||||
const roll = Math.random()
|
||||
|
||||
if (roll < 0.18 && running.length > 0) {
|
||||
const svc = running[Math.floor(Math.random() * running.length)]
|
||||
svc.status = 'crashed'
|
||||
svc.crashReason = 'disk full'
|
||||
log(state, `Disk full on ${svcDef(svc.defId).name}. ${svcDef(svc.defId).flavourCrash}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (roll < 0.32) {
|
||||
const hasUps = state.upgrades.includes('ups')
|
||||
const seconds = hasUps ? 4 : 12
|
||||
state.outageUntil = Date.now() + seconds * 1000
|
||||
log(state, `Power outage. Everything is dark for ${seconds}s.${hasUps ? ' UPS softened the blow.' : ''}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (roll < 0.45 && running.length > 0) {
|
||||
const svc = running[Math.floor(Math.random() * running.length)]
|
||||
svc.age = Math.max(0, svc.age - 300)
|
||||
log(state, `Kernel panic. ${svcDef(svc.defId).name}'s host rebooted, some progress lost.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (roll < 0.6) {
|
||||
log(state, FAN_NOISE_LINES[Math.floor(Math.random() * FAN_NOISE_LINES.length)])
|
||||
return
|
||||
}
|
||||
|
||||
if (roll < 0.8) {
|
||||
log(state, UPDATE_AVAILABLE_LINES[Math.floor(Math.random() * UPDATE_AVAILABLE_LINES.length)])
|
||||
return
|
||||
}
|
||||
|
||||
if (running.length > 0 && !state.upgrades.includes('backups1')) {
|
||||
const idx = Math.floor(Math.random() * state.services.length)
|
||||
const removed = state.services.splice(idx, 1)[0]
|
||||
log(state, `${RM_RF_LINES[Math.floor(Math.random() * RM_RF_LINES.length)]} ${svcDef(removed.defId).name} is gone.`)
|
||||
return
|
||||
}
|
||||
|
||||
log(state, 'Nothing happened. Suspicious.')
|
||||
}
|
||||
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
return state.totalEarned >= 50000
|
||||
}
|
||||
|
||||
export function prestige(state: GameState): GameState {
|
||||
const bonus = 1 + Math.floor(state.totalEarned / 50000) * 0.15
|
||||
const fresh: GameState = {
|
||||
points: 15,
|
||||
totalEarned: 0,
|
||||
hardware: [{ id: 'hw-1', defId: 'pi4', nickname: pickHostname(new Set()) }],
|
||||
services: [],
|
||||
upgrades: [],
|
||||
log: [
|
||||
{ t: Date.now(), text: `Rack decommissioned. Multiplier now ${(state.prestigeMultiplier * bonus).toFixed(2)}x.` },
|
||||
{ t: Date.now(), text: 'A new Raspberry Pi sits on the desk. This feels familiar.' },
|
||||
],
|
||||
gameTime: 0,
|
||||
lastTick: Date.now(),
|
||||
outageUntil: 0,
|
||||
prestigeMultiplier: state.prestigeMultiplier * bonus,
|
||||
prestigeCount: state.prestigeCount + 1,
|
||||
seq: 1,
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
64
src/help.ts
Normal file
64
src/help.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CHANGELOG, VERSION } from './data/version'
|
||||
|
||||
const CONTROLS_HTML = `
|
||||
<ul class="help-list">
|
||||
<li>Click a <strong>hardware</strong> entry to select it as the deploy target for new services.</li>
|
||||
<li><strong>+ deploy service</strong> installs the selected service onto the selected host, if it has RAM/power to spare.</li>
|
||||
<li>A crashed service shows <strong>fix</strong> - each service has its own 20s cooldown after being fixed, so one crash never blocks fixing another.</li>
|
||||
<li><strong>rm</strong> uninstalls a service (running or crashed), frees its host capacity, and refunds 25% of its deploy cost.</li>
|
||||
<li>Services only produce points/sec - there are no secondary bonuses tied to which ones you run, beyond the odd random <em>quirk</em> rolled on deploy.</li>
|
||||
<li>Upgrades are permanent. Some require an earlier upgrade first (shown locked with 🔒).</li>
|
||||
<li>Crossing 50,000 lifetime points unlocks <strong>prestige</strong>: wipe your rack for a permanent production multiplier.</li>
|
||||
</ul>
|
||||
`
|
||||
|
||||
let modal: HTMLDivElement | null = null
|
||||
|
||||
function close() {
|
||||
modal?.remove()
|
||||
modal = null
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
export function toggleHelp() {
|
||||
if (modal) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
const changelogHtml = CHANGELOG.map(
|
||||
(entry) => `
|
||||
<div class="cl-entry">
|
||||
<div class="cl-head"><span class="cl-version">v${entry.version}</span><span class="dim">${entry.date}</span></div>
|
||||
<ul>${entry.changes.map((c) => `<li>${c}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`,
|
||||
).join('')
|
||||
|
||||
modal = document.createElement('div')
|
||||
modal.id = 'help-modal'
|
||||
modal.innerHTML = `
|
||||
<div class="help-box">
|
||||
<div class="help-head">
|
||||
<span>rack <span class="dim">v${VERSION}</span></span>
|
||||
<button class="help-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<h3>HOW THIS WORKS</h3>
|
||||
${CONTROLS_HTML}
|
||||
<h3>CHANGELOG</h3>
|
||||
${changelogHtml}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) close()
|
||||
})
|
||||
modal.querySelector('.help-close')!.addEventListener('click', close)
|
||||
document.body.appendChild(modal)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
50
src/main.ts
Normal file
50
src/main.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import './style.css'
|
||||
import { load, newGame, save } from './state'
|
||||
import { tick } from './engine'
|
||||
import { render } from './ui'
|
||||
import { toggleHelp } from './help'
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
const state = load() ?? newGame()
|
||||
|
||||
function scheduleRender() {
|
||||
render(app, state, scheduleRender)
|
||||
}
|
||||
|
||||
let last = performance.now()
|
||||
// Passive UI refresh - user actions call scheduleRender() directly for
|
||||
// instant feedback, so this only needs to be fast enough to reflect the
|
||||
// tick loop's background crashes/events/points ticking up.
|
||||
const RENDER_MS = 1000
|
||||
|
||||
function loop(now: number) {
|
||||
const dt = (now - last) / 1000
|
||||
last = now
|
||||
if (dt > 0) tick(state, Math.min(dt, 5))
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
// Skip the rebuild while a <select> is focused - recreating it mid-render
|
||||
// forces any open native dropdown to close immediately.
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLSelectElement)) {
|
||||
scheduleRender()
|
||||
}
|
||||
setTimeout(renderLoop, RENDER_MS)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
renderLoop()
|
||||
|
||||
window.addEventListener('beforeunload', () => save(state))
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) save(state)
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key !== '?') return
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLSelectElement || active instanceof HTMLInputElement) return
|
||||
toggleHelp()
|
||||
})
|
||||
54
src/state.ts
Normal file
54
src/state.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { GameState } from './types'
|
||||
import { pickHostname } from './data/hostnames'
|
||||
|
||||
const SAVE_KEY = 'rack-save-v1'
|
||||
|
||||
export function newGame(): GameState {
|
||||
return {
|
||||
points: 15,
|
||||
totalEarned: 0,
|
||||
hardware: [{ id: 'hw-1', defId: 'pi4', nickname: pickHostname(new Set()) }],
|
||||
services: [],
|
||||
upgrades: [],
|
||||
log: [{ t: Date.now(), text: 'A Raspberry Pi sits on the desk. It boots. That is the whole plan so far.' }],
|
||||
gameTime: 0,
|
||||
lastTick: Date.now(),
|
||||
outageUntil: 0,
|
||||
prestigeMultiplier: 1,
|
||||
prestigeCount: 0,
|
||||
seq: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function save(state: GameState) {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function load(): GameState | null {
|
||||
const raw = localStorage.getItem(SAVE_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as GameState
|
||||
if (typeof parsed.points !== 'number' || !Array.isArray(parsed.hardware)) return null
|
||||
// Migrate saves from before hosts had nicknames.
|
||||
const taken = new Set(parsed.hardware.map((h) => h.nickname).filter(Boolean))
|
||||
for (const hw of parsed.hardware) {
|
||||
if (!hw.nickname) {
|
||||
hw.nickname = pickHostname(taken)
|
||||
taken.add(hw.nickname)
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function wipe() {
|
||||
localStorage.removeItem(SAVE_KEY)
|
||||
}
|
||||
|
||||
export function nextId(state: GameState, prefix: string): string {
|
||||
state.seq += 1
|
||||
return `${prefix}-${state.seq}`
|
||||
}
|
||||
494
src/style.css
Normal file
494
src/style.css
Normal file
@@ -0,0 +1,494 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #080808;
|
||||
--panel: #0f0f0f;
|
||||
--border: #1e1e1e;
|
||||
--accent: #00e87a;
|
||||
--accent-dim: #00e87a66;
|
||||
--text: #d6d6d6;
|
||||
--text-dim: #6b6b6b;
|
||||
--danger: #ff5c5c;
|
||||
--warn: #e8c400;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.rate {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.points {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item.selected {
|
||||
border-color: var(--accent-dim);
|
||||
background: #0d1a13;
|
||||
}
|
||||
|
||||
.item .name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item.hardware {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.item.upgrade {
|
||||
cursor: pointer;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item.upgrade.owned {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.item.upgrade.locked {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.item.upgrade.affordable .upgrade-status {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.item.upgrade:not(.locked):not(.owned):hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.item.service {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.item.service.crashed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.service.running .status-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.service.crashed .status-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.redundant-badge {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fix-btn {
|
||||
background: var(--danger);
|
||||
color: #080808;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.uninstall-btn {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.uninstall-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.fix-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--accent-dim);
|
||||
color: var(--accent);
|
||||
padding: 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.buy-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.buy-btn:not(:disabled):hover {
|
||||
background: #0d1a13;
|
||||
}
|
||||
|
||||
.deploy-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.svc-select {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.deploy-row .buy-btn {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #3d3d3d;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.prestige-banner {
|
||||
border: 1px solid var(--accent-dim);
|
||||
background: #0d1a13;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prestige-btn {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.quirk-badge {
|
||||
color: var(--warn);
|
||||
font-size: 10px;
|
||||
border: 1px solid #4a3f00;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.rack-art-panel {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rack-art {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.rack-art-line {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.rack-host-name {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.slot-running {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.slot-crashed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
#help-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.help-box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.help-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-close:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.help-body {
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.help-body h3 {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.help-body h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cl-entry {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cl-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cl-version {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cl-entry ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
75
src/types.ts
Normal file
75
src/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface HardwareDef {
|
||||
id: string
|
||||
name: string
|
||||
flavour: string
|
||||
cost: number
|
||||
ramSlots: number
|
||||
powerBudget: number
|
||||
}
|
||||
|
||||
export interface ServiceDef {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
deployCost: number
|
||||
ram: number
|
||||
power: number
|
||||
baseRate: number
|
||||
baseCrashPerSec: number
|
||||
flavourDeploy: string
|
||||
flavourCrash: string
|
||||
}
|
||||
|
||||
export interface UpgradeDef {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
flavour: string
|
||||
requires?: string
|
||||
}
|
||||
|
||||
export interface QuirkDef {
|
||||
id: string
|
||||
name: string
|
||||
flavour: string
|
||||
rateMult: number
|
||||
crashMult: number
|
||||
}
|
||||
|
||||
export interface OwnedHardware {
|
||||
id: string
|
||||
defId: string
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface DeployedService {
|
||||
id: string
|
||||
defId: string
|
||||
hardwareId: string
|
||||
status: 'running' | 'crashed'
|
||||
crashReason: string
|
||||
age: number
|
||||
redundant: boolean
|
||||
fixCooldownUntil: number
|
||||
quirkId: string | null
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
t: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
points: number
|
||||
totalEarned: number
|
||||
hardware: OwnedHardware[]
|
||||
services: DeployedService[]
|
||||
upgrades: string[]
|
||||
log: LogEntry[]
|
||||
gameTime: number
|
||||
lastTick: number
|
||||
outageUntil: number
|
||||
prestigeMultiplier: number
|
||||
prestigeCount: number
|
||||
seq: number
|
||||
}
|
||||
352
src/ui.ts
Normal file
352
src/ui.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user