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:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
207
CHANGELOG.md
Normal 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
134
README.md
Normal 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
13
index.html
Normal 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
1259
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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
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
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal 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
12
vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user