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:
Fredrik Johansson
2026-07-02 21:55:54 +02:00
commit f0314e7492
23 changed files with 4083 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
PLAN.md

207
CHANGELOG.md Normal file
View File

@@ -0,0 +1,207 @@
# Changelog
## 2026-07-02 — initial build
- Scaffolded with `vite` `vanilla-ts` template, stripped to a blank shell.
Chose vanilla TS + DOM over React/Zustand per `PLAN.md`'s recommendation —
keeps this dependency-light like the other homelab-family projects.
- Dev/preview ports set to `5677`/`4587` in `vite.config.ts` (Vite defaults
`5173`/`4173` are claimed by sibling projects on this machine — avoided
those plus ±10 as instructed).
- Implemented the full core loop from `PLAN.md`: hardware tiers (Pi →
Desktop → Server → Rack → Colo) gating RAM/power capacity, 11 deployable
services across 6 categories, 9 upgrades (some gated behind prerequisites,
e.g. Backups III requires Backups II), and a Poisson-ish random event
scheduler (disk full, power outage, kernel panic, fan noise, update
available, rare `rm -rf`) whose interval tightens as game time advances.
- Added prestige as a real feature rather than a stretch goal: crossing
50,000 lifetime points surfaces a "nuke it and start over" banner that
wipes progress for a permanent multiplier on future production.
- Decision: new games start with 15 pts instead of 0. With zero starting
services and the cheapest deploy costing 10, a strict 0-start would strand
the player with no way to ever earn anything. Applied the same bootstrap
amount after prestige.
- Decision: "fix" (crashed service recovery) has a 60s global cooldown
across all services, matching the "have you tried turning it off and on
again" flavour from `PLAN.md` — prevents it from being a free instant-fix
spam button.
- Verified with a headless Chrome screenshot against the dev server
(`localhost:5677`) rather than just `tsc --noEmit`, per the project's
"test UI changes in a browser" convention.
## 2026-07-02 — soft-lock and refresh-jank fixes
- Bug: the service `<select>` dropdown couldn't stay open — the whole app
re-rendered (rebuilding every DOM node, including the select) every 250ms,
which closes any open native dropdown instantly. Fixed by skipping the
passive rebuild while a `<select>` has focus, and by dropping the passive
refresh rate from 250ms to 1000ms (user actions still re-render instantly
via the direct `onChange()` call in click handlers, so this only affects
how fast background ticks/crashes become visible).
- Bug (real soft-lock): the crash "fix" cooldown was global across *all*
services (60s). If two services crashed close together — very plausible
early on with only 2-3 RAM slots and no spare hardware to buy — the second
stayed dead for up to a minute with zero income and nothing else to do.
Reported by user: "nginx crashed, can't install a new one, same with
pi-hole." Fixed by moving the cooldown onto each `DeployedService`
(`fixCooldownUntil` field) instead of `GameState`, and shortening it to
20s now that it's per-service rather than shared. A freshly crashed
service can always be fixed immediately; the cooldown only throttles
re-fixing the *same* service repeatedly.
- Also preserved the log panel's scroll position across rebuilds — a full
`innerHTML = ''` rebuild was resetting it to the top every render.
## 2026-07-02 — capacity was a shared pool, not per-host
- Bug: `capacity()` summed RAM/power across *all* owned hardware into one
combined pool, and `deployService` checked against that pool regardless
of which host was selected. So clicking a specific hardware item before
deploying was cosmetic — a service could draw from the Desktop's power
budget while nominally "deployed on the Pi." User reported losing track
of which service ran where, and one Nginx instance disappearing after
deploying Wireguard (most likely the pre-existing rare `rm -rf` random
event, unrelated to deploy capacity — it fires independently and only
while Backups I is unowned).
- Fixed by adding `hostCapacity(state, hardwareId)` which checks RAM/power
against the *specific* host's own budget, and switching `deployService`
to use it instead of the aggregate. Host selection now actually
constrains where a service can go.
- UI: each hardware item now shows its own `ram x/y · pwr x/y`, and each
service row now shows which host it's running on, so "which host is this
on" is answerable at a glance instead of requiring a guess.
- Verified by seeding `localStorage` with two hosts and one service per
host (served via Vite's `public/` dir so the origin matched, then
screenshotted headless) — confirmed per-host capacity numbers and host
labels render correctly.
## 2026-07-02 — uninstall services, and a note on what services actually do
- Feature: added `uninstallService()` and an `rm` button on every service
row (running or crashed) — there was previously no way to free up a
host's RAM/power short of a crash-and-abandon, which combined with
per-host capacity (above) could genuinely strand a full host. Uninstall
refunds 25% of the original deploy cost (doubled if the instance was
running redundant, since that consumed 2x the deploy cost in practice).
- Confirmed for the record: services currently have exactly one effect —
producing points/sec. No secondary bonuses, synergies, or unlocks tied to
which services are running. Worth knowing before uninstalling for
capacity: there's no hidden reason to keep a low-value service around
beyond its own production.
## 2026-07-02 — test suite
- Added Vitest (`npm test`) and `src/engine.test.ts`, 31 tests against
`engine.ts` and the static `data/` definitions. No UI/DOM tests — `ui.ts`
is a straightforward render of engine state and isn't where the risk is;
the engine is where a change silently breaks the core loop or the
balance.
- Coverage: deploy/capacity (including the per-host isolation fix),
hardware/upgrade purchasing and prerequisite gating, per-service fix
cooldowns (explicitly tests that one service's cooldown doesn't block
fixing a different crashed service — regression test for the soft-lock
bug reported earlier), uninstall refunds, tick production/aging/outage
behaviour, and prestige.
- Added a `balance sanity` block that asserts properties of the data
rather than specific numbers (payback time per service, hardware cost/
capacity monotonicity, crash-risk ceiling, starting-balance affordability
window). One of these caught a real gap between my assumption and the
actual data on first run: I asserted crash risk stays under 10%/minute
without checking the real values first, and waste-go's configured
0.15/minute failed it. Not a game bug — fixed the test's threshold to
match the intended range (documented inline) instead of changing the
data.
## 2026-07-02 — quirks, 3 new services, rack view, in-app help/changelog
- Feature: service **quirks**. `deployService` now has a 30% chance
(`QUIRK_CHANCE` in `src/data/quirks.ts`) of rolling a per-instance trait
(Overclocked, Chatty, Legacy, Well-documented, Flaky, Artisanal) that
multiplies that instance's `ratePerSec` and crash chance independently of
its service definition. Stored as `quirkId` on `DeployedService`, shown
as a badge on the service row. Answers "do services do anything besides
produce points" with a small yes, without adding a whole synergy system.
- Feature: 3 new services — **Homepage** (Dashboard, very cheap, meant to
sit next to Nginx early), **Grafana** (Monitoring), **Matrix Synapse**
(Chat, mid-late game). Balanced against the existing `balance sanity`
test thresholds (payback < 300s, crash risk < 16%/min) rather than eyeballed.
- Feature: **ASCII rack view** (`buildRackArt` in `ui.ts`) — a monospace
strip above the two-column layout, one line per owned host, showing
which services are deployed there as 2-letter codes (colored by running/
crashed status) and remaining capacity as dots. Directly answers "which
host is this on" at a glance, which was the underlying ask behind the
earlier per-host capacity fix.
- Feature: in-app **help & changelog overlay** (`src/help.ts`), modeled on
`../adventure`'s `HelpModal.ts` pattern: a single keypress (`?`, also
works via a header button) toggles a centered modal, closable via ×,
click-outside, or Escape. Content is a short "how this works" list plus
the full changelog, sourced from `src/data/version.ts` (`VERSION` +
`CHANGELOG` array) rather than duplicating this file's prose — that data
module is the condensed, in-app-facing counterpart to this file.
- Bumped in-app `VERSION` to `0.2.0` to mark this batch.
## 2026-07-02 — deploy costs visible, multi-buy hardware, close the desktop→server gap
- Feature: the deploy dropdown now shows each service's RAM/power cost
inline (`Nginx — 10pts · ram 1 · pwr 1`), and disables any service that
wouldn't fit on the currently selected host (`(no room on selected
host)`). Previously RAM/power requirements were only visible after
deploying, by checking the host's used/total numbers — the dropdown gave
no advance warning. User reported: "some of the software has ram or cpu
requirements, they need to be shown in the dropdown."
- Feature: hardware can now be bought again after you already own it — a
second Old Desktop, a third, etc. — not just the single "next tier"
purchase from before. Each extra unit of a tier costs 40% more than the
last unit of that same tier (`hardwareCost()` in `engine.ts`); the
starter Pi 4 is excluded (it's a one-time freebie, not a purchasable
tier — rebuying it at cost 0 would let RAM/power be farmed for free).
This is the direct fix for the reported "gap from Old Desktop to 1U
server where it takes ages to gain points": before this, the only lever
between a 150pt Desktop and a 2200pt Server was grinding on a single
Desktop's 5 RAM / 8 power budget. Now a second Desktop (210pts) is a
much closer, cheaper stepping stone.
- Since more than one host can now share a tier, hardware/service labels
disambiguate with a suffix ("Old Desktop #2") whenever more than one of
that tier is owned — added a shared `hostLabel()` helper in `ui.ts` used
by the hardware list, service rows, and the rack view.
- Bumped in-app `VERSION` to `0.3.0`. Added 3 tests: repeat-buying a tier
produces a second independent host, escalating cost per extra unit, and
the starter Pi's rebuy block.
## 2026-07-02 — host nicknames
- Feature: every host is now randomly christened with a name from a curated
pool (`src/data/hostnames.ts`) on purchase — sci-fi AIs (hal9000, glados,
shodan), mythology (prometheus, cerberus), and self-deprecating sysadmin
humour (the-basement, works-on-my-machine, do-not-touch). User feedback:
"Add quirky and/or nerdy names to any machine in the rack."
- This also replaces the `#2`/`#3` numbering scheme added for the earlier
multi-buy feature — nicknames are drawn without replacement per game
(`pickHostname()` takes the set of already-used names), so they're
unique by construction and read far better than an index. Falls back to
`unit-N` only if the ~54-name pool is ever exhausted (would need 55
hosts in one game).
- Old saves from before this change are migrated on load — any host
missing a `nickname` gets one assigned, without touching anything else
in the save.
- Bumped in-app `VERSION` to `0.4.0`. Added 4 tests: starter Pi gets a
pooled name, no two purchased hosts collide, `pickHostname()` respects
the taken set, and the `unit-N` fallback once the pool is exhausted.
## 2026-07-02 — subpath build, git init
- Added `npm run build:subpath` (`vite build --base=/rack/`) so `dist/`
asset URLs resolve correctly when served from `goonk.se/rack` instead of
a domain root. Verified the emitted `index.html` references
`/rack/assets/...` rather than `/assets/...`.
- Confirmed the deployment target: `../goonk` is an Astro site
(`docker-compose.yml` image `goonk-cv`) whose `public/` directory is
already the established home for sibling projects (`public/waste`,
`public/pitwall`) - Astro copies `public/` verbatim into its own build
output, and its nginx config's `try_files $uri $uri/ /index.html`
fallback needs no changes to serve a new subdirectory. So deploying rack
is: `npm run build:subpath`, then copy `dist/` into `goonk/public/rack/`.
- This repo (`/home/frejoh/temp/rack`) was living as an untracked directory
inside the *parent* `/home/frejoh/temp` git repo, unlike every sibling
project (`goonk`, `adventure`, etc.), which each have their own `.git`.
Ran `git init` here to match that convention ahead of the eventual real
deploy.

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# rack
A browser-based idle/incremental game about running a homelab. Start with a
single Raspberry Pi on a desk, incrementally expand into something that
vaguely justifies its electricity bill.
Dark terminal aesthetic, dry flavour text, numbers go up, occasionally
things go wrong.
## Running it
```bash
npm install
npm run dev # dev server on http://localhost:5677
npm run build # production build
npm run preview # preview the build on http://localhost:4587
npm test # vitest run - engine + balance tests
```
No backend. Game state lives entirely in `localStorage`, autosaved on every
render tick and on tab close/hide.
## How it works
- **Hardware** (`src/data/hardware.ts`) provides RAM slots and power budget,
enforced per host (not a shared pool). You can either unlock the next
tier, or buy another unit of a tier you already own as a cheaper bridge —
each extra unit of the same tier costs 40% more than the last
(`hardwareCost()` in `engine.ts`). The starter Pi 4 is a one-time freebie
and can't be rebought.
- **Services** (`src/data/services.ts`) run on hardware, consume RAM/power,
and produce points/sec. They age (production creeps up over time) and can
crash (probability rises with age, mitigated by the Cooling upgrade). The
deploy dropdown shows each service's RAM/power cost up front and disables
anything that wouldn't fit the currently selected host.
- **Upgrades** (`src/data/upgrades.ts`) are one-time persistent purchases:
UPS shortens outages, Backups tiers reduce/reverse crash losses,
Monitoring reveals crash risk, Redundancy runs a second instance of every
service, Documentation is a small passive multiplier.
- **Random events** (`src/engine.ts` `rollRandomEvent`) fire on a
Poisson-ish interval that tightens as the game clock advances: disk-full
crashes, power outages, kernel panics, fan-noise flavour-only events,
optional updates, and a rare `rm -rf` that deletes a service outright
(guarded once Backups I is bought).
- **Prestige**: once total lifetime earnings cross 50,000 pts, a banner
offers to decommission the rack — wipes hardware/services/upgrades but
grants a permanent production multiplier and carries forward.
- **Host nicknames** (`src/data/hostnames.ts`): every host is randomly
christened on purchase from a pool of sci-fi/mythology/sysadmin-humour
names (hal9000, shodan, the-basement, works-on-my-machine, ...), drawn
without replacement so they double as unique identifiers wherever a host
is named.
- **Quirks** (`src/data/quirks.ts`): each deploy has a 30% chance of rolling
a per-instance trait (Overclocked, Chatty, Legacy, Well-documented,
Flaky, Artisanal) that shifts that instance's production and crash risk.
Shown as a badge on the service row and in the rack view's tooltip.
- **Rack view**: an ASCII-art strip above the two main columns, one line per
owned host, showing which services (as 2-letter codes) are deployed where
and free capacity as dots — a compact answer to "what's actually running
on what."
- **Help & changelog**: click the `?` in the header, or press `?` anywhere
outside a text input, to open an in-app overlay with a quick "how this
works" list and the full version changelog (`src/data/version.ts`),
mirroring `../adventure`'s help-modal pattern.
## Project layout
```
src/
types.ts game state & definition types
state.ts new game, save/load (localStorage), id allocation
engine.ts tick loop: production, aging, crashes, events, prestige
ui.ts DOM rendering, one full re-render per render tick
help.ts the ?-triggered help/changelog overlay
data/ static definitions: hardware/services/upgrades/events/
quirks/version (VERSION + in-app CHANGELOG entries)
style.css terminal theme
```
The simulation tick runs on `requestAnimationFrame` and is time-delta based
(not frame-based), so offline/backgrounded tabs still catch up correctly
when they regain focus. The passive UI re-render runs on its own ~1s
interval, decoupled from the tick — user actions (buy, deploy, fix,
uninstall) re-render immediately via a direct callback instead of waiting
on that interval, and it's skipped entirely while a `<select>` has focus so
the service-picker dropdown doesn't get yanked shut mid-render.
## Deploying as a subdirectory (e.g. goonk.se/rack)
`npm run build` emits root-relative asset URLs (`/assets/...`), which only
works if rack is served from the domain root. To deploy it under a
subpath instead, use:
```bash
npm run build:subpath # vite build --base=/rack/
```
That rewrites `dist/index.html`'s asset URLs to `/rack/assets/...`. Drop
the resulting `dist/` contents into the host site wherever it serves
static files under `/rack/` — for the `goonk` Astro site specifically,
that's `goonk/public/rack/` (Astro copies `public/` verbatim into its own
`dist/` at build time, and its nginx config already does a
`try_files $uri $uri/ /index.html` fallback, so no nginx changes are
needed - existing `public/waste`, `public/pitwall` follow the same
one-subdir-per-project convention). `rack`'s own `localStorage` key
(`rack-save-v1`) is namespaced, so it won't collide with anything else
served from the same origin.
## Tests
`src/engine.test.ts` (Vitest) covers the core loop mechanics rather than UI:
deploy/capacity checks (including per-host isolation), hardware/upgrade
purchases and prerequisite gating, the fix-cooldown (per-service, not
global — see CHANGELOG for why that matters), uninstall refunds,
production/crash/outage ticking, and prestige. A separate `balance sanity`
block asserts properties of the static data in `src/data/` rather than
specific numbers — e.g. every service pays back its own deploy cost in
under 5 minutes at base rate, hardware cost/capacity strictly increases
with tier, and no service's crash risk has crept past the intended
ceiling. These are meant to catch a future data tweak that breaks the
early-game feel, not to lock the current numbers in stone — adjust the
thresholds deliberately if you retune the balance.
## Design decisions worth knowing
- **Vanilla TypeScript + DOM**, no framework — matches the rest of the
homelab-project family (`waste-go`, `flit`) and keeps this dependency-light
per `PLAN.md`.
- **Dev/preview ports are non-default** (`5677` / `4587` instead of Vite's
`5173` / `4173`) because the other sibling projects on this machine claim
the usual ports and everything within ±10 of them.
- **Starting balance is 15 pts**, not 0 — the cheapest service (Nginx)
costs 10, so a brand-new game can bootstrap its first deploy immediately
without an awkward zero-income deadlock.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖥️</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rack</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1259
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "rack",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:subpath": "tsc && vite build --base=/rack/",
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.9"
}
}

17
src/data/events.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
// Avoid the usual 5173/4173 defaults - those ports are claimed by sibling
// projects on this machine.
export default defineConfig({
server: { port: 5677, strictPort: true },
preview: { port: 4587, strictPort: true },
test: {
environment: 'node',
},
})