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>
2026-07-02 21:55:54 +02:00
|
|
|
|
# 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.
|
2026-07-02 22:23:26 +02:00
|
|
|
|
|
|
|
|
|
|
## 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`).
|