# 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 `` 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. ## 2026-07-02 — containerize, decoupled from goonk-cv's own deploy - Corrected course from the previous entry: copying rack's build into `goonk/public/rack/` would mean every rack change requires rebuilding and redeploying goonk-cv's own image — exactly the coupling explicitly ruled out ("I don't necessarily want to redeploy goonk-cv when updating this"). `goonk-cv` is built as its own Docker image via Gitea Actions and pulled to host independently of any other project's repo, so rack needed the same shape, not a file dropped into goonk's build. - Added `Dockerfile` + `nginx/default.conf` (multi-stage: `node:24-alpine` build → `nginx:alpine` static serve), mirroring `goonk`'s own Dockerfile structure exactly, minus the `/admin` and `/api` proxy blocks it needs and rack doesn't (no backend). Verified locally: `docker build` succeeds and the resulting container serves the app correctly on a test port. - Added `.gitea/workflows/docker.yml`, copied from `goonk`'s workflow (which already derives the registry image name from the repo path, so no rack-specific edits were needed beyond dropping the `-api` build step goonk has and rack doesn't). Requires a `TKNTKN` repo secret (Gitea PAT, `packages:write`) to be added manually in this repo's settings — not something settable from here. - Added `docker-compose.yml` (host-side reference, `image: repo.explewd.com/explewd/rack:latest`) and `.env.example` for the `HOST_PORT` override, matching `goonk`'s compose shape. - Decision: **subdomain, not `goonk.se/rack`.** Discussed both — a subpath deployment needs the `--base=/rack/` build *and* the edge proxy to forward `/rack/...` through unstripped *and* the container to serve files at that same sub-path, three things that have to stay in sync. A subdomain (`rack.explewd.com`) needs one DNS record and one new `server_name`/proxy-host entry, with the app built completely vanilla — same decoupled-deploy story, far less to keep synchronized. Kept `npm run build:subpath` in `package.json` in case the subpath route is wanted later, but it's not part of the current deploy path. - Host port: `5677`, matching the existing dev-server port for memorability, mapped via `docker-compose.yml`'s `HOST_PORT` (defaults to `5677`, override via `.env`).