commit f0314e749276f8d8f71d9961a9b7b3e07d1c5d5a Author: Fredrik Johansson Date: Thu Jul 2 21:55:54 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9812e30 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b974afe --- /dev/null +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eebb336 --- /dev/null +++ b/README.md @@ -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 ` 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() +}) diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..c34577d --- /dev/null +++ b/src/state.ts @@ -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}` +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..8f0e021 --- /dev/null +++ b/src/style.css @@ -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; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1c19de6 --- /dev/null +++ b/src/types.ts @@ -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 +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..659ec10 --- /dev/null +++ b/src/ui.ts @@ -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 = { + 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 = ` +
rack_
+
+ ${inOutage ? '✗' : '▲'} + ${inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`} + ${fmt(state.points)} pts + +
+ ` + 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 = `

RACK

` + 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 `[${abbr}]` + }) + .join('') + + const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed) + const dots = '·'.repeat(Math.min(freeSlots, 20)) + + line.innerHTML = `${hw.nickname.padEnd(16, ' ')}${badges}${dots}` + pre.appendChild(line) + } + section.appendChild(pre) + return section +} + +function buildLeftColumn(state: GameState, cap: ReturnType, onChange: () => void): HTMLElement { + const col = document.createElement('div') + col.className = 'col left' + + const hwSection = document.createElement('section') + hwSection.className = 'panel' + hwSection.innerHTML = `

HARDWARE Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}

` + 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 = `${hostLabel(state, hw.id)}ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}` + 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 = `

UPGRADES

` + 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 = `${up.name}${ + owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost) + }` + 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 = `

SERVICES

` + 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 ? ' ×2' : '' + const quirk = quirkDef(svc.quirkId) + const quirkBadge = quirk ? ` ${quirk.name}` : '' + row.innerHTML = ` + ${svc.status === 'running' ? '✓' : '✗'} + ${def.name}${badge}${quirkBadge} + ${hostLabel(state, svc.hardwareId)} + ${def.category} + ${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'} + ${showRates && svc.status === 'running' ? `risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr` : ''} + ` + 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 = `

LOG

` + 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 = `${timeAgo(entry.t)} ${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 = `The rack has served its purpose. Decommission it for a permanent multiplier?` + 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 +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..206b5e0 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f499e15 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +/// +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', + }, +})