`:
+
+```jsx
+ {down && (
+
+ {down.factor === 0 ? 'Offline' : `At ${Math.round(down.factor * 100)}%`}
+ {down.kind === 'overheat' ? ' - overheated' : down.kind === 'driveFailure' ? ' - drive failure' : ' - incident'}
+
+ )}
+```
+
+adding `danger` to the theme import at the top of the file.
+
+- [ ] **Step 5: Correct the Overclock panel's copy**
+
+In `client/src/game/components/OverclockPanel.jsx`, the explanatory block still
+describes a producing lane and a lane freeze. Replace its text:
+
+```jsx
+ Overclock nodes no longer produce FLOPS on their own - they multiply your Racks output instead, and generate heat doing it. Let heat hit 100% and one of your rack tiers goes dark for a while: running hot risks the very thing it amplifies. No nodes are ever lost. Venting sheds {Math.round(ventPercent)}% of your heat capacity.
+```
+
+and change the per-node rate line from `{fmt(rate)} F/s` to a boost reading:
+
+```jsx
+ +{fmt(rate)} to Racks · {def.heatPerSec.toFixed(2)} heat/s each
+```
+
+- [ ] **Step 6: Wire it all into `RackStack.jsx`**
+
+Add the imports:
+
+```jsx
+import OutageStrip from './game/components/OutageStrip.jsx';
+import ResiliencePanel from './game/components/ResiliencePanel.jsx';
+```
+
+Add the two dispatchers in the "Economy actions - optimistic dispatch" block
+(~line 598), alongside `buyGrid`/`ventHeat`. The helper is `dispatchAction`,
+not `applyLocal` — `applyLocal` is the internal optimistic-prediction step that
+`dispatchAction` calls:
+
+```jsx
+ function buySupply(id) { dispatchAction({ type: 'buySupply', id }); }
+ function resolveOutage(id) { dispatchAction({ type: 'resolveOutage', id }); }
+```
+
+Neither action belongs in `api.js`'s `IMMEDIATE` set: both are ordinary
+economy actions whose optimistic result is exactly what the server will
+confirm, so the normal 1s flush is correct. (`claimAnomaly` is IMMEDIATE only
+because its reward is *rolled server-side* and the client cannot predict it.)
+
+Render the strip inside the sticky header, immediately after `
`:
+
+```jsx
+
+```
+
+Render the panel alongside the other tabs:
+
+```jsx
+ {activeTab === 'resilience' && (
+
+ )}
+```
+
+Pass the two new props to `RacksPanel`:
+
+```jsx
+
+```
+
+Finally, surface the notices as toasts. In `handleReconcile`, directly after
+the existing `if (serverState.server.overheated) setModal({ type: 'meltdown' });`:
+
+```jsx
+ // v1.11: one-shot outage notices, same lifecycle as `overheated` above.
+ // Toast, not modal - these are information, not a reward (the v1.10 rule:
+ // rewards use the modal, everything else uses the toast). The ABSORBED
+ // notice is mandatory (spec §6): the moment a hedge pays off is the only
+ // time the player learns hedging was worth it.
+ for (const n of serverState.server.outageNotices || []) {
+ showToast(n.absorbed
+ ? `${OUTAGE_NOTICE_LABEL[n.kind] || n.kind} absorbed. ${n.remaining} left.`
+ : `${OUTAGE_NOTICE_LABEL[n.kind] || n.kind} - part of your farm is degraded.`);
+ }
+```
+
+and add the label map alongside `REJECT_MESSAGES`:
+
+```jsx
+ const OUTAGE_NOTICE_LABEL = {
+ ransomware: 'Ransomware',
+ ispOutage: 'ISP outage',
+ driveFailure: 'Drive failure',
+ };
+```
+
+- [ ] **Step 7: Build the client and check it renders**
+
+Run: `cd client && npm run build`
+Expected: build succeeds with no unresolved imports.
+
+Run: `TEST_BACKEND=sqlite npx vitest run tests/tours.test.js`
+Expected: PASS — the tour registry and `TOUR_IDS` still agree (no new tour was
+registered, only new onboarding steps).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add client/src
+git commit -m "v1.11 Task 10: outage strip, Resilience tab, offline-tier badges and notice toasts"
+```
+
+---
+
+### Task 11: Smoke suite, changelog, version bump, release
+
+**Files:**
+- Create: `tests/e2e/smoke-v111.mjs`
+- Modify: `CHANGELOG.md`
+- Modify: `package.json` (version), `Dockerfile` (LABEL)
+- Test: the smoke suite itself
+
+**Interfaces:**
+- Consumes: everything above.
+- Produces: nothing new; this task ships what exists.
+
+- [ ] **Step 1: Write the smoke suite**
+
+Create `tests/e2e/smoke-v111.mjs`. Copy the harness shape from
+`tests/e2e/smoke-v110.mjs` verbatim — the spawn/seed/JWT/teardown scaffolding
+is identical and must not be reinvented. Change only:
+
+```js
+const PORT = 3811;
+const DB_PATH = '/tmp/e2e-v111.db';
+```
+
+Admin checks need an owner account, so this suite also sets
+`process.env.SUPER_ADMIN_IDS` before importing `server/db.js`, exactly as
+`smoke-v14-events.mjs` does:
+
+```js
+const OWNER_ID = 'github:37058311';
+process.env.SUPER_ADMIN_IDS = OWNER_ID;
+```
+
+Seed helper, adapted from smoke-v110's (`seedUser` there hardcodes a `v110-`
+provider id prefix — change the prefix, keep the shape):
+
+```js
+let seq = 0;
+async function seedUser(mutate, ident) {
+ seq += 1;
+ const user = await upsertUser({
+ provider: ident ? ident.provider : 'discord',
+ providerId: ident ? ident.providerId : `v111-${seq}`,
+ username: ident ? ident.username : `v111user${seq}`,
+ avatarUrl: null,
+ });
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 40, manager: true, ready: 0 };
+ s.run.grid[0] = { id: 0, owned: 10 };
+ if (mutate) mutate(s);
+ await putSave(user.id, s, Date.now() - 60 * 60 * 1000); // 1h ago: an offline gap
+ return user;
+}
+```
+
+The checks:
+
+```js
+ // --- 1-2: an outage costs output, and Cold Storage never notices ---------
+ const HOUR = 3600 * 1000;
+ const past = Date.now() - HOUR;
+
+ const clean = await seedUser((s) => {
+ s.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: 0, startedAt: past };
+ });
+ const dark = await seedUser((s) => {
+ s.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: 0, startedAt: past };
+ s.server.outages = [{
+ id: 'hazard:e2e', kind: 'ransomware', scope: { lane: '*' }, factor: 0,
+ startAt: past, endAt: Date.now() + HOUR, source: 'hazard',
+ }];
+ });
+
+ const cleanState = (await api(clean, '/api/state')).body;
+ const darkState = (await api(dark, '/api/state')).body;
+
+ await check('an outage reduces output over the same window', async () => {
+ assert(cleanState.state.run.credits > 10, 'clean save earned nothing');
+ assert(darkState.state.run.credits < cleanState.state.run.credits,
+ `expected the darkened save to earn less: ${darkState.state.run.credits} vs ${cleanState.state.run.credits}`);
+ });
+
+ await check('Cold Storage is a safe harbour - identical with and without an incident', async () => {
+ assert(darkState.state.meta.coldStorage.job.accruedOfflineSec
+ === cleanState.state.meta.coldStorage.job.accruedOfflineSec,
+ 'cold storage job accrual differed under an outage');
+ assert(darkState.state.meta.coldStorage.tapes === cleanState.state.meta.coldStorage.tapes,
+ 'cold storage tapes differed under an outage');
+ });
+
+ // --- 3: buying a supply --------------------------------------------------
+ const buyer = await seedUser((s) => { s.run.credits = 1e12; });
+ await check('buySupply charges credits and stocks one', async () => {
+ const res = await api(buyer, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({ actions: [{ type: 'buySupply', id: 'antivirus' }] }),
+ });
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ assert(res.body.results[0].ok === true, `buySupply rejected: ${JSON.stringify(res.body.results[0])}`);
+ assert(res.body.state.meta.supplies.antivirus === 1,
+ `expected 1 antivirus, got ${res.body.state.meta.supplies.antivirus}`);
+ assert(res.body.state.run.credits < 1e12, 'credits were not charged');
+ });
+
+ const pauper = await seedUser((s) => { s.run.credits = 0; s.run.tiers[0].owned = 0; s.run.grid[0].owned = 0; });
+ await check('buySupply is refused when unaffordable, and changes nothing', async () => {
+ const res = await api(pauper, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({ actions: [{ type: 'buySupply', id: 'antivirus' }] }),
+ });
+ assert(res.body.results[0].error === 'insufficient_credits',
+ `expected insufficient_credits, got ${JSON.stringify(res.body.results[0])}`);
+ assert(res.body.state.meta.supplies.antivirus === 0, 'stock changed on a rejected buy');
+ });
+
+ // --- 4: the bound. A 1970 nextHazardAt must terminate, not spin ----------
+ const ancient = await seedUser((s) => { s.server.nextHazardAt = 1; });
+ await check('a nextHazardAt far in the past terminates and reschedules', async () => {
+ const t0 = Date.now();
+ const res = await api(ancient, '/api/state');
+ const took = Date.now() - t0;
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ assert(took < 5000, `took ${took}ms - the firing loop is not bounded`);
+ assert(res.body.state.server.nextHazardAt > Date.now(),
+ 'nextHazardAt was not rolled forward past now');
+ assert(res.body.state.server.outages.length <= MAX_HAZARDS_PER_EVALUATION,
+ `fired ${res.body.state.server.outages.length} outages, above the bound`);
+ });
+
+ // --- 5: absorption reaches an offline player ----------------------------
+ const hedged = await seedUser((s) => {
+ s.meta.supplies = { antivirus: 3, backupIsp: 3, spareDrives: 3 };
+ s.server.nextHazardAt = Date.now() - HOUR / 2; // one is due
+ });
+ await check('a stocked supply absorbs a hazard that fired while offline', async () => {
+ const res = await api(hedged, '/api/state');
+ const supplies = res.body.state.meta.supplies;
+ const total = supplies.antivirus + supplies.backupIsp + supplies.spareDrives;
+ assert(total < 9, 'nothing was consumed - no hazard fired to absorb');
+ assert(res.body.state.server.outages.length === 0,
+ `absorbed hazards must leave no outage, found ${res.body.state.server.outages.length}`);
+ });
+
+ // --- 6-7: the kill switch, and the boolean type, end to end -------------
+ const owner = await seedUser(undefined, { provider: 'github', providerId: '37058311', username: 'owner_v111_e2e' });
+ assert(`${owner.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${owner.id}`);
+
+ await check('a string on a boolean tunable is rejected', async () => {
+ const cur = (await api(owner, '/api/admin/config')).body;
+ const doc = structuredClone(cur.data);
+ doc.risk.enabled = 'no';
+ const res = await api(owner, '/api/admin/config', { method: 'PUT', body: JSON.stringify(doc) });
+ assert(res.status >= 400 || (res.body && Array.isArray(res.body.errors)),
+ 'a string boolean was accepted');
+ assert(res.body.errors.some((e) => e.startsWith('risk.enabled:')),
+ `expected a risk.enabled error, got ${JSON.stringify(res.body.errors)}`);
+ });
+
+ const throttled = await seedUser((s) => {
+ s.server.outages = [{
+ id: 'hazard:kill', kind: 'ransomware', scope: { lane: '*' }, factor: 0,
+ startAt: past, endAt: Date.now() + 10 * HOUR, source: 'hazard',
+ }];
+ });
+ await check('the kill switch clears a live outage on the next reconcile', async () => {
+ const cur = (await api(owner, '/api/admin/config')).body;
+ const off = structuredClone(cur.data);
+ off.risk.enabled = false;
+ const put = await api(owner, '/api/admin/config', { method: 'PUT', body: JSON.stringify(off) });
+ assert(typeof put.body.version === 'number', `config PUT failed: ${JSON.stringify(put.body)}`);
+
+ const res = await api(throttled, '/api/state');
+ assert(res.body.state.server.outages.length === 0,
+ `expected the outage cleared, found ${res.body.state.server.outages.length}`);
+
+ // restore, so check 8's browser pass sees the shipped defaults
+ const on = structuredClone(off);
+ on.risk.enabled = true;
+ await api(owner, '/api/admin/config', { method: 'PUT', body: JSON.stringify(on) });
+ });
+```
+
+Check 8 uses the same Playwright resolution the other suites do (copy
+`loadPlaywright`/`findScratchpadPlaywright` verbatim from smoke-v110) and
+**SKIPs rather than fails** when no browser resolves:
+
+```js
+ if (!browser) {
+ console.log('SKIP the Resilience tab renders (no Playwright browser available)');
+ } else {
+ await check('the Resilience tab renders supplies with a price', async () => {
+ const page = await browser.newPage();
+ await page.context().addCookies([{
+ name: COOKIE_NAME, value: cookieFor(buyer).split('=')[1],
+ domain: 'localhost', path: '/',
+ }]);
+ await page.goto(BASE_URL);
+ await page.getByRole('button', { name: /Resilience/ }).click();
+ const buy = page.getByTestId('supply-buy-antivirus');
+ await buy.waitFor({ timeout: 10000 });
+ assert((await buy.textContent()).includes('Buy 1'), 'supply buy button did not render its price');
+ await page.close();
+ });
+ }
+```
+
+Every check prints `PASS
` or `FAIL : `; the run ends with
+`=== ERRORS ===` and each failure, or `NONE`; non-zero exit if anything failed;
+the server child process is always killed on the way out. Import
+`MAX_HAZARDS_PER_EVALUATION` from `shared/outages.js` alongside the other
+dynamic imports at the top.
+
+- [ ] **Step 2: Run the smoke suite**
+
+Run: `node tests/e2e/smoke-v111.mjs`
+Expected: every check PASS (check 8 may SKIP without a browser).
+
+Run: `npm run smoke`
+Expected: every `tests/e2e/smoke-v1*.mjs` suite passes — the new file is picked
+up by the glob automatically.
+
+- [ ] **Step 3: Write the changelog entry**
+
+Prepend to `CHANGELOG.md`, matching the existing voice (what broke or changed,
+and why it was worth doing — not a list of commits):
+
+```markdown
+## v1.11.0
+
+- **Things can now go wrong.** Every few hours something breaks: ransomware
+ halves every lane, an ISP outage takes the Grid dark, a drive failure kills
+ one rack tier. Incidents only ever **reduce output** — they never destroy
+ racks, FLOPS, tapes or upgrades, so there is no such thing as a dead save
+ and no repair you must be able to afford. In an idle game the real currency
+ is lost time, and that is all this takes.
+
+ You are never told when the next one is coming, only the standing rate
+ (about one every six hours). That is deliberate: a schedule you can see
+ turns preparation into buying one licence twenty minutes beforehand.
+
+- **Prepaid supplies, and a cure priced worse than the cure.** Antivirus
+ licences, backup ISP lines and spare drives are bought with FLOPS and absorb
+ one matching incident automatically — **including while you are offline**,
+ which is the only defence that can reach an incident that starts and ends
+ during a nine-hour absence. They live in your permanent progress, so they
+ survive a Migrate; spend down before you prestige rather than watching the
+ balance evaporate.
+
+ Something already broken can be resolved on the spot for FLOPS, scaled by
+ how much of it is left. That price is always higher than the supply that
+ would have prevented it. Coming back to a running incident should never
+ leave you a spectator, but it should never be the cheap path either.
+
+- **Cold Storage never fails.** No incident touches it — not blocks, not jobs,
+ not tapes, not the tape tree. It is the one lane that always pays, and a
+ real reason to invest before a long absence.
+
+- **The Grid takes scheduled maintenance.** Unlike incidents, a maintenance
+ window is announced well ahead and shown with a countdown, so you can route
+ around it. Downtime you can plan for is a decision; downtime you cannot is
+ indistinguishable from the game being broken.
+
+- **The Overclock Bay no longer produces FLOPS. It multiplies your Racks.**
+ This is a change to how an existing lane works, so read it carefully: the
+ nodes you own now contribute a multiplier to Racks output instead of
+ generating output of their own. At the shipped balance the conversion is
+ **exactly neutral** — a save's total output is the same the moment it
+ deploys — but the lane now scales with your racks rather than beside them.
+
+ Overheating changed to match. Instead of freezing the Overclock lane, it now
+ knocks **one rack tier offline** for a few minutes. Running hot risks the
+ very thing it amplifies, and the punishment is self-limiting. Nothing is
+ ever destroyed, and no nodes are lost.
+
+- **All of it is switchable from the Balancing tab**, including a master kill
+ switch. Turning the system off is a true kill, not a pause: any incident
+ already running is cleared on the next reconcile, so nobody is left
+ throttled by a system that no longer exists. The config schema grew a proper
+ boolean type to make that possible — a 0/1 "boolean" is exactly the kind of
+ thing that later gets set to 2.
+```
+
+- [ ] **Step 4: Bump the version**
+
+`package.json`:
+
+```json
+ "version": "1.11.0",
+```
+
+`Dockerfile` (line 47):
+
+```
+LABEL org.opencontainers.image.version="1.11.0"
+```
+
+**Not** `client/package.json` — `client/vite.config.js` reads the root as the
+single authority.
+
+- [ ] **Step 5: Full verification, both backends**
+
+```bash
+systemctl --user start podman.socket
+export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
+export TESTCONTAINERS_RYUK_DISABLED=true
+npm run test:all
+npm run smoke
+cd client && npm run build && cd ..
+```
+
+Expected: SQLite suite passes, Postgres suite passes, every smoke suite passes,
+client builds. Do not proceed on a partial pass — report exactly which of the
+four failed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add tests/e2e/smoke-v111.mjs CHANGELOG.md package.json Dockerfile
+git commit -m "v1.11.0: smoke suite, changelog, version bump"
+```
+
+- [ ] **Step 7: Release ritual**
+
+Open the PR from `v1.11-risk-reliability`. After a whole-branch review and
+**merge**:
+
+```bash
+git checkout main && git pull
+git tag v1.11.0 # tag MAIN, never the branch
+git push origin v1.11.0
+```
+
+The tag push is what triggers the GHCR publish. A merge alone publishes
+nothing, and a tag without the leading `v` misses the workflow's `v*.*.*` glob
+and silently publishes nothing either.
+
+---
+
+## Spec coverage
+
+| Spec section | Task |
+|---|---|
+| §2 decision 1 (output only, never destroys) | 5 (supplies exception), 9 (property test) |
+| §2 decision 2 (prepaid + premium cure) | 5, 6 |
+| §2 decision 3 (maintenance telegraphed, hazards not) | 4 (rate only), 7, 10 |
+| §2 decision 4 (Overclock → Racks multiplier; overheat downs a tier) | 8 |
+| §2 decision 5 (offline cap samples the whole absence) | 3 |
+| §2 decision 6 (Cold Storage safe harbour) | 2 (`OUTAGE_LANES`), 3, 11 |
+| §2 decision 7 (admin-toggleable, kill switch) | 1, 9 |
+| §3 the outage object; `server.outages` | 2, 3 |
+| §4 the integral; `shared/outages.js`; the offline cap | 2, 3 |
+| §5 determinism, derivation, firing, the bound | 4 |
+| §6 the three hazards, stockpiles, cure, standing rate | 4, 5, 6, 10 |
+| §7 Grid maintenance; the Overclock rework | 7, 8 |
+| §8 boolean tunables, the toggles, AND-ing, kill switch | 1, 9 |
+| §9 status strip, maintenance window, supplies panel, notices, Racks panel | 10 |
+| §10 testing (integral, determinism, bound, absorption, property, kill switch, Cold Storage, Overclock conversion) | 2, 3, 4, 5, 8, 9, 11 |
+| §11 out of scope | nothing in this plan touches F, G, H, I or L |
+| §12 tour obligation, both backends, version authority, release ritual | 10, 11 |
+
+## Notes for the implementer
+
+- **The Overclock rework (Task 8) is separable.** If the release runs long it
+ can ship on its own afterwards — the outage model does not depend on it.
+ Nothing else here is separable; hazards without mitigation is just a tax.
+- **Verify every signature before using a snippet.** The table at the top of
+ this plan was checked against this branch, but the code moves. The v1.10
+ plan's snippets were wrong four times and the implementers caught all four —
+ do that again.
+- **If a decision in spec §2 looks wrong while you are implementing it, raise
+ it.** Do not quietly do something else.
diff --git a/docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md b/docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md
new file mode 100644
index 0000000..562a4ca
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md
@@ -0,0 +1,467 @@
+# v1.11 Risk & Reliability — design
+
+**Status: APPROVED, NOT PLANNED.** Brainstormed and approved by the owner on
+2026-08-08. The next step is `superpowers:writing-plans` to turn this into
+`docs/superpowers/plans/YYYY-MM-DD-v1.11-risk-reliability.md`, then
+subagent-driven implementation, then a whole-branch review.
+
+**Branch:** `v1.11-risk-reliability`, cut from `c7af6ee` (the v1.10.0 merge).
+
+Covers backlog items **B** (risk and mitigation), **C** (dynamic Grid) and
+**D** (Overclock rework) from
+`docs/superpowers/specs/2026-08-08-post-v1.9-idea-backlog.md` — which lives on
+the unmerged `v2-idea-backlog` branch, so read it with
+`git show origin/v2-idea-backlog:docs/superpowers/specs/2026-08-08-post-v1.9-idea-backlog.md`.
+
+The backlog groups B, C and D as one release because they "share scheduling
+machinery, a 'capacity offline' concept and a notification surface." That
+turned out to understate it: under the decisions below they are not three
+systems sharing machinery, they are **one system with three sources**.
+
+---
+
+## 1. Goal
+
+Give the game a second axis. Today everything is monotonic growth — the only
+question is how fast. This adds loss to defend against, and a reason to spend
+credits on something other than the next multiplier.
+
+---
+
+## 2. Decisions already made
+
+These were settled during brainstorming and are **not open for
+re-litigation during planning or implementation**. Where an implementer
+disagrees, raise it rather than quietly doing something else.
+
+| # | Decision | Why |
+|---|---|---|
+| 1 | **Hazards reduce output only. They never destroy racks, credits, tapes or upgrades.** | Cannot create a dead save, needs no repair-affordability floor, and makes the evaluation math exact rather than approximate. In an idle game, lost time is the real currency. |
+| 2 | **Mitigation is prepaid consumables, plus a reactive cure at a premium.** | Stockpiles reward planning and create a recurring sink. The cure means a returning player is never merely a spectator — but it is priced strictly worse than preparing, and only applies to a hazard still running. |
+| 3 | **Grid maintenance is telegraphed; hazards are not.** | Downtime you can route around is planning; downtime you cannot is indistinguishable from the game being broken. Hazards stay unannounced or the prepaid economy collapses — instead the player sees a standing risk rate. |
+| 4 | **Overclock converts fully to a Racks multiplier. Overheating knocks one rack tier offline.** | Makes heat a genuine risk dial: push harder and you multiply your main lane; lose the bet and you lose part of it. |
+| 5 | **The offline cap samples the whole absence proportionally.** | A hazard covering 2 of 12 absent hours degrades 2/12ths of the capped payout. Keeps hazards meaningful for long absences instead of letting most of them land in unpaid time. |
+| 6 | **Cold Storage is a safe harbour. Hazards never touch it.** | Gives the lane an identity beyond "the offline one" — it becomes the thing that never fails, and a real reason to invest before a long absence. Also keeps the blast radius inside the three active lanes. |
+| 7 | **Everything is admin-toggleable, including a master kill switch.** | This is a live game with real players. If hazards feel bad, the owner turns them off from the Balancing tab without a deploy. |
+
+---
+
+## 3. The core model: an outage
+
+Every effect in this release is one object:
+
+```js
+{
+ id, // stable string, derived (see §5) - NOT random
+ kind, // 'ransomware' | 'ispOutage' | 'driveFailure' | 'maintenance' | 'overheat'
+ scope, // { lane: 'grid' } (whole lane) | { lane: 'tiers', index: 2 } (one index) | { lane: '*' }
+ factor, // output multiplier while active. 0 = fully offline, 0.5 = halved
+ startAt, // ms epoch
+ endAt, // ms epoch
+ source, // 'hazard' | 'scheduled' | 'overheat' - drives how the UI narrates it
+}
+```
+
+*This part of your infrastructure runs at `factor` between `startAt` and
+`endAt`.* That single shape covers all three backlog items:
+
+| Source | kind | scope | factor |
+|---|---|---|---|
+| Ransomware | `ransomware` | `{ lane: '*' }` | 0.5 |
+| ISP outage | `ispOutage` | `{ lane: 'grid' }` | 0 |
+| Drive failure | `driveFailure` | `{ lane: 'tiers', index: n }` | 0 |
+| Term-break / evening dropoff | `maintenance` | `{ lane: 'grid', index: n }` | 0 |
+| Overheat penalty | `overheat` | `{ lane: 'tiers', index: n }` | 0 |
+
+The backlog asked for "a shared notion of capacity currently offline so the UI
+can explain a slowdown with one coherent story instead of two competing ones."
+`state.server.outages` **is** that notion — not a concept layered over two
+systems, but the only representation either system has. There is no separate
+hazard list and maintenance list to reconcile.
+
+**Live outages live in `server.outages`** (an array). Expired entries are
+pruned during evaluation. `server` is the right home: it is already where
+`nextAnomalyAt`, `boost` and `gameCooldowns` live, it survives Migrate and
+Singularity, and `hardReset` clears it wholesale.
+
+---
+
+## 4. Evaluation: one integral, no sub-stepping
+
+`evaluate()` in `shared/state.js` today computes the entire elapsed window in a
+single multiplication per lane (`rate * elapsedSec`). It must stay that way.
+
+Within one evaluation window there are **no player actions** — the window is by
+definition the gap between two requests. So the only thing that varies across
+it is which outages are active, and every outage is a constant factor over an
+interval. That makes production a piecewise-constant integral, which has a
+closed form:
+
+```
+effectiveFactor(lane, index, from, to) =
+ (1 / (to - from)) * Σ over sub-intervals of (subLength * productOfActiveFactors)
+```
+
+Concretely: collect every outage boundary inside `[from, to]`, sort them, and
+for each resulting sub-interval multiply together the factors of the outages
+covering it. The lane's production for the window is
+`rate * elapsedSec * effectiveFactor`. **Exact, not approximate**, and it does
+not require stepping the simulation.
+
+Overlapping outages **multiply**. Ransomware (0.5 on everything) during an ISP
+outage (0 on the Grid) leaves the Grid at 0 and the other lanes at 0.5.
+
+### Where this goes
+
+A new module, `shared/outages.js`, owning:
+
+- `activeAt(outages, at)` — those covering an instant.
+- `effectiveFactor(outages, scope, from, to)` — the integral above.
+- `pruneExpired(outages, now)`.
+- `scheduleNextHazard(server, config, now)` and the derivation helpers in §5.
+
+`shared/` must not import from `client/` and must stay free of runtime
+dependencies. `evaluate()` calls into this module; it does not grow the logic
+itself. `shared/state.js` is already long, and this is the natural seam.
+
+### The offline cap (decision 5)
+
+The offline branch of `evaluate()` credits `cappedSec = min(elapsedSec,
+capHours * 3600)`. With outages, the factor is computed over the **whole**
+absence `[lastEvaluatedAt, now]` and then applied to the capped payout:
+
+```js
+const factor = effectiveFactor(outages, scope, lastEvaluatedAt, now);
+const produced = tierRate(...) * cappedSec * factor;
+```
+
+So an incident covering 2 of 12 absent hours costs you 2/12ths of what you were
+credited, regardless of the cap. The capped window is a representative sample
+of the absence, not its first N hours.
+
+**This is a deliberate, slightly odd rule and it must be commented as such at
+the call site**, or a future reader will "fix" it into the literal
+first-N-hours reading, which the owner explicitly rejected: at roughly one
+incident per six hours, most incidents would land in unpaid time and cost
+nothing, quietly gutting the system for the players it should reach most.
+
+---
+
+## 5. Determinism: derived, never rolled
+
+**The client runs `evaluate()` optimistically against the same shared code the
+server runs.** If hazards were rolled with `Math.random()` at evaluation time,
+client and server would disagree about what happened while the player was away,
+and every reconcile would snap the display. The existing `claimAnomaly` sidesteps
+this by having the client wait for the authoritative reward — evaluation cannot,
+because it happens on both sides constantly.
+
+So: **a hazard's identity, target and duration are derived from its scheduled
+timestamp**, not rolled.
+
+```js
+// A small, pure, well-distributed integer hash. The scheduled time is the only
+// input, so both sides derive the same incident without communicating.
+function hazardFrom(scheduledAt, config, state) { ... }
+```
+
+The same requirement applies to the overheat victim: **which rack tier goes
+offline is derived from the overheat's timestamp**, exactly as the backlog
+demands ("must be derivable, not rolled fresh on each evaluation, or two
+clients reconciling the same overheat could disagree about which rack died").
+
+`scheduleNextHazard` still uses an **injected** `rng` (defaulting to
+`Math.random`) to pick the *next* scheduled time, matching `scheduleAnomaly`'s
+existing signature and testability. The distinction that matters:
+
+- **When** the next hazard happens — injected rng, decided once, stored. Both
+ sides then read the stored timestamp.
+- **What** that hazard is — derived from the stored timestamp. Never stored
+ redundantly, never rolled.
+
+### Firing
+
+Hazards fire unattended inside `evaluate()`:
+
+```
+while (server.nextHazardAt <= now && fired < MAX_HAZARDS_PER_EVALUATION) {
+ derive the hazard from server.nextHazardAt
+ if a matching supply is stocked -> consume one, record an "absorbed" notice
+ else -> push an outage
+ scheduleNextHazard(server, config, server.nextHazardAt) // from the fire time, not `now`
+ fired++
+}
+```
+
+Two details that are easy to get wrong:
+
+- **Schedule the next one from the fire time, not from `now`** — otherwise a
+ long absence produces exactly one hazard however long it was.
+- **`MAX_HAZARDS_PER_EVALUATION` is a required bound**, not a nicety. A save
+ whose `nextHazardAt` is far in the past (clock change, restored backup, a
+ hand-edited save) must not spin. On hitting the bound, jump `nextHazardAt`
+ forward to a fresh schedule from `now` and move on.
+
+An anomaly is an *opportunity the player claims* and never fires on its own; a
+hazard fires unattended. Same scheduling shape, different lifecycle — do not
+assume `scheduleAnomaly`'s call sites are the right ones to copy.
+
+---
+
+## 6. Hazards and mitigation
+
+### The three hazards
+
+| Hazard | Effect | Countered by |
+|---|---|---|
+| Ransomware | All lanes at 0.5 | Antivirus licence |
+| ISP outage | Grid at 0 | Backup ISP line |
+| Drive failure | One rack tier at 0 | Spare drive |
+
+Durations and severities are config-driven (§8) — the numbers above are the
+shape, not the balance. Balance is the plan's job, with one hard rule from
+decision 1: **no hazard may ever reduce a stored value.** Hazards multiply
+production; they never subtract from `credits`, `wafers`, `tapes` or `owned`.
+
+### Stockpiles (prepaid)
+
+```js
+meta.supplies = { antivirus: 0, backupIsp: 0, spareDrives: 0 }
+```
+
+**Bought with credits, stored in `meta`.** That combination is deliberate:
+credits are the run currency, so this is a sink for the thing players have most
+of, and `meta` survives Migrate — which gives a player a genuine reason to spend
+down before prestiging instead of watching the balance evaporate. They are wiped
+by `hardReset` along with everything else.
+
+Absorption happens **at fire time**, inside evaluation, which means it works
+while the player is offline. That is the whole point: a hazard that fires during
+a 9-hour absence is over before any reactive option exists, so the stockpile is
+the *only* defence that can reach it.
+
+**A silent save is a wasted save.** An absorbed hazard must produce a visible
+notification — "Ransomware absorbed. 2 antivirus licences left." The backlog is
+blunt about this and it is a requirement, not polish: the moment a hedge pays
+off is the only time the player learns hedging was worth it. Absorbed hazards
+are therefore recorded as one-shot notices for the client, not silently dropped.
+
+### The reactive cure
+
+A `resolveOutage` action ends a **currently running** hazard early, for credits.
+Constraints:
+
+- Priced strictly worse than the stockpile that would have prevented it.
+ Concretely: cost scales with remaining duration, and its floor is above the
+ supply price. If curing is ever cheaper than preparing, the prepaid economy is
+ dead and decision 2 has been violated.
+- Only valid while `now < endAt`. A hazard that already ended is not curable —
+ no retroactive refunds.
+- Cannot cure `maintenance` (it is scheduled and telegraphed, not misfortune)
+ or `overheat` (that is the player's own doing — see §7).
+
+### The standing risk rate
+
+Because hazards are not telegraphed (decision 3), the player must still be able
+to make an informed stocking decision. The UI shows the *rate*, derived from
+config — "~1 incident per 6h" — never the next scheduled time. Showing
+`nextHazardAt` would convert the whole prepaid economy into buying one licence
+twenty minutes before it fires.
+
+---
+
+## 7. The Grid, and the Overclock rework
+
+### Grid maintenance (item C)
+
+Scheduled ahead and **visible**: `server.gridMaintenance` holds the upcoming
+window (index, `startAt`, `endAt`), scheduled far enough out that the player can
+see it coming and route around it. When it starts it is simply an outage with
+`source: 'scheduled'` — no fire-time derivation needed, because every parameter
+was fixed when it was scheduled.
+
+Thematic hooks the backlog suggests and this design supports for free, since
+they are only different scheduling rules over the same outage object: university
+clusters idling on a term schedule, home volunteers dropping off in the evening.
+The plan should pick **one** shape to ship and leave the rest as config.
+
+### Overclock (item D)
+
+Overclock nodes **stop producing directly**. Instead the lane contributes a
+multiplier to total Racks output, computed in `computeMults` alongside the
+existing multipliers. `OVERCLOCK_DEFS[].baseProd` becomes a boost contribution
+rather than a production rate.
+
+This is the only **breaking gameplay change** in the release:
+
+- Existing saves have real investment in the lane, and their income changes
+ shape on the deploy. It needs a balance pass so a mid-game save is not
+ suddenly poorer, and a changelog entry that says plainly what changed.
+- `goalCtx` in `shared/goals.js` computes `overclockOutput` and folds it into
+ `totalOutputPerSec`. Every goal, contract and achievement reading that number
+ is affected. **This is the highest-risk edit in the release** — the existing
+ goal and achievement suites are the regression net, and they must pass
+ untouched.
+
+**Overheating** now: heat resets to 0 as it does today, and one rack tier goes
+offline for a window — an outage with `source: 'overheat'`, its victim derived
+from the overheat timestamp (§5). This **replaces** the current
+"freeze the Overclock lane on a cooldown" penalty rather than stacking with it.
+The penalty moves from the overclock lane to the racks lane, which is coherent
+now that overclock multiplies racks: running hot risks the very thing it
+amplifies, and the punishment is self-limiting.
+
+v1.6 deliberately reworked the heat UX (percentage venting, auto-dismissing
+overheat popup). **Re-read that work before changing what overheating means** —
+`config.heat.ventPercent`, `overheatCooldownMs` and `overheatPopupMs` are all
+recent and intentional. `server.overheated` is an existing one-shot client
+signal and is the right precedent for the notice mechanism in §9.
+
+---
+
+## 8. Admin toggles
+
+The Balancing tab is `TUNABLES`-driven, so anything added to that array gets UI
+for free. But **`validateConfig` currently requires every tunable to be a
+number** — there is no boolean, and `upgradeConfig` only copies numbers. So the
+config system needs a small, contained extension first:
+
+1. Tunable descriptors gain `type: 'boolean'` (existing entries stay numeric by
+ default, so nothing else changes).
+2. `validateConfig` accepts a boolean for those paths and rejects one anywhere
+ else; `upgradeConfig` copies booleans through.
+3. `AdminBalancing.jsx` renders a checkbox for boolean descriptors.
+
+That is worth doing properly rather than encoding toggles as 0/1 numbers: it
+makes every future toggle free, and a 0/1 "boolean" is exactly the kind of thing
+that later gets set to 2.
+
+### The toggles
+
+| Path | Effect |
+|---|---|
+| `risk.enabled` | **Master kill switch.** Off: no hazards fire, no maintenance, no overheat shutdown, and any live outages are cleared on the next evaluation. |
+| `risk.hazardsEnabled` | Hazards only. |
+| `risk.maintenanceEnabled` | Grid maintenance only. |
+| `risk.overheatShutdownEnabled` | Off: overheating reverts to today's Overclock-lane freeze. |
+| `risk.ransomwareEnabled` / `ispOutageEnabled` / `driveFailureEnabled` | Per-hazard. A disabled kind is never derived. |
+
+Plus numeric tunables for rate, duration and severity per hazard, the supply
+prices, the cure's price multiplier, and the maintenance cadence.
+
+The toggles **AND together, master first**: a source runs only when
+`risk.enabled` is on *and* its own switch is on. `risk.enabled` off means the
+whole system is inert regardless of every other value, so the owner can kill it
+in one click without auditing six other switches.
+
+**The master switch must be a true kill switch**, not merely a pause: turning it
+off has to clear live outages, or a player who was mid-ransomware when the owner
+disabled the system stays throttled forever with nothing in the UI to explain
+it. Killing the system must visibly un-break every affected save on the next
+evaluation.
+
+Config already flows through the live-event overlay
+(`getEffectiveConfig`), so an event can legitimately turn the risk system up for
+a themed week. Worth knowing; not a goal of this release.
+
+---
+
+## 9. Client surfaces
+
+- **A status strip** wherever a lane is degraded, reading from `server.outages`
+ — one coherent story: "Grid: University Cluster offline · maintenance · 12m
+ left", "All lanes at 50% · ransomware · 1h 40m left".
+- **The upcoming maintenance window**, visible before it starts (decision 3).
+- **A supplies panel**: current stock, price, buy. Plus the standing risk rate.
+- **Notices**, following the existing `server.overheated` precedent — a one-shot
+ signal set by the evaluation that produced it and cleared on the next one.
+ Needed for: a hazard starting, a hazard being absorbed by a stockpile (§6 —
+ mandatory), and an overheat shutdown. Do **not** add a new notification
+ system; `RackStack.jsx` already has both a toast and a modal path, and the
+ v1.10 rule applies — rewards use the modal, rejections use the toast.
+- **The Racks panel** must show a tier that is offline as offline, with a
+ reason. A tier silently producing nothing reads as a bug.
+
+---
+
+## 10. Testing
+
+Both backends must pass (`npm run test:all`), plus a new
+`tests/e2e/smoke-v111.mjs` matching the `tests/e2e/smoke-v1*.mjs` glob.
+
+The tests that actually matter here:
+
+- **The integral.** Overlapping outages multiply; an outage entirely outside the
+ window contributes nothing; one straddling either edge contributes exactly its
+ overlap; zero outages leaves production bit-identical to today.
+- **Determinism.** The same `(server, config, window)` derives the same hazards
+ twice — this is the client/server agreement guarantee and it deserves an
+ explicit test, not incidental coverage.
+- **The bound.** A `nextHazardAt` far in the past terminates and reschedules
+ rather than spinning.
+- **Absorption.** Consumes exactly one supply, produces a notice, and applies no
+ output penalty. Absorbing with an empty stockpile is not possible.
+- **Decision 1 as a property:** across a large randomised sweep of hazards, no
+ stored value (`credits`, `wafers`, `tapes`, any `owned`) ever decreases.
+ This is the guardrail that keeps a later "small" change from reintroducing
+ asset loss.
+- **The kill switch** clears live outages and restores full production.
+- **Cold Storage is untouched** by any hazard — job accrual, tapes and upgrades
+ identical with and without an active incident.
+- **The Overclock conversion**: the existing goals, contracts and achievements
+ suites must pass. `goalCtx.totalOutputPerSec` changing shape is the risk.
+
+---
+
+## 11. Out of scope
+
+Named so the plan does not quietly absorb them: the third prestige (F), unique
+placeable items (G), the event effect registry (H), the shard store and themes
+(I), and multi-provider identity linking (L). All remain in the backlog.
+
+Also out of scope: hazards touching Cold Storage (decision 6), any hazard that
+destroys or subtracts a stored value (decision 1), and telegraphing hazards
+(decision 3).
+
+---
+
+## 12. Standing obligations
+
+- **If this ships a feature tour, its steps must also be appended to
+ `client/src/game/data/tours/onboarding.js`.** Completing the onboarding tour
+ marks every registered tour complete, which is only correct while onboarding
+ remains a superset. No test catches a violation.
+- Both backends pass. Postgres needs a container runtime; this machine has
+ podman, not docker:
+ ```bash
+ systemctl --user start podman.socket
+ export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
+ export TESTCONTAINERS_RYUK_DISABLED=true
+ ```
+- Version bump goes in `package.json` and the `Dockerfile` LABEL. **Not**
+ `client/package.json` — `client/vite.config.js` reads the root as the single
+ authority.
+- Release ritual: merge the PR, then tag **`main`** (never the branch) as
+ `v1.11.0` and push the tag. The tag push is what triggers the GHCR publish; a
+ merge alone publishes nothing, and a tag without the leading `v` misses the
+ workflow's `v*.*.*` glob and silently publishes nothing either.
+
+---
+
+## 13. Notes for whoever plans this
+
+- **Verify every signature against the code before using a snippet.** The v1.10
+ plan's snippets were wrong four times and implementers caught all four. Known
+ traps in this repo: the racks lane is `tiers`, not `racks`;
+ `createMinigameSession(userId, game)` is 2-arg; and **`requireAuth` populates
+ `req.user.sub`, NOT `req.user.id`**.
+- Suggested task order, since it front-loads the risk: the config boolean type
+ → `shared/outages.js` and its integral (pure, fully testable alone) →
+ evaluation wiring with no sources yet (proves zero outages changes nothing) →
+ hazards and scheduling → supplies and absorption → the cure → maintenance →
+ the Overclock rework (the breaking one, deliberately late, with the goals
+ suite as its net) → admin toggles → client surfaces → smoke, changelog,
+ release.
+- The Overclock rework is separable. If the release runs long, it can ship on
+ its own afterwards — the outage model does not depend on it. Nothing else here
+ is separable; hazards without mitigation is just a tax.
diff --git a/package.json b/package.json
index e3b9ca1..390dc97 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
- "version": "1.10.0",
+ "version": "1.11.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/server/stateService.js b/server/stateService.js
index a657a59..27471ed 100644
--- a/server/stateService.js
+++ b/server/stateService.js
@@ -1,5 +1,6 @@
import { migrateSave, evaluate } from '../shared/state.js';
import { applyAction, scheduleAnomaly } from '../shared/reducer.js';
+import { scheduleNextHazard, scheduleGridMaintenance } from '../shared/outages.js';
import {
getSave, putSave, updateParticipationProgress,
} from './db.js';
@@ -73,6 +74,26 @@ export async function loadEvaluateAndSchedule(userId, now) {
scheduleAnomaly(state.server, config, now, Math.random);
}
+ // v1.11: same precedent as scheduleAnomaly directly above - evaluate() fires
+ // hazards, but a save that has never had one scheduled (fresh, or written
+ // before v1.11) needs its first `nextHazardAt` seeded from the SERVER's rng,
+ // once. From then on both sides read the stored timestamp and DERIVE what
+ // that hazard is, which is what keeps the client's optimistic evaluate()
+ // agreeing with the server about what happened during an absence.
+ if (!(state.server.nextHazardAt > 0)) {
+ scheduleNextHazard(state.server, config, now, Math.random);
+ }
+
+ // v1.11: maintenance is TELEGRAPHED, so unlike a hazard it is scheduled here
+ // (server rng, stored, then read by both sides) rather than inside
+ // evaluate() - the client must draw the same countdown the server will
+ // honour, or it would jump on every reconcile. evaluate() clears the slot
+ // when it activates the window, which is what makes this both the seed and
+ // the "schedule the next one" path.
+ if (!state.server.gridMaintenance) {
+ scheduleGridMaintenance(state.server, config, now, Math.random);
+ }
+
// Join-on-login (spec §5.3): if a live event is active and this user
// hasn't joined it yet, snapshot their baselines and start their personal
// window; if their in-flight progress belongs to a now-superseded event,
diff --git a/shared/configSchema.js b/shared/configSchema.js
index 69fd408..06f3b9a 100644
--- a/shared/configSchema.js
+++ b/shared/configSchema.js
@@ -56,6 +56,56 @@ export const DEFAULT_CONFIG = {
leaderboardCacheMs: 60000,
leaderboardLimit: 50,
},
+ // v1.11 Risk & Reliability. Every effect in the release is an "outage"
+ // (shared/outages.js); these are its dials. The seven booleans AND together
+ // with `enabled` first, so the owner can kill the whole system in one click
+ // without auditing the rest - see shared/outages.js's riskOn().
+ risk: {
+ enabled: true,
+ hazardsEnabled: true,
+ maintenanceEnabled: true,
+ overheatShutdownEnabled: true,
+ ransomwareEnabled: true,
+ ispOutageEnabled: true,
+ driveFailureEnabled: true,
+
+ // ~1 incident per 6h on average. The player is shown this RATE, derived
+ // from these two numbers - never server.nextHazardAt (spec decision 3).
+ hazardMinDelayMs: 14400000, // 4h
+ hazardMaxDelayMs: 28800000, // 8h
+
+ ransomwareFactor: 0.5,
+ ransomwareDurationMs: 1800000, // 30m, all lanes at half
+ ispOutageFactor: 0,
+ ispOutageDurationMs: 900000, // 15m, Grid dark
+ driveFailureFactor: 0,
+ driveFailureDurationMs: 1200000, // 20m, one rack tier dark
+
+ // Supply prices are expressed in SECONDS OF CURRENT OUTPUT, the same
+ // idiom as social.contractFlopsSeconds and batchQueue.blockFlopsSeconds,
+ // so a sink priced today still bites at 1e12 FLOPS/s. supplyPriceMin is
+ // the floor for a fresh save whose output is ~0.
+ antivirusPriceSeconds: 900,
+ backupIspPriceSeconds: 600,
+ spareDrivesPriceSeconds: 750,
+ supplyPriceMin: 500,
+
+ // The reactive cure is priced strictly worse than preparing (decision 2):
+ // cost = supplyPrice * cureMultiplier * (1 + remaining/total), so its
+ // FLOOR is cureMultiplier times the supply it should have been.
+ cureMultiplier: 2.5,
+
+ maintenanceMinDelayMs: 43200000, // 12h
+ maintenanceMaxDelayMs: 86400000, // 24h
+ maintenanceDurationMs: 1800000, // 30m
+
+ overheatOutageMs: 600000, // 10m of one rack tier offline
+
+ // Overclock's conversion factor (spec §7). At 1 the lane contributes
+ // exactly the output it used to produce directly, so a mid-game save's
+ // total output is unchanged on the deploy - see shared/gameRules.js.
+ overclockBoostGain: 1,
+ },
};
export const TUNABLES = [
@@ -168,6 +218,36 @@ export const TUNABLES = [
{ path: 'social.streakDay7Tapes', label: 'Streak final-day tape reward', min: 0, max: 10000, integer: true },
{ path: 'social.leaderboardCacheMs', label: 'Leaderboard cache TTL (ms)', min: 0, max: 3600000, integer: true },
{ path: 'social.leaderboardLimit', label: 'Leaderboard rows per board', min: 1, max: 500, integer: true },
+
+ // v1.11 Risk & Reliability. `type: 'boolean'` rows carry no min/max - the
+ // type is the range. Encoding these as 0/1 numbers was explicitly rejected:
+ // a 0/1 "boolean" is exactly the kind of thing that later gets set to 2.
+ { path: 'risk.enabled', label: 'Risk system enabled (master)', type: 'boolean' },
+ { path: 'risk.hazardsEnabled', label: 'Hazards enabled', type: 'boolean' },
+ { path: 'risk.maintenanceEnabled', label: 'Grid maintenance enabled', type: 'boolean' },
+ { path: 'risk.overheatShutdownEnabled', label: 'Overheat knocks a rack offline', type: 'boolean' },
+ { path: 'risk.ransomwareEnabled', label: 'Hazard enabled: Ransomware', type: 'boolean' },
+ { path: 'risk.ispOutageEnabled', label: 'Hazard enabled: ISP outage', type: 'boolean' },
+ { path: 'risk.driveFailureEnabled', label: 'Hazard enabled: Drive failure', type: 'boolean' },
+
+ { path: 'risk.hazardMinDelayMs', label: 'Hazard min delay (ms)', min: 60000, max: 604800000, integer: true },
+ { path: 'risk.hazardMaxDelayMs', label: 'Hazard max delay (ms)', min: 60000, max: 604800000, integer: true },
+ { path: 'risk.ransomwareFactor', label: 'Ransomware output factor', min: 0, max: 1, integer: false },
+ { path: 'risk.ransomwareDurationMs', label: 'Ransomware duration (ms)', min: 1000, max: 86400000, integer: true },
+ { path: 'risk.ispOutageFactor', label: 'ISP outage output factor', min: 0, max: 1, integer: false },
+ { path: 'risk.ispOutageDurationMs', label: 'ISP outage duration (ms)', min: 1000, max: 86400000, integer: true },
+ { path: 'risk.driveFailureFactor', label: 'Drive failure output factor', min: 0, max: 1, integer: false },
+ { path: 'risk.driveFailureDurationMs', label: 'Drive failure duration (ms)', min: 1000, max: 86400000, integer: true },
+ { path: 'risk.antivirusPriceSeconds', label: 'Antivirus price (seconds of output)', min: 0, max: 86400, integer: true },
+ { path: 'risk.backupIspPriceSeconds', label: 'Backup ISP price (seconds of output)', min: 0, max: 86400, integer: true },
+ { path: 'risk.spareDrivesPriceSeconds', label: 'Spare drive price (seconds of output)', min: 0, max: 86400, integer: true },
+ { path: 'risk.supplyPriceMin', label: 'Supply price floor (FLOPS)', min: 0, max: 1e12, integer: false },
+ { path: 'risk.cureMultiplier', label: 'Cure price multiplier', min: 1, max: 100, integer: false },
+ { path: 'risk.maintenanceMinDelayMs', label: 'Maintenance min delay (ms)', min: 60000, max: 604800000, integer: true },
+ { path: 'risk.maintenanceMaxDelayMs', label: 'Maintenance max delay (ms)', min: 60000, max: 604800000, integer: true },
+ { path: 'risk.maintenanceDurationMs', label: 'Maintenance duration (ms)', min: 1000, max: 86400000, integer: true },
+ { path: 'risk.overheatOutageMs', label: 'Overheat rack shutdown (ms)', min: 1000, max: 86400000, integer: true },
+ { path: 'risk.overclockBoostGain', label: 'Overclock boost gain', min: 0, max: 100, integer: false },
];
export function getAtPath(obj, path) {
@@ -200,6 +280,13 @@ export function validateConfig(doc) {
}
for (const t of TUNABLES) {
const v = getAtPath(doc, t.path);
+ // v1.11: a boolean tunable accepts ONLY a boolean. Both directions are
+ // enforced - a number here, or a boolean on a numeric path below, is a
+ // rejection rather than a silent coercion.
+ if (t.type === 'boolean') {
+ if (typeof v !== 'boolean') errors.push(`${t.path}: missing or not a boolean`);
+ continue;
+ }
if (typeof v !== 'number' || Number.isNaN(v)) { errors.push(`${t.path}: missing or not a number`); continue; }
if (v < t.min || v > t.max) errors.push(`${t.path}: ${v} outside [${t.min}, ${t.max}]`);
if (t.integer && !Number.isInteger(v)) errors.push(`${t.path}: must be an integer`);
@@ -211,6 +298,10 @@ export function upgradeConfig(doc) {
const out = structuredClone(DEFAULT_CONFIG);
for (const t of TUNABLES) {
const v = getAtPath(doc || {}, t.path);
+ if (t.type === 'boolean') {
+ if (typeof v === 'boolean') setAtPath(out, t.path, v);
+ continue;
+ }
if (typeof v === 'number' && !Number.isNaN(v)) setAtPath(out, t.path, v);
}
return out;
diff --git a/shared/events.js b/shared/events.js
index 91eb31c..717a8e0 100644
--- a/shared/events.js
+++ b/shared/events.js
@@ -73,6 +73,17 @@ export function validateModifiers(modifiers) {
errors.push(`unknown modifier path: ${path}`);
continue;
}
+ // v1.11: boolean tunables are admin-only. mergeEventModifiers would
+ // happily setAtPath a number onto a boolean path, and the merged document
+ // would then fail validateConfig below with a confusing "not a boolean" -
+ // reject it here, where the author can read it. An event may turn the risk
+ // system UP (its numeric dials are all overlayable); it may not flip its
+ // switches.
+ const tDef = TUNABLES.find((t) => t.path === path);
+ if (tDef && tDef.type === 'boolean') {
+ errors.push(`${path}: boolean tunables cannot be set by an event modifier`);
+ continue;
+ }
if (typeof value !== 'number' || Number.isNaN(value)) {
errors.push(`${path}: value must be a number`);
}
diff --git a/shared/gameRules.js b/shared/gameRules.js
index ab7dcfc..000ad9e 100644
--- a/shared/gameRules.js
+++ b/shared/gameRules.js
@@ -1,4 +1,4 @@
-import { GROWTH, MILESTONES } from './gameData.js';
+import { GROWTH, MILESTONES, OVERCLOCK_DEFS } from './gameData.js';
import { computeColdStorageEffects } from './coldStorage.js';
export function costAt(def, owned) {
@@ -103,6 +103,38 @@ export function computeMults(meta, config, boostMult = 1) {
};
}
+/**
+ * v1.11: the Racks-output multiplier contributed by the Overclock lane.
+ *
+ * Overclock nodes no longer produce FLOPS directly - OVERCLOCK_DEFS[].baseProd
+ * is now a BOOST CONTRIBUTION. The lane's would-be output is expressed as a
+ * fraction of the Racks lane's:
+ *
+ * boost = 1 + gain * overclockOutput / racksOutput
+ *
+ * At the default gain of 1 that is algebraically racksOutput +
+ * overclockOutput, so a mid-game save's total output is UNCHANGED across the
+ * deploy - which is what lets the existing goals/contracts/achievements suites
+ * pass untouched, and turns the balance pass into one tunable rather than a
+ * re-costing exercise. Raising risk.overclockBoostGain is how the lane becomes
+ * worth pushing.
+ *
+ * Returns exactly 1 when there is nothing to amplify (racksOutput <= 0) or
+ * nothing amplifying it, so an untouched save is unaffected.
+ */
+export function overclockBoost(run, config, overclockMult, thresholds, racksOutput) {
+ if (!(racksOutput > 0)) return 1;
+ const gain = config.risk.overclockBoostGain;
+ if (!(gain > 0)) return 1;
+ const ocOutput = run.overclock.reduce((sum, o, i) => {
+ const def = OVERCLOCK_DEFS[i];
+ if (!def || !o || o.owned === 0) return sum;
+ return sum + tierRate(o.owned, def.baseProd, overclockMult, thresholds);
+ }, 0);
+ if (ocOutput <= 0) return 1;
+ return 1 + gain * (ocOutput / racksOutput);
+}
+
export function migrateGain(lifetimeRun, legacyGainMult) {
return Math.floor(Math.sqrt(lifetimeRun / 1e6) * legacyGainMult);
}
diff --git a/shared/goals.js b/shared/goals.js
index 2d20845..08b2e17 100644
--- a/shared/goals.js
+++ b/shared/goals.js
@@ -1,4 +1,4 @@
-import { fmt, computeMults, tierRate } from './gameRules.js';
+import { fmt, computeMults, tierRate, overclockBoost } from './gameRules.js';
import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from './gameData.js';
export const GOAL_DEFS = [
@@ -49,11 +49,24 @@ export function goalCtx(state, config, now) {
const gridOutput = state.run.grid.reduce(
(sum, g, i) => sum + tierRate(g.owned, GRID_DEFS[i].baseProd, gridMult, thresholds), 0
);
+ // v1.11: the Overclock lane multiplies Racks rather than producing directly.
+ // At the default gain this is algebraically racks + overclock, so the number
+ // every goal, repeatable, contract, streak reward and achievement reads is
+ // unchanged across the deploy.
+ //
+ // The legacy heat-cooldown freeze (still reachable with
+ // risk.overheatShutdownEnabled off) zeroes the lane's contribution exactly
+ // as it zeroed its output before.
+ //
+ // Note this deliberately does NOT apply outages: goalCtx reports the
+ // player's INSTALLED capacity, which is what goals, contracts and supply
+ // prices should be measured against. A hazard must not make a contract
+ // easier or a supply cheaper.
const heatOnCooldown = !!state.run.heatCooldownUntil && now < state.run.heatCooldownUntil;
- const overclockOutput = heatOnCooldown ? 0 : state.run.overclock.reduce(
- (sum, o, i) => sum + tierRate(o.owned, OVERCLOCK_DEFS[i].baseProd, overclockMult, thresholds), 0
- );
- const totalOutputPerSec = racksOutput + gridOutput + overclockOutput;
+ const boost = heatOnCooldown
+ ? 1
+ : overclockBoost(state.run, config, overclockMult, thresholds, racksOutput);
+ const totalOutputPerSec = racksOutput * boost + gridOutput;
let unlockedUpTo = 0;
for (let i = 1; i < TIER_DEFS.length; i++) {
diff --git a/shared/outages.js b/shared/outages.js
new file mode 100644
index 0000000..af5a368
--- /dev/null
+++ b/shared/outages.js
@@ -0,0 +1,475 @@
+/**
+ * v1.11 Risk & Reliability - the outage model.
+ *
+ * Every effect in the release is one object:
+ *
+ * { id, kind, scope, factor, startAt, endAt, source }
+ *
+ * "This part of your infrastructure runs at `factor` between `startAt` and
+ * `endAt`." Hazards, scheduled Grid maintenance and the overheat shutdown are
+ * the same shape with different provenance - there is no separate hazard list
+ * and maintenance list to reconcile, which is what lets the UI tell one
+ * coherent story about a slowdown (spec §3).
+ *
+ * Zero runtime dependencies and no imports outside shared/ - this module is
+ * consumed identically by the server's authoritative evaluate() and the
+ * client's optimistic prediction, which is the whole reason it is pure.
+ */
+
+import { GRID_DEFS } from './gameData.js';
+
+/**
+ * Lanes an outage may cover. Cold Storage is deliberately ABSENT and must
+ * stay absent: it is the safe harbour (spec decision 6), the one lane that
+ * never fails, and the reason a player invests in it before a long absence.
+ * A `{ lane: '*' }` wildcard covers the three lanes listed here and nothing
+ * else - it is not "everything", it is "every ACTIVE lane".
+ */
+export const OUTAGE_LANES = ['tiers', 'grid', 'overclock'];
+
+export function scopeCovers(scope, lane, index) {
+ if (!scope || typeof scope !== 'object') return false;
+ if (!OUTAGE_LANES.includes(lane)) return false; // coldstorage, always
+ if (scope.lane === '*') return true;
+ if (scope.lane !== lane) return false;
+ if (scope.index === undefined || scope.index === null) return true;
+ return scope.index === index;
+}
+
+/** Outages covering an instant. Half-open: [startAt, endAt). */
+export function activeAt(outages, at) {
+ if (!Array.isArray(outages)) return [];
+ return outages.filter((o) => o && o.startAt <= at && at < o.endAt);
+}
+
+/** A new array with finished outages dropped. Never mutates its input. */
+export function pruneExpired(outages, now) {
+ if (!Array.isArray(outages)) return [];
+ return outages.filter((o) => o && o.endAt > now);
+}
+
+/**
+ * The average output multiplier for one lane index across [from, to).
+ *
+ * Within a single evaluation window there are NO player actions - the window
+ * is by definition the gap between two requests - so the only thing that
+ * varies across it is which outages are active, and every outage is a
+ * constant factor over an interval. Production is therefore a
+ * piecewise-constant integral with a closed form: collect every outage
+ * boundary inside the window, and for each resulting sub-interval multiply
+ * together the factors of the outages covering it.
+ *
+ * This is EXACT, not an approximation, and it does not require stepping the
+ * simulation - evaluate() stays one multiplication per lane (spec §4). Do not
+ * replace it with sampling; tests/outages.test.js cross-checks it against a
+ * brute-force integral precisely to pin that down.
+ *
+ * Overlapping outages MULTIPLY: ransomware (0.5 on everything) during an ISP
+ * outage (0 on the Grid) leaves the Grid at 0 and the other lanes at 0.5.
+ */
+export function effectiveFactor(outages, lane, index, from, to) {
+ const span = to - from;
+ if (!(span > 0)) return 1;
+ if (!Array.isArray(outages) || outages.length === 0) return 1;
+
+ const relevant = outages.filter(
+ (o) => o && scopeCovers(o.scope, lane, index) && o.endAt > from && o.startAt < to,
+ );
+ if (relevant.length === 0) return 1;
+
+ const bounds = new Set([from, to]);
+ for (const o of relevant) {
+ if (o.startAt > from && o.startAt < to) bounds.add(o.startAt);
+ if (o.endAt > from && o.endAt < to) bounds.add(o.endAt);
+ }
+ const points = [...bounds].sort((a, b) => a - b);
+
+ let weighted = 0;
+ for (let i = 0; i < points.length - 1; i++) {
+ const a = points[i];
+ const b = points[i + 1];
+ // Sample at the midpoint: every boundary is already a split point, so no
+ // outage can start or end strictly inside (a, b) and the midpoint's
+ // membership is the whole sub-interval's membership.
+ const mid = (a + b) / 2;
+ let f = 1;
+ for (const o of relevant) {
+ if (o.startAt <= mid && mid < o.endAt) f *= o.factor;
+ }
+ weighted += (b - a) * f;
+ }
+ return weighted / span;
+}
+
+/**
+ * The single most severe outage covering a lane index right now, or null.
+ * For UI copy only - never for math, which must use effectiveFactor's
+ * integral over the whole window rather than an instant.
+ */
+export function laneOutageFor(outages, lane, index, at) {
+ let worst = null;
+ for (const o of activeAt(outages, at)) {
+ if (!scopeCovers(o.scope, lane, index)) continue;
+ if (!worst || o.factor < worst.factor) worst = o;
+ }
+ return worst;
+}
+
+// ---------------------------------------------------------------------------
+// Hazards: derived, never rolled
+// ---------------------------------------------------------------------------
+
+/**
+ * A save whose nextHazardAt is far in the past - a clock change, a restored
+ * backup, a hand-edited save - must not spin the firing loop for hours of
+ * simulated time. This bound is a REQUIREMENT, not a nicety. On hitting it,
+ * fireDueHazards jumps nextHazardAt forward to a fresh schedule from `now`.
+ */
+export const MAX_HAZARDS_PER_EVALUATION = 8;
+
+export const HAZARD_KINDS = ['ransomware', 'ispOutage', 'driveFailure'];
+
+/** Which stockpile absorbs which hazard. */
+export const SUPPLY_FOR_KIND = {
+ ransomware: 'antivirus',
+ ispOutage: 'backupIsp',
+ driveFailure: 'spareDrives',
+};
+
+export const SUPPLY_IDS = ['antivirus', 'backupIsp', 'spareDrives'];
+
+const SUPPLY_PRICE_KEY = {
+ antivirus: 'antivirusPriceSeconds',
+ backupIsp: 'backupIspPriceSeconds',
+ spareDrives: 'spareDrivesPriceSeconds',
+};
+
+/**
+ * A supply's credit price, expressed as seconds of the player's current
+ * output with a flat floor - the same idiom as social.contractFlopsSeconds
+ * and batchQueue.blockFlopsSeconds. A flat price would be a meaningful sink
+ * for an hour and free forever after.
+ *
+ * `totalOutputPerSec` comes from goalCtx and is deliberately the UNDEGRADED
+ * rate: pricing off a degraded rate would make supplies cheapest exactly when
+ * an incident is running, which inverts the intended pressure.
+ */
+export function supplyPrice(supplyId, config, totalOutputPerSec) {
+ const key = SUPPLY_PRICE_KEY[supplyId];
+ if (!key) return Infinity;
+ const rate = typeof totalOutputPerSec === 'number' && totalOutputPerSec > 0 ? totalOutputPerSec : 0;
+ return Math.max(config.risk.supplyPriceMin, rate * config.risk[key]);
+}
+
+/**
+ * What it costs to end `outage` right now.
+ *
+ * cost = supplyPrice * cureMultiplier * (1 + remaining/total)
+ *
+ * The trailing factor is in (1, 2], so the cure's FLOOR is `cureMultiplier`
+ * times the supply that would have prevented it - strictly worse than
+ * preparing, at every remaining duration and every output rate (spec
+ * decision 2). If curing is ever cheaper than preparing, the prepaid economy
+ * is dead. tests/outages.test.js asserts that as a property across the whole
+ * space; if you change this formula, that test is the contract.
+ */
+export function cureCost(outage, config, totalOutputPerSec, now) {
+ const supply = SUPPLY_FOR_KIND[outage.kind];
+ if (!supply) return Infinity;
+ const total = outage.endAt - outage.startAt;
+ const remaining = Math.max(0, outage.endAt - now);
+ const share = total > 0 ? remaining / total : 0;
+ return supplyPrice(supply, config, totalOutputPerSec) * config.risk.cureMultiplier * (1 + share);
+}
+
+const HAZARD_SPECS = {
+ ransomware: { enabledKey: 'ransomwareEnabled', factorKey: 'ransomwareFactor', durationKey: 'ransomwareDurationMs' },
+ ispOutage: { enabledKey: 'ispOutageEnabled', factorKey: 'ispOutageFactor', durationKey: 'ispOutageDurationMs' },
+ driveFailure: { enabledKey: 'driveFailureEnabled', factorKey: 'driveFailureFactor', durationKey: 'driveFailureDurationMs' },
+};
+
+/**
+ * The master switch ANDed with one source's own switch, master first
+ * (spec §8). `risk.enabled` off means the whole system is inert regardless of
+ * every other value, so the owner can kill it in one click without auditing
+ * six other switches.
+ */
+export function riskOn(config, sourceKey) {
+ const risk = config && config.risk;
+ if (!risk || risk.enabled !== true) return false;
+ return risk[sourceKey] === true;
+}
+
+/**
+ * The standing risk rate the UI shows, in incidents per hour. Derived from
+ * config - NEVER from server.nextHazardAt, which must not reach the client
+ * (spec decision 3: showing it turns the prepaid economy into buying one
+ * licence twenty minutes before it fires).
+ */
+export function hazardRatePerHour(config) {
+ const { hazardMinDelayMs, hazardMaxDelayMs } = config.risk;
+ const meanMs = (hazardMinDelayMs + hazardMaxDelayMs) / 2;
+ if (!(meanMs > 0)) return 0;
+ return 3600000 / meanMs;
+}
+
+// A small, pure, well-distributed 32-bit integer hash. Both the high and low
+// halves of the millisecond timestamp are folded in, so two times 2^32ms
+// apart do not collide.
+function hash32(n) {
+ const v = Math.floor(n);
+ let x = (v ^ Math.floor(v / 4294967296)) | 0;
+ x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
+ x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
+ x = (x ^ (x >>> 16)) >>> 0;
+ return x;
+}
+
+/**
+ * A deterministic [0,1) draw keyed by (scheduledAt, salt). The scheduled time
+ * is the ONLY input, so the client and the server derive the same incident
+ * without communicating - which is the entire reason hazards are derived
+ * rather than rolled (spec §5).
+ */
+function unitAt(scheduledAt, salt) {
+ return hash32(hash32(scheduledAt) ^ Math.imul(salt + 1, 0x9e3779b1)) / 4294967296;
+}
+
+/**
+ * The hazard scheduled for `scheduledAt`: its kind, target and duration, all
+ * derived from that timestamp. Returns null when no kind is available (every
+ * kind disabled, or a drive failure with no owned racks to fail).
+ *
+ * NEVER call Math.random() from here, and never store what this returns as a
+ * second source of truth - it is re-derivable by definition, and a stored
+ * copy is a copy that can disagree.
+ */
+export function hazardFrom(scheduledAt, config, state) {
+ const kinds = HAZARD_KINDS.filter((k) => config.risk[HAZARD_SPECS[k].enabledKey] === true);
+ if (kinds.length === 0) return null;
+
+ const kind = kinds[Math.floor(unitAt(scheduledAt, 0) * kinds.length)];
+ const spec = HAZARD_SPECS[kind];
+ const factor = config.risk[spec.factorKey];
+ const durationMs = config.risk[spec.durationKey];
+
+ let scope;
+ if (kind === 'ransomware') {
+ scope = { lane: '*' };
+ } else if (kind === 'ispOutage') {
+ scope = { lane: 'grid' };
+ } else {
+ // Only an owned rack tier can suffer a drive failure. The victim is
+ // derived from the timestamp too - two clients reconciling the same
+ // incident must not disagree about which rack died.
+ const owned = [];
+ for (let i = 0; i < state.run.tiers.length; i++) {
+ const t = state.run.tiers[i];
+ if (t && t.owned > 0) owned.push(i);
+ }
+ if (owned.length === 0) return null;
+ scope = { lane: 'tiers', index: owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)] };
+ }
+
+ return {
+ id: `hazard:${Math.floor(scheduledAt)}`,
+ kind,
+ scope,
+ factor,
+ startAt: scheduledAt,
+ endAt: scheduledAt + durationMs,
+ source: 'hazard',
+ };
+}
+
+/**
+ * Picks WHEN the next hazard happens. Same shape and testability as
+ * scheduleAnomaly (shared/reducer.js): an injected rng, decided once, stored.
+ * Both sides then read the stored timestamp and DERIVE what that hazard is.
+ *
+ * The rng here is safe despite the client running evaluate() too: the next
+ * hazard's time is never displayed (decision 3), and the client's whole state
+ * is replaced by the authoritative copy on the next reconcile - so a
+ * divergent draw is overwritten before anything can observe it. What must NOT
+ * diverge is the identity of a hazard that actually fired, and that is
+ * derived, not drawn.
+ */
+export function scheduleNextHazard(server, config, now, rng = Math.random) {
+ const { hazardMinDelayMs, hazardMaxDelayMs } = config.risk;
+ server.nextHazardAt = now + hazardMinDelayMs + rng() * (hazardMaxDelayMs - hazardMinDelayMs);
+}
+
+/**
+ * Spends one matching supply to absorb `hazard`, or returns false.
+ *
+ * This is the ONE place in the release that decrements a stored value, and it
+ * is not a violation of decision 1: supplies are a consumable the player
+ * bought for exactly this purpose. Nothing here may ever touch credits,
+ * wafers, tapes or owned counts.
+ */
+function absorbWithSupply(state, hazard, notices) {
+ const supply = SUPPLY_FOR_KIND[hazard.kind];
+ if (!supply) return false;
+ const bag = state.meta.supplies;
+ if (!bag) return false;
+ const stock = typeof bag[supply] === 'number' ? bag[supply] : 0;
+ if (stock < 1) return false;
+
+ bag[supply] = stock - 1;
+ // A silent save is a wasted save (spec §6): the moment a hedge pays off is
+ // the only time the player learns hedging was worth it. This notice is a
+ // requirement, not polish - do not drop it to "reduce noise".
+ notices.push({
+ kind: hazard.kind, absorbed: true, supply,
+ remaining: bag[supply], at: hazard.startAt,
+ });
+ return true;
+}
+
+/**
+ * Fires every hazard due at or before `now`, mutating `state` in place, and
+ * returns the one-shot notices for the client.
+ *
+ * An anomaly is an OPPORTUNITY the player claims and never fires on its own;
+ * a hazard fires unattended. Same scheduling shape, different lifecycle - do
+ * not assume scheduleAnomaly's call sites are the right ones to copy.
+ */
+export function fireDueHazards(state, config, now, rng = Math.random) {
+ const server = state.server;
+ const notices = [];
+ if (!riskOn(config, 'hazardsEnabled')) return notices;
+
+ // A save that has never had one scheduled (fresh, migrated, or hard-reset)
+ // gets its first schedule here rather than firing instantly from epoch 0.
+ if (!(server.nextHazardAt > 0)) {
+ scheduleNextHazard(server, config, now, rng);
+ return notices;
+ }
+
+ const seen = new Set(server.outages.map((o) => o.id));
+ let fired = 0;
+ while (server.nextHazardAt <= now && fired < MAX_HAZARDS_PER_EVALUATION) {
+ const scheduledAt = server.nextHazardAt;
+ const hazard = hazardFrom(scheduledAt, config, state);
+ if (hazard && !seen.has(hazard.id)) {
+ seen.add(hazard.id);
+ if (!absorbWithSupply(state, hazard, notices)) {
+ server.outages.push(hazard);
+ notices.push({
+ kind: hazard.kind, absorbed: false, scope: hazard.scope,
+ endAt: hazard.endAt, at: scheduledAt,
+ });
+ }
+ }
+ // From the FIRE time, not from `now` - otherwise a long absence produces
+ // exactly one hazard however long it was.
+ scheduleNextHazard(server, config, scheduledAt, rng);
+ fired++;
+ }
+
+ // Hit the bound with work still pending: jump forward to a fresh schedule
+ // from `now` and move on, rather than spinning.
+ if (server.nextHazardAt <= now) scheduleNextHazard(server, config, now, rng);
+
+ return notices;
+}
+
+/**
+ * Knocks one rack tier offline after a meltdown. Returns the outage, or null
+ * when the shutdown is disabled (the caller then falls back to the pre-v1.11
+ * Overclock-lane freeze) or there is no owned tier to knock out.
+ *
+ * The victim is DERIVED from the overheat's timestamp, exactly as a hazard's
+ * target is: two clients reconciling the same overheat must not disagree
+ * about which rack died.
+ *
+ * The penalty moved from the Overclock lane to the Racks lane because
+ * Overclock now multiplies Racks - running hot risks the very thing it
+ * amplifies, and the punishment is self-limiting.
+ */
+export function overheatOutage(state, config, now) {
+ if (!riskOn(config, 'overheatShutdownEnabled')) return null;
+
+ const owned = [];
+ for (let i = 0; i < state.run.tiers.length; i++) {
+ const t = state.run.tiers[i];
+ if (t && t.owned > 0) owned.push(i);
+ }
+ if (owned.length === 0) return null;
+
+ const index = owned[Math.floor(unitAt(now, 2) * owned.length)];
+ const id = `overheat:${Math.floor(now)}`;
+ if (state.server.outages.some((o) => o && o.id === id)) return null;
+
+ const outage = {
+ id,
+ kind: 'overheat',
+ scope: { lane: 'tiers', index },
+ factor: 0,
+ startAt: now,
+ endAt: now + config.risk.overheatOutageMs,
+ source: 'overheat',
+ };
+ state.server.outages.push(outage);
+ return outage;
+}
+
+// ---------------------------------------------------------------------------
+// Grid maintenance: telegraphed, not sprung
+// ---------------------------------------------------------------------------
+
+/**
+ * Picks the next Grid maintenance window and stores it, VISIBLE, on
+ * `server.gridMaintenance`.
+ *
+ * Called from the server load path only (server/stateService.js), never from
+ * evaluate() - exactly the scheduleAnomaly precedent, and for a sharper
+ * reason here: this window is DISPLAYED, with a countdown. If the client drew
+ * it from its own rng the countdown would jump on every reconcile.
+ *
+ * Downtime you can route around is planning; downtime you cannot is
+ * indistinguishable from the game being broken. That is the whole difference
+ * between this and a hazard.
+ */
+export function scheduleGridMaintenance(server, config, now, rng = Math.random) {
+ const { maintenanceMinDelayMs, maintenanceMaxDelayMs, maintenanceDurationMs } = config.risk;
+ const startAt = now + maintenanceMinDelayMs
+ + rng() * (maintenanceMaxDelayMs - maintenanceMinDelayMs);
+ const index = Math.min(GRID_DEFS.length - 1, Math.floor(rng() * GRID_DEFS.length));
+ server.gridMaintenance = { index, startAt, endAt: startAt + maintenanceDurationMs };
+}
+
+/**
+ * Converts a due, already-scheduled window into an outage. Every parameter
+ * was fixed when it was scheduled, so there is nothing to derive and this is
+ * deterministic on both sides. Returns the outage, or null.
+ */
+export function activateDueMaintenance(state, config, now) {
+ if (!riskOn(config, 'maintenanceEnabled')) return null;
+ const gm = state.server.gridMaintenance;
+ if (!gm || gm.startAt > now) return null;
+
+ state.server.gridMaintenance = null; // stateService schedules the next
+
+ // Deliberately NO "gm.endAt <= now, so it is over, skip it" guard. A window
+ // that covers the whole evaluation gap ends exactly at `now`, and skipping
+ // it would pay the player in full for time they were demonstrably down. An
+ // outage genuinely in the past is harmless to push: effectiveFactor ignores
+ // anything with endAt <= from, and pruneExpired drops it at the end of this
+ // same evaluate(). Let the integral decide, not a guard that cannot see
+ // lastEvaluatedAt.
+ const id = `maintenance:${Math.floor(gm.startAt)}`;
+ if (state.server.outages.some((o) => o && o.id === id)) return null;
+
+ const outage = {
+ id,
+ kind: 'maintenance',
+ scope: { lane: 'grid', index: gm.index },
+ factor: 0,
+ startAt: gm.startAt,
+ endAt: gm.endAt,
+ source: 'scheduled',
+ };
+ state.server.outages.push(outage);
+ return outage;
+}
diff --git a/shared/reducer.js b/shared/reducer.js
index fddcbd6..b97e286 100644
--- a/shared/reducer.js
+++ b/shared/reducer.js
@@ -9,6 +9,7 @@ import { utcDateKey } from './daily.js';
import { contractsForState, contractProgress } from './contracts.js';
import { canClaimStreak, nextStreakCount, streakReward } from './streak.js';
import { checkAchievements } from './achievements.js';
+import { SUPPLY_IDS, supplyPrice, cureCost } from './outages.js';
const LANE_DEFS = { tiers: TIER_DEFS, grid: GRID_DEFS, overclock: OVERCLOCK_DEFS };
@@ -338,6 +339,52 @@ function buyTapeUpgrade(s, action, config) {
return { ok: true };
}
+// v1.11: prepaid mitigation, priced in seconds of current output (see
+// supplyPrice). `id` is user-supplied, so it is resolved with .includes()
+// against a frozen list - never as a bare key into an object literal, which
+// is the prototype-pollution shape validIndex/HANDLERS already guard against
+// elsewhere in this file.
+function buySupply(s, action, config, now) {
+ const { id } = action;
+ if (typeof id !== 'string' || !SUPPLY_IDS.includes(id)) return err('invalid_target');
+
+ const ctx = goalCtx(s, config, now);
+ const cost = supplyPrice(id, config, ctx.totalOutputPerSec);
+ if (!Number.isFinite(cost) || cost > s.run.credits) return err('insufficient_credits');
+
+ s.run.credits -= cost;
+ s.meta.supplies[id] = (s.meta.supplies[id] || 0) + 1;
+ return { ok: true, id, cost, stock: s.meta.supplies[id] };
+}
+
+// v1.11: the reactive cure. A returning player is never merely a spectator -
+// but this is priced strictly worse than preparing (see cureCost) and only
+// applies to a hazard still running.
+//
+// Ends the outage by TRUNCATING endAt to `now`, not by splicing it out: an
+// evaluation window that straddles the cure must still see the time the lane
+// was actually down. pruneExpired drops it on the next evaluate().
+function resolveOutage(s, action, config, now) {
+ const { id } = action;
+ if (typeof id !== 'string') return err('invalid_target');
+ // .find over the array, never a bare key lookup - same hardening as
+ // claimEventRung's claimables resolution.
+ const outage = s.server.outages.find((o) => o && o.id === id);
+ if (!outage) return err('invalid_target');
+ // Maintenance is scheduled and telegraphed, not misfortune; an overheat is
+ // the player's own doing. Neither is curable (spec §6).
+ if (outage.source !== 'hazard') return err('invalid_target');
+ if (now >= outage.endAt) return err('invalid_target');
+
+ const ctx = goalCtx(s, config, now);
+ const cost = cureCost(outage, config, ctx.totalOutputPerSec, now);
+ if (!Number.isFinite(cost) || cost > s.run.credits) return err('insufficient_credits');
+
+ s.run.credits -= cost;
+ outage.endAt = now;
+ return { ok: true, id, cost };
+}
+
function applyLevelUps(meta, xpGain) {
let xp = meta.xp + xpGain;
let level = meta.level;
@@ -649,6 +696,7 @@ const HANDLERS = Object.assign(Object.create(null), {
claimBlock, claimAllBlocks, resetTrack, startJob, cancelJob, claimJob, buyTapeUpgrade,
claimEventRung, setLeaderboardOptOut,
claimContract, claimStreak,
+ buySupply, resolveOutage,
});
export function applyAction(state, action, config, now, rng = Math.random) {
diff --git a/shared/state.js b/shared/state.js
index 9ce8e53..577a0e8 100644
--- a/shared/state.js
+++ b/shared/state.js
@@ -1,7 +1,11 @@
import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from './gameData.js';
-import { computeMults, tierRate } from './gameRules.js';
+import { computeMults, tierRate, overclockBoost } from './gameRules.js';
import { TOTAL_BLOCKS } from './coldStorageData.js';
import { computeColdStorageEffects, jobDurationSec } from './coldStorage.js';
+import {
+ effectiveFactor, pruneExpired, fireDueHazards, activateDueMaintenance,
+ overheatOutage, riskOn,
+} from './outages.js';
function freshTiers() {
return TIER_DEFS.map((t) => ({ id: t.id, owned: 0, manager: false, ready: 0 }));
@@ -50,6 +54,12 @@ export function initialState() {
// Pure prestige - no payout, ever (spec §6.3). { [id]: unlockedAtMs }.
achievements: {},
streak: { count: 0, lastClaimDate: null },
+ // v1.11: prepaid mitigation. Bought with CREDITS (the run currency, so
+ // this is a sink for what players have most of) but stored in META, so
+ // it survives Migrate - which gives a real reason to spend down before
+ // prestiging instead of watching the balance evaporate. hardReset wipes
+ // it along with everything else.
+ supplies: { antivirus: 0, backupIsp: 0, spareDrives: 0 },
eventProgress: null,
// Live Events (v1.4): personal windows that were force-ended early by
// a NEW event going active (spec §5.2) but whose 48h claim grace
@@ -75,10 +85,37 @@ export function initialState() {
boost: null,
lastVentAt: 0,
gameCooldowns: { rush: 0, debug: 0, match: 0, balance: 0 },
+ // v1.11 Risk & Reliability. `outages` IS the shared notion of capacity
+ // currently offline - not a concept layered over two systems, but the
+ // only representation either has (spec §3). `server` is the right home:
+ // it already holds nextAnomalyAt/boost/gameCooldowns, it survives
+ // Migrate and Singularity, and hardReset clears it wholesale.
+ outages: [],
+ nextHazardAt: 0,
+ gridMaintenance: null,
},
};
}
+// v1.11: an outage reaching evaluate() with a non-numeric startAt/endAt/factor
+// would poison the integral into NaN and silently zero a player's income for
+// the rest of the save's life. Validate on the way in, drop what fails.
+function isValidOutage(o) {
+ return !!o && typeof o === 'object'
+ && typeof o.id === 'string'
+ && !!o.scope && typeof o.scope === 'object' && typeof o.scope.lane === 'string'
+ && Number.isFinite(o.factor) && o.factor >= 0 && o.factor <= 1
+ && Number.isFinite(o.startAt) && Number.isFinite(o.endAt)
+ && o.endAt > o.startAt;
+}
+
+function isValidMaintenance(m) {
+ return !!m && typeof m === 'object'
+ && Number.isInteger(m.index) && m.index >= 0
+ && Number.isFinite(m.startAt) && Number.isFinite(m.endAt)
+ && m.endAt > m.startAt;
+}
+
/**
* Lifts a v1.1 `{run, meta}` save (no `server` block, possibly short
* `tiers`/`grid`/`overclock`, missing stats keys) into the canonical
@@ -165,10 +202,28 @@ export function migrateSave(raw) {
lastClaimDate: typeof srcStreak.lastClaimDate === 'string' ? srcStreak.lastClaimDate : null,
};
+ // v1.11: defaulted AND clamped. Absorption decrements this inside
+ // evaluate(), so a negative or non-numeric count would let a hand-edited
+ // save absorb hazards forever.
+ const srcSupplies = isPlainObject(srcMeta.supplies) ? srcMeta.supplies : {};
+ meta.supplies = {};
+ for (const id of ['antivirus', 'backupIsp', 'spareDrives']) {
+ const v = srcSupplies[id];
+ meta.supplies[id] = typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
+ }
+
const server = {
...base.server,
...srcServer,
gameCooldowns: { ...base.server.gameCooldowns, ...(srcServer.gameCooldowns || {}) },
+ // v1.11: shape-pinned, not merely defaulted - same reasoning as
+ // pendingEventClaims above. effectiveFactor()/pruneExpired() iterate this
+ // on every evaluation, and a corrupt or hand-edited save carrying a
+ // non-array (or an outage with a NaN bound) must never reach them.
+ outages: Array.isArray(srcServer.outages) ? srcServer.outages.filter(isValidOutage) : [],
+ nextHazardAt: typeof srcServer.nextHazardAt === 'number' && Number.isFinite(srcServer.nextHazardAt)
+ ? srcServer.nextHazardAt : 0,
+ gridMaintenance: isValidMaintenance(srcServer.gridMaintenance) ? srcServer.gridMaintenance : null,
};
return { run, meta, server };
@@ -179,7 +234,7 @@ export function migrateSave(raw) {
* this closes the gap analytically, in one shot, whenever the server needs
* an up-to-date view (a request comes in, a save happens, etc).
*/
-export function evaluate(state, config, lastEvaluatedAt, now) {
+export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) {
const s = structuredClone(state);
const elapsedSec = Math.max(0, (now - lastEvaluatedAt) / 1000);
recordLegacyCorePeak(s.meta);
@@ -190,6 +245,35 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
// subsequent call regardless of what happens this time.
delete s.server.overheated;
+ // v1.11: outage notices are one-shot client signals with exactly the same
+ // lifecycle as `overheated` above - set by the evaluation that produced
+ // them, cleared on every subsequent call.
+ delete s.server.outageNotices;
+
+ // Fire BEFORE the integral, so an incident that started part-way through
+ // this window degrades the part it covered. (Pruning is the mirror image
+ // and happens after - see the bottom of this function.) `outages` below is
+ // the same array object fireDueHazards pushes into, so the integral sees
+ // anything that just fired - do not re-bind or clone it between these.
+ // v1.11 (spec §8): the master switch is a TRUE KILL SWITCH, not a pause.
+ // Clearing here - BEFORE the integral - means even the window currently
+ // being evaluated is paid in full, so killing the system visibly un-breaks
+ // every affected save on the next evaluation. A player mid-ransomware when
+ // the owner flips this must not stay throttled with nothing in the UI to
+ // explain it.
+ if (!config.risk || config.risk.enabled !== true) {
+ if (s.server.outages.length > 0) s.server.outages = [];
+ s.server.gridMaintenance = null;
+ } else {
+ activateDueMaintenance(s, config, now);
+ const notices = fireDueHazards(s, config, now, rng);
+ if (notices.length > 0) s.server.outageNotices = notices;
+ }
+
+ // Bound AFTER the block above: the kill branch reassigns s.server.outages
+ // to a fresh array, so a binding taken earlier would point at the old one.
+ const outages = s.server.outages;
+
const online = elapsedSec <= config.offline.onlineGapThresholdSec;
let gained = 0;
@@ -206,10 +290,33 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
let creditsGain = 0;
let lifetimeGain = 0;
+ // v1.11: the Overclock lane contributes a multiplier to Racks instead of
+ // producing directly. The boost is NOT itself degraded by outages -
+ // ransomware's { lane: '*' } already covers the Racks lane the boost
+ // multiplies, and applying it to both would square the penalty.
+ // An active heat cooldown freezes the lane, however it came to be set -
+ // NOT gated on the toggle. Under the v1.11 default a cooldown is only ever
+ // set by overheatOutage's fallback (shutdown enabled but no owned rack to
+ // down), and a cooldown that is set but not honoured would let heat
+ // re-cross the cap on every single evaluation. This also matches
+ // goalCtx's condition exactly, so the displayed rate and the produced
+ // rate cannot disagree.
+ const legacyFreeze = !!s.run.heatCooldownUntil && now < s.run.heatCooldownUntil;
+ const racksBase = s.run.tiers.reduce((sum, ts, i) => {
+ const def = TIER_DEFS[i];
+ if (!def || !ts || ts.owned === 0) return sum;
+ return sum + tierRate(ts.owned, def.baseProd, racksMult, thresholds);
+ }, 0);
+ const ocBoost = legacyFreeze
+ ? 1
+ : overclockBoost(s.run, config, overclockMult, thresholds, racksBase);
+
s.run.tiers = s.run.tiers.map((ts, i) => {
const def = TIER_DEFS[i];
if (!def || !ts || ts.owned === 0) return ts;
- const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds) * elapsedSec;
+ const factor = effectiveFactor(outages, 'tiers', i, lastEvaluatedAt, now);
+ const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds)
+ * elapsedSec * factor * ocBoost;
lifetimeGain += produced;
if (ts.manager) { creditsGain += produced; return ts; }
return { ...ts, ready: (ts.ready || 0) + produced };
@@ -218,38 +325,40 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
s.run.grid.forEach((g, i) => {
const def = GRID_DEFS[i];
if (!def || !g || g.owned === 0) return;
- const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * elapsedSec;
+ const factor = effectiveFactor(outages, 'grid', i, lastEvaluatedAt, now);
+ const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * elapsedSec * factor;
creditsGain += produced;
lifetimeGain += produced;
});
- // Overclock lane: frozen entirely (no production, no heat change) while
- // an overheat cooldown from a previous gap is still active.
- const onCooldownNow = !!s.run.heatCooldownUntil && now < s.run.heatCooldownUntil;
- if (onCooldownNow) {
- // leave heat/cooldown as-is; nothing produced this gap on this lane
- } else {
+ // v1.11: the Overclock lane no longer produces - its output became the
+ // `ocBoost` multiplier applied to Racks above. Heat still accrues here,
+ // which is what makes the lane a risk dial rather than free money.
+ //
+ // The legacy freeze (risk.overheatShutdownEnabled off) still stops heat
+ // accrual entirely for the duration of the cooldown, exactly as it did
+ // before v1.11.
+ if (!legacyFreeze) {
if (s.run.heatCooldownUntil && now >= s.run.heatCooldownUntil) {
s.run.heatCooldownUntil = null;
}
- s.run.overclock.forEach((o, i) => {
- const def = OVERCLOCK_DEFS[i];
- if (!def || !o || o.owned === 0) return;
- const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * elapsedSec;
- creditsGain += produced;
- lifetimeGain += produced;
- });
const heatGain = s.run.overclock.reduce((sum, o, i) => {
const def = OVERCLOCK_DEFS[i];
if (!def || !o) return sum;
return sum + o.owned * def.heatPerSec;
}, 0) * eff.heatDiscount;
const netHeat = heatGain - eff.autoVentPerSec;
- let newHeat = Math.max(0, s.run.heat + netHeat * elapsedSec);
+ const newHeat = Math.max(0, s.run.heat + netHeat * elapsedSec);
if (newHeat >= config.heat.capacity + csEff.heatCapacityBonus) {
s.run.heat = 0;
- s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs;
s.server.overheated = true;
+ // The penalty moved from the Overclock lane to the Racks lane, which
+ // is coherent now that Overclock multiplies Racks. overheatOutage
+ // returns null when the shutdown is disabled (or there is no owned
+ // tier to down), in which case fall back to today's lane freeze.
+ if (!overheatOutage(s, config, now)) {
+ s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs;
+ }
} else {
s.run.heat = newHeat;
}
@@ -285,10 +394,32 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
let offlineCredits = 0;
let offlineLifetime = 0;
+ // DELIBERATE, AND ODD ON PURPOSE (spec decision 5): the outage factor is
+ // computed over the WHOLE absence [lastEvaluatedAt, now] and then applied
+ // to the CAPPED payout. An incident covering 2 of 12 absent hours costs
+ // 2/12ths of what you were credited, regardless of the cap - the capped
+ // window is a representative SAMPLE of the absence, not its first N hours.
+ //
+ // Do not "fix" this into the literal first-N-hours reading. At roughly one
+ // incident per six hours, most incidents would land in unpaid time and
+ // cost nothing, which quietly guts the system for exactly the players it
+ // should reach most - the ones who are away for a long time. This was
+ // considered and explicitly rejected by the owner.
+ // v1.11: same conversion as the online branch - Overclock multiplies
+ // Racks rather than producing. Heat is untouched offline, as before.
+ const racksBaseOffline = s.run.tiers.reduce((sum, ts, i) => {
+ const def = TIER_DEFS[i];
+ if (!def || !ts || ts.owned === 0) return sum;
+ return sum + tierRate(ts.owned, def.baseProd, racksMult, thresholds);
+ }, 0);
+ const ocBoostOffline = overclockBoost(s.run, config, overclockMult, thresholds, racksBaseOffline);
+
s.run.tiers = s.run.tiers.map((ts, i) => {
const def = TIER_DEFS[i];
if (!def || !ts || ts.owned === 0) return ts;
- const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds) * cappedSec;
+ const factor = effectiveFactor(outages, 'tiers', i, lastEvaluatedAt, now);
+ const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds)
+ * cappedSec * factor * ocBoostOffline;
offlineLifetime += produced;
if (ts.manager) { offlineCredits += produced; return ts; }
return { ...ts, ready: (ts.ready || 0) + produced };
@@ -297,15 +428,8 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
s.run.grid.forEach((g, i) => {
const def = GRID_DEFS[i];
if (!def || !g || g.owned === 0) return;
- const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * cappedSec;
- offlineCredits += produced;
- offlineLifetime += produced;
- });
-
- s.run.overclock.forEach((o, i) => {
- const def = OVERCLOCK_DEFS[i];
- if (!def || !o || o.owned === 0) return;
- const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * cappedSec;
+ const factor = effectiveFactor(outages, 'grid', i, lastEvaluatedAt, now);
+ const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * cappedSec * factor;
offlineCredits += produced;
offlineLifetime += produced;
});
@@ -324,6 +448,11 @@ export function evaluate(state, config, lastEvaluatedAt, now) {
s.server.boost = null;
}
+ // Prune AFTER the integral, never before: an outage that ended part-way
+ // through this window still degraded the part it covered, and pruning first
+ // would silently pay that time in full.
+ s.server.outages = pruneExpired(s.server.outages, now);
+
return { state: s, gained };
}
diff --git a/tests/configSchema.test.js b/tests/configSchema.test.js
index 98f4806..3c2775c 100644
--- a/tests/configSchema.test.js
+++ b/tests/configSchema.test.js
@@ -23,6 +23,11 @@ describe('configSchema', () => {
it('every TUNABLES path resolves in DEFAULT_CONFIG and is in range', () => {
for (const t of TUNABLES) {
const v = getAtPath(DEFAULT_CONFIG, t.path);
+ // v1.11: boolean tunables carry no min/max - the type IS the range.
+ if (t.type === 'boolean') {
+ expect(v, t.path).toBeTypeOf('boolean');
+ continue;
+ }
expect(v, t.path).toBeTypeOf('number');
expect(v).toBeGreaterThanOrEqual(t.min);
expect(v).toBeLessThanOrEqual(t.max);
@@ -99,3 +104,39 @@ describe('v1.6 heat tunables', () => {
expect(out.heat.ventCooldownMs).toBe(3000);
});
});
+
+describe('boolean tunables (v1.11)', () => {
+ it('validates booleans on boolean paths and rejects numbers there', () => {
+ expect(validateConfig(DEFAULT_CONFIG).ok).toBe(true);
+
+ const bad = structuredClone(DEFAULT_CONFIG);
+ bad.risk.enabled = 1;
+ const res = validateConfig(bad);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.startsWith('risk.enabled:'))).toBe(true);
+ });
+
+ it('rejects a boolean on a numeric path', () => {
+ const bad = structuredClone(DEFAULT_CONFIG);
+ bad.heat.capacity = true;
+ expect(validateConfig(bad).ok).toBe(false);
+ });
+
+ it('upgradeConfig copies booleans through and fills missing ones', () => {
+ const old = { schemaVersion: 1, risk: { enabled: false } };
+ const up = upgradeConfig(old);
+ expect(up.risk.enabled).toBe(false); // preserved
+ expect(up.risk.hazardsEnabled).toBe(true); // filled from defaults
+ expect(validateConfig(up).ok).toBe(true);
+ });
+
+ it('has the v1.11 risk defaults and every risk leaf is a TUNABLES row', () => {
+ expect(DEFAULT_CONFIG.risk.enabled).toBe(true);
+ expect(DEFAULT_CONFIG.risk.ransomwareFactor).toBe(0.5);
+ expect(DEFAULT_CONFIG.risk.overclockBoostGain).toBe(1);
+ const paths = new Set(TUNABLES.map((t) => t.path));
+ for (const key of Object.keys(DEFAULT_CONFIG.risk)) {
+ expect(paths.has(`risk.${key}`), `risk.${key}`).toBe(true);
+ }
+ });
+});
diff --git a/tests/e2e/smoke-v111.mjs b/tests/e2e/smoke-v111.mjs
new file mode 100644
index 0000000..04f6f89
--- /dev/null
+++ b/tests/e2e/smoke-v111.mjs
@@ -0,0 +1,438 @@
+#!/usr/bin/env node
+// v1.11 Risk & Reliability - end-to-end smoke suite (Task 11).
+//
+// Covers:
+//
+// 1. A save carrying a live ransomware outage across the whole absence earns
+// strictly less than the identical save without one - and both earn
+// something. This is the release working at all.
+// 2. That same pair have IDENTICAL Cold Storage job accrual and tapes. Cold
+// Storage is the safe harbour (spec decision 6) and nothing may reach it.
+// 3. POST /api/actions { type: 'buySupply', id: 'antivirus' } charges credits
+// and stocks one; with no credits it is refused as insufficient_credits
+// and changes nothing.
+// 4. A save whose nextHazardAt is 1 (1970) reconciles quickly, rolls
+// nextHazardAt into the future, and never exceeds
+// MAX_HAZARDS_PER_EVALUATION outages. The bound is a requirement, not a
+// nicety - an unbounded loop here is a hung request.
+// 5. A stocked supply absorbs a hazard that fired while the player was
+// offline, leaving no outage behind. That is the only defence that can
+// reach an incident which starts and ends during an absence.
+// 6. The master kill switch clears a live outage on the next reconcile - a
+// true kill, not a pause.
+// 7. PUT /api/admin/config with a string on risk.enabled is rejected. The
+// v1.11 boolean tunable type is enforced end to end, not just in unit
+// tests.
+// 8. Over the built client: the Resilience tab renders its supply shop.
+//
+// Same harness shape as smoke-v110.mjs - boots a real `node server/index.js`
+// against a scratch SQLite file, seeds users/saves through server/db.js and
+// mints JWT cookies via server/auth.js. Checks 1-7 are API invariants and need
+// no browser; check 8 uses the same Playwright resolution the other suites do
+// and SKIPs rather than fails when no browser can be resolved.
+//
+// Every check prints `PASS ` or `FAIL : `. At the end:
+// `=== ERRORS ===` followed by each failure, or `NONE`. Exits non-zero if
+// anything failed. The server child process is always killed on the way out.
+
+import { spawn } from 'node:child_process';
+import { rmSync, existsSync, readdirSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.join(__dirname, '..', '..');
+
+const PORT = 3811;
+const BASE_URL = `http://localhost:${PORT}`;
+const DB_PATH = '/tmp/e2e-v111.db';
+const JWT_SECRET = '9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0';
+// Admin checks need an owner. Same mechanism smoke-v14-events.mjs uses: the id
+// is provider:providerId, so seeding github/37058311 produces exactly this.
+const OWNER_ID = 'github:37058311';
+
+for (const ext of ['', '-wal', '-shm']) {
+ try { rmSync(DB_PATH + ext, { force: true }); } catch (e) { /* ignore */ }
+}
+
+process.env.JWT_SECRET = JWT_SECRET;
+process.env.SUPER_ADMIN_IDS = OWNER_ID;
+process.env.DB_PATH = DB_PATH;
+process.env.NODE_ENV = 'test';
+
+const {
+ upsertUser, putSave, setToursCompleted, driver,
+} = await import(path.join(REPO_ROOT, 'server', 'db.js'));
+const { issueToken, COOKIE_NAME } = await import(path.join(REPO_ROOT, 'server', 'auth.js'));
+const { initialState } = await import(path.join(REPO_ROOT, 'shared', 'state.js'));
+const { MAX_HAZARDS_PER_EVALUATION } = await import(path.join(REPO_ROOT, 'shared', 'outages.js'));
+const { TOUR_IDS } = await import(path.join(REPO_ROOT, 'shared', 'tours.js'));
+
+// GET /api/state returns run/meta/server FLATTENED at the top level, not
+// wrapped in `state` - unlike POST /api/actions, which does return { state }.
+const stateOf = (body) => ({ run: body.run, meta: body.meta, server: body.server });
+
+// Multiple processes hold this same SQLite file open (this harness for
+// seeding, plus the spawned server for real traffic); busy_timeout is a
+// SQLite-only pragma (Postgres uses MVCC instead), so only apply it against
+// the SQLite driver.
+if (driver.__backend === 'sqlite') {
+ driver.__raw.pragma('busy_timeout = 5000');
+}
+
+let serverProc = null;
+let shuttingDown = false;
+
+function killServer() {
+ if (serverProc && !serverProc.killed) {
+ try { serverProc.kill('SIGTERM'); } catch (e) { /* ignore */ }
+ }
+}
+process.on('exit', killServer);
+process.on('SIGINT', () => { killServer(); process.exit(130); });
+process.on('SIGTERM', () => { killServer(); process.exit(143); });
+
+async function startServer() {
+ serverProc = spawn(process.execPath, [path.join(REPO_ROOT, 'server', 'index.js')], {
+ cwd: REPO_ROOT,
+ env: { ...process.env, PORT: String(PORT) },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let out = '';
+ serverProc.stdout.on('data', (d) => { out += d.toString(); });
+ serverProc.stderr.on('data', (d) => { out += d.toString(); });
+ serverProc.on('exit', (code, signal) => {
+ if (code !== null && code !== 0 && !shuttingDown) {
+ console.error(`\n[server] exited early (code=${code} signal=${signal}); output:\n${out}`);
+ }
+ });
+
+ const deadline = Date.now() + 15000;
+ for (;;) {
+ try {
+ const res = await fetch(`${BASE_URL}/`);
+ if (res.ok || res.status === 404) break;
+ } catch (e) { /* not up yet */ }
+ if (Date.now() > deadline) {
+ throw new Error(`server did not become ready within 15s; output:\n${out}`);
+ }
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => setTimeout(r, 150));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Playwright resolution: plain import first, scratchpad fallback second.
+// Mirrors smoke-v12..v110 so this suite behaves the same way in CI.
+// ---------------------------------------------------------------------------
+
+function findScratchpadPlaywright() {
+ const found = [];
+ const tmp = '/tmp';
+ let claudeDirs = [];
+ try {
+ claudeDirs = readdirSync(tmp).filter((d) => d.startsWith('claude-') || d === 'e2e-verify');
+ } catch (e) {
+ return found;
+ }
+ function walk(dir, depth) {
+ if (depth > 6) return;
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch (e) {
+ return;
+ }
+ for (const ent of entries) {
+ if (!ent.isDirectory()) continue;
+ const full = path.join(dir, ent.name);
+ if (ent.name === 'playwright' && full.includes('node_modules')) {
+ const idx = path.join(full, 'index.mjs');
+ if (existsSync(idx)) found.push(idx);
+ }
+ if (ent.name !== 'playwright') walk(full, depth + 1);
+ }
+ }
+ for (const d of claudeDirs) walk(path.join(tmp, d), 0);
+ return found;
+}
+
+async function loadPlaywrightOrNull() {
+ try {
+ return await import('playwright');
+ } catch (e) {
+ for (const c of findScratchpadPlaywright()) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ return await import(`file://${c}`);
+ } catch (e2) { /* try the next candidate */ }
+ }
+ return null;
+ }
+}
+
+const failures = [];
+
+async function check(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ console.log(`FAIL ${name}: ${e && e.message ? e.message : e}`);
+ failures.push({ name, message: e && e.message ? e.message : String(e) });
+ }
+}
+
+function assert(cond, message) {
+ if (!cond) throw new Error(message);
+}
+
+const HOUR = 3600 * 1000;
+
+let seq = 0;
+async function seedUser(mutate, ident) {
+ seq += 1;
+ const user = await upsertUser({
+ provider: ident ? ident.provider : 'discord',
+ providerId: ident ? ident.providerId : `v111-${seq}`,
+ username: ident ? ident.username : `v111user${seq}`,
+ avatarUrl: null,
+ });
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 40, manager: true, ready: 0 };
+ s.run.grid[0] = { id: 0, owned: 10 };
+ if (mutate) mutate(s);
+ await putSave(user.id, s, Date.now() - HOUR); // a 1h offline gap
+ return user;
+}
+
+// The onboarding tour auto-starts for an account that has completed nothing,
+// and its overlay is a full-screen `fixed inset-0` div that swallows every
+// click - so the browser check below must seed the tours as done first.
+async function seedToursCompleted(user) {
+ await setToursCompleted(user.id, TOUR_IDS);
+}
+
+function cookieFor(user) {
+ const token = issueToken({ id: user.id, username: user.username, avatar_url: user.avatar_url });
+ return `${COOKIE_NAME}=${token}`;
+}
+
+async function api(user, urlPath, opts = {}) {
+ const res = await fetch(`${BASE_URL}${urlPath}`, {
+ ...opts,
+ headers: {
+ 'content-type': 'application/json',
+ cookie: cookieFor(user),
+ ...(opts.headers || {}),
+ },
+ });
+ const text = await res.text();
+ let body = null;
+ try { body = JSON.parse(text); } catch (e) { /* not json */ }
+ return { status: res.status, body };
+}
+
+async function main() {
+ await startServer();
+ console.log('Server up.');
+
+ const past = Date.now() - HOUR;
+
+ // --- 1-2: an outage costs output, Cold Storage never notices -------------
+
+ const clean = await seedUser((s) => {
+ s.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: 0, startedAt: past };
+ });
+ const dark = await seedUser((s) => {
+ s.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: 0, startedAt: past };
+ s.server.outages = [{
+ id: 'hazard:e2e', kind: 'ransomware', scope: { lane: '*' }, factor: 0,
+ startAt: past, endAt: Date.now() + HOUR, source: 'hazard',
+ }];
+ });
+
+ const cleanState = stateOf((await api(clean, '/api/state')).body);
+ const darkState = stateOf((await api(dark, '/api/state')).body);
+
+ await check('an outage reduces output over the same window', async () => {
+ assert(cleanState.run.credits > 10, 'clean save earned nothing');
+ assert(darkState.run.credits < cleanState.run.credits,
+ `expected the darkened save to earn less: ${darkState.run.credits} vs ${cleanState.run.credits}`);
+ });
+
+ await check('Cold Storage is a safe harbour - identical with and without an incident', async () => {
+ assert(darkState.meta.coldStorage.job.accruedOfflineSec
+ === cleanState.meta.coldStorage.job.accruedOfflineSec,
+ 'cold storage job accrual differed under an outage');
+ assert(darkState.meta.coldStorage.tapes === cleanState.meta.coldStorage.tapes,
+ 'cold storage tapes differed under an outage');
+ });
+
+ // --- 3: buying a supply --------------------------------------------------
+
+ const buyer = await seedUser((s) => { s.run.credits = 1e12; });
+ await seedToursCompleted(buyer);
+ await check('buySupply charges credits and stocks one', async () => {
+ // Baseline AFTER the offline gap is credited, not the seeded 1e12 - an
+ // hour of accrual dwarfs the supply price, so comparing against the seed
+ // would "pass" even if nothing were charged.
+ const before = stateOf((await api(buyer, '/api/state')).body).run.credits;
+ const res = await api(buyer, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({ actions: [{ type: 'buySupply', id: 'antivirus' }] }),
+ });
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ assert(res.body.results[0].ok === true, `buySupply rejected: ${JSON.stringify(res.body.results[0])}`);
+ assert(res.body.state.meta.supplies.antivirus === 1,
+ `expected 1 antivirus, got ${res.body.state.meta.supplies.antivirus}`);
+ const cost = res.body.results[0].cost;
+ assert(cost > 0, `expected a positive cost, got ${cost}`);
+ assert(res.body.state.run.credits <= before,
+ `credits did not fall: ${before} -> ${res.body.state.run.credits}`);
+ });
+
+ const pauper = await seedUser((s) => {
+ s.run.credits = 0;
+ s.run.tiers[0].owned = 0;
+ s.run.grid[0].owned = 0;
+ });
+ await check('buySupply is refused when unaffordable, and changes nothing', async () => {
+ const res = await api(pauper, '/api/actions', {
+ method: 'POST',
+ body: JSON.stringify({ actions: [{ type: 'buySupply', id: 'antivirus' }] }),
+ });
+ assert(res.body.results[0].error === 'insufficient_credits',
+ `expected insufficient_credits, got ${JSON.stringify(res.body.results[0])}`);
+ assert(res.body.state.meta.supplies.antivirus === 0, 'stock changed on a rejected buy');
+ });
+
+ // --- 4: the bound. A 1970 nextHazardAt must terminate, not spin ----------
+
+ const ancient = await seedUser((s) => { s.server.nextHazardAt = 1; });
+ await check('a nextHazardAt far in the past terminates and reschedules', async () => {
+ const t0 = Date.now();
+ const res = await api(ancient, '/api/state');
+ const took = Date.now() - t0;
+ assert(res.status === 200, `expected 200, got ${res.status}`);
+ assert(took < 5000, `took ${took}ms - the firing loop is not bounded`);
+ const st = stateOf(res.body);
+ assert(st.server.nextHazardAt > Date.now(), 'nextHazardAt was not rolled forward past now');
+ assert(st.server.outages.length <= MAX_HAZARDS_PER_EVALUATION,
+ `fired ${st.server.outages.length} outages, above the bound`);
+ });
+
+ // --- 5: absorption reaches an offline player ----------------------------
+
+ const hedged = await seedUser((s) => {
+ s.meta.supplies = { antivirus: 3, backupIsp: 3, spareDrives: 3 };
+ s.server.nextHazardAt = Date.now() - HOUR / 2; // one is due
+ });
+ await check('a stocked supply absorbs a hazard that fired while offline', async () => {
+ const st = stateOf((await api(hedged, '/api/state')).body);
+ const supplies = st.meta.supplies;
+ const total = supplies.antivirus + supplies.backupIsp + supplies.spareDrives;
+ assert(total < 9, 'nothing was consumed - no hazard fired to absorb');
+ assert(st.server.outages.length === 0,
+ `absorbed hazards must leave no outage, found ${st.server.outages.length}`);
+ });
+
+ // --- 6-7: the kill switch, and the boolean type, end to end -------------
+
+ const owner = await seedUser(undefined, {
+ provider: 'github', providerId: '37058311', username: 'owner_v111_e2e',
+ });
+ assert(`${owner.id}` === OWNER_ID, `expected seeded owner id ${OWNER_ID}, got ${owner.id}`);
+
+ await check('a string on a boolean tunable is rejected', async () => {
+ const cur = (await api(owner, '/api/admin/config')).body;
+ const doc = structuredClone(cur.data);
+ doc.risk.enabled = 'no';
+ // PUT /api/admin/config takes the document wrapped as { data }.
+ const res = await api(owner, '/api/admin/config', {
+ method: 'PUT', body: JSON.stringify({ data: doc }),
+ });
+ assert(res.body && Array.isArray(res.body.errors), 'a string boolean was accepted');
+ assert(res.body.errors.some((e) => e.startsWith('risk.enabled:')),
+ `expected a risk.enabled error, got ${JSON.stringify(res.body.errors)}`);
+ });
+
+ const throttled = await seedUser((s) => {
+ s.server.outages = [{
+ id: 'hazard:kill', kind: 'ransomware', scope: { lane: '*' }, factor: 0,
+ startAt: past, endAt: Date.now() + 10 * HOUR, source: 'hazard',
+ }];
+ });
+ await check('the kill switch clears a live outage on the next reconcile', async () => {
+ const cur = (await api(owner, '/api/admin/config')).body;
+ const off = structuredClone(cur.data);
+ off.risk.enabled = false;
+ const put = await api(owner, '/api/admin/config', {
+ method: 'PUT', body: JSON.stringify({ data: off }),
+ });
+ assert(typeof put.body.version === 'number', `config PUT failed: ${JSON.stringify(put.body)}`);
+
+ const st = stateOf((await api(throttled, '/api/state')).body);
+ assert(st.server.outages.length === 0,
+ `expected the outage cleared, found ${st.server.outages.length}`);
+
+ // Restore, so the browser pass below sees the shipped defaults.
+ const on = structuredClone(off);
+ on.risk.enabled = true;
+ await api(owner, '/api/admin/config', {
+ method: 'PUT', body: JSON.stringify({ data: on }),
+ });
+ });
+
+ // --- 8: the Resilience tab renders --------------------------------------
+
+ const pw = await loadPlaywrightOrNull();
+ if (!pw) {
+ console.log('SKIP the Resilience tab renders its supply shop (no Playwright browser available)');
+ } else {
+ let browser = null;
+ try {
+ browser = await pw.chromium.launch();
+ await check('the Resilience tab renders its supply shop', async () => {
+ const context = await browser.newContext();
+ await context.addCookies([{
+ name: COOKIE_NAME,
+ value: cookieFor(buyer).slice(COOKIE_NAME.length + 1),
+ domain: 'localhost',
+ path: '/',
+ }]);
+ const page = await context.newPage();
+ await page.goto(BASE_URL);
+ await page.getByRole('button', { name: /Resilience/ }).click();
+ const buy = page.getByTestId('supply-buy-antivirus');
+ await buy.waitFor({ timeout: 10000 });
+ const label = await buy.textContent();
+ assert(label.includes('Buy 1'), `supply buy button did not render its price: ${label}`);
+ await context.close();
+ });
+ } catch (e) {
+ console.log(`SKIP the Resilience tab renders its supply shop (browser launch failed: ${e.message})`);
+ } finally {
+ if (browser) await browser.close();
+ }
+ }
+
+ console.log('\n=== ERRORS ===');
+ if (failures.length === 0) {
+ console.log('NONE');
+ } else {
+ for (const f of failures) console.log(`${f.name}: ${f.message}`);
+ }
+}
+
+try {
+ await main();
+} catch (e) {
+ console.error(`\nFATAL: ${e && e.stack ? e.stack : e}`);
+ failures.push({ name: 'harness', message: String(e) });
+} finally {
+ shuttingDown = true;
+ killServer();
+}
+
+process.exit(failures.length === 0 ? 0 : 1);
diff --git a/tests/e2e/smoke-v12.mjs b/tests/e2e/smoke-v12.mjs
index 56b0842..a189608 100644
--- a/tests/e2e/smoke-v12.mjs
+++ b/tests/e2e/smoke-v12.mjs
@@ -444,24 +444,36 @@ async function main() {
const overheated = await bootAndGetState(econPage);
assert(overheated.run.heat === 0, `expected heat reset to 0 after overheat, got ${overheated.run.heat}`);
- assert(typeof overheated.run.heatCooldownUntil === 'number' && overheated.run.heatCooldownUntil > Date.now(),
- 'expected an active heatCooldownUntil in the future');
assert(overheated.run.overclock[0].owned === overclockOwnedBefore,
`expected no node loss: overclock[0].owned should still be ${overclockOwnedBefore}, got ${overheated.run.overclock[0].owned}`);
+ // v1.11: the overheat penalty MOVED from the Overclock lane to the Racks
+ // lane. Overclock now multiplies Racks, so running hot risks the very
+ // thing it amplifies - a rack tier goes dark for a while instead of the
+ // Overclock lane freezing. heatCooldownUntil is therefore no longer set
+ // (it survives only as the fallback when there is no owned rack to down,
+ // and as the legacy path behind risk.overheatShutdownEnabled = false).
+ assert(overheated.run.heatCooldownUntil === null,
+ `expected no legacy lane freeze, got heatCooldownUntil=${overheated.run.heatCooldownUntil}`);
+ const downed = (overheated.server.outages || []).find((o) => o.source === 'overheat');
+ assert(downed, 'expected an overheat outage in server.outages');
+ assert(downed.scope.lane === 'tiers' && downed.factor === 0,
+ `expected a rack tier fully offline, got ${JSON.stringify(downed.scope)} factor=${downed.factor}`);
+
const bodyText = await econPage.textContent('body');
assert(bodyText.includes('Overheated!'), 'expected the meltdown modal');
assert(bodyText.includes('no nodes were lost'), 'expected the meltdown modal to reassure no nodes were lost');
- // Dismiss the meltdown modal, switch to the Overclock tab, and check
- // the frozen-lane messaging + disabled Vent button.
+ // Dismiss the meltdown modal. The outage strip must SAY what is down -
+ // capacity silently producing nothing reads as a bug (v1.11 spec §9).
+ // Asserted on the strip rather than the Racks panel because the strip
+ // lives in the sticky header and is visible whatever tab is open and
+ // whatever tier was picked, including one past unlockedUpTo.
await econPage.getByRole('button', { name: 'Understood', exact: true }).click();
- await econPage.getByRole('button', { name: 'Overclock', exact: true }).click();
- await econPage.getByText(/Overclock lane frozen after meltdown/).waitFor({ timeout: 3000 });
- // v1.6: the label carries the live vent percentage ("Vent Heat (-25%)"),
- // so match the prefix rather than the whole string.
- const ventBtn = econPage.getByRole('button', { name: /^Vent Heat/ });
- assert(await ventBtn.isDisabled(), 'expected Vent Heat to be disabled during the meltdown lockout');
+ const strip = econPage.getByTestId('outage-strip');
+ await strip.waitFor({ timeout: 3000 });
+ const stripText = await strip.textContent();
+ assert(/overheat/.test(stripText), `expected the outage strip to name the overheat, got: ${stripText}`);
// Non-admin heat-bar rescale: nameUser1's raw heat (50) never changed;
// only the capacity did (2000 -> 100), so their displayed percentage
diff --git a/tests/e2e/smoke-v16.mjs b/tests/e2e/smoke-v16.mjs
index b388398..353fe78 100644
--- a/tests/e2e/smoke-v16.mjs
+++ b/tests/e2e/smoke-v16.mjs
@@ -334,7 +334,10 @@ async function main() {
const first = await counter.textContent();
assert(/^1 \//.test(first.trim()), `expected to start at step 1, got "${first}"`);
const total = Number(first.trim().split('/')[1]);
- assert(total === 11, `expected 11 steps for a fresh account, got ${total}`);
+ // v1.11 appended 2 ungated Resilience steps to the onboarding tour
+ // (a fresh save can be hit by a hazard, so it is never gated behind
+ // progression), taking a fresh account's resolved count 11 -> 13.
+ assert(total === 13, `expected 13 steps for a fresh account, got ${total}`);
await next.click();
const second = await counter.textContent();
diff --git a/tests/events.test.js b/tests/events.test.js
index ee69886..17f20d6 100644
--- a/tests/events.test.js
+++ b/tests/events.test.js
@@ -122,3 +122,15 @@ describe('rungProgress', () => {
expect(rungProgress(rung, meta, {})).toEqual({ current: 1000, target: 500, met: true });
});
});
+
+describe('event modifiers vs boolean tunables (v1.11)', () => {
+ it('rejects a modifier targeting a boolean tunable', () => {
+ const res = validateModifiers([{ path: 'risk.enabled', value: 0 }]);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.includes('risk.enabled'))).toBe(true);
+ });
+
+ it('still accepts a numeric risk modifier', () => {
+ expect(validateModifiers([{ path: 'risk.ransomwareFactor', value: 0.25 }]).ok).toBe(true);
+ });
+});
diff --git a/tests/gameRules.test.js b/tests/gameRules.test.js
index 7714db6..e4f87b3 100644
--- a/tests/gameRules.test.js
+++ b/tests/gameRules.test.js
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_CONFIG } from '../shared/configSchema.js';
-import { TIER_DEFS } from '../shared/gameData.js';
-import { costAt, costForN, maxAffordable, milestoneMult, tierRate, xpForLevel, computeEffects, computeMults, migrateGain, minigameWafers } from '../shared/gameRules.js';
+import { TIER_DEFS, OVERCLOCK_DEFS } from '../shared/gameData.js';
+import { costAt, costForN, maxAffordable, milestoneMult, tierRate, xpForLevel, computeEffects, computeMults, migrateGain, minigameWafers, overclockBoost } from '../shared/gameRules.js';
+import { initialState } from '../shared/state.js';
const meta0 = { legacyCores: 0, level: 0, upgrades: {}, shardUpgrades: {} };
@@ -52,3 +53,44 @@ describe('gameRules', () => {
expect(minigameWafers('balance', 6, meta0, DEFAULT_CONFIG)).toBe(9);
});
});
+
+describe('overclockBoost (v1.11)', () => {
+ it('is exactly 1 with an empty overclock lane', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const { thresholds, racksMult, overclockMult } = computeMults(s.meta, DEFAULT_CONFIG, 1);
+ const racksOutput = tierRate(10, TIER_DEFS[0].baseProd, racksMult, thresholds);
+ expect(overclockBoost(s.run, DEFAULT_CONFIG, overclockMult, thresholds, racksOutput)).toBe(1);
+ });
+
+ it('is 1 when there is nothing to amplify', () => {
+ const s = initialState();
+ s.run.overclock[0] = { id: 0, owned: 5 };
+ const { thresholds, overclockMult } = computeMults(s.meta, DEFAULT_CONFIG, 1);
+ expect(overclockBoost(s.run, DEFAULT_CONFIG, overclockMult, thresholds, 0)).toBe(1);
+ });
+
+ it('at gain 1 it exactly preserves the pre-v1.11 total', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 40, manager: true, ready: 0 };
+ s.run.overclock[0] = { id: 0, owned: 3 };
+ const { thresholds, racksMult, overclockMult } = computeMults(s.meta, DEFAULT_CONFIG, 1);
+ const racksOutput = tierRate(40, TIER_DEFS[0].baseProd, racksMult, thresholds);
+ const ocOutput = tierRate(3, OVERCLOCK_DEFS[0].baseProd, overclockMult, thresholds);
+ const boost = overclockBoost(s.run, DEFAULT_CONFIG, overclockMult, thresholds, racksOutput);
+ expect(racksOutput * boost).toBeCloseTo(racksOutput + ocOutput, 6);
+ });
+
+ it('scales with the gain tunable', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 40, manager: true, ready: 0 };
+ s.run.overclock[0] = { id: 0, owned: 3 };
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.overclockBoostGain = 2;
+ const { thresholds, racksMult, overclockMult } = computeMults(s.meta, cfg, 1);
+ const racksOutput = tierRate(40, TIER_DEFS[0].baseProd, racksMult, thresholds);
+ const b1 = overclockBoost(s.run, DEFAULT_CONFIG, overclockMult, thresholds, racksOutput);
+ const b2 = overclockBoost(s.run, cfg, overclockMult, thresholds, racksOutput);
+ expect(b2 - 1).toBeCloseTo(2 * (b1 - 1), 9);
+ });
+});
diff --git a/tests/goals.test.js b/tests/goals.test.js
index 3c40b05..d2b8e71 100644
--- a/tests/goals.test.js
+++ b/tests/goals.test.js
@@ -37,17 +37,31 @@ describe('goalCtx', () => {
expect(ctx.totalOutputPerSec).toBeCloseTo(expected);
});
- it('overclock lane contributes 0 while a heat cooldown is active, matching normal computation once cleared', () => {
+ // v1.11: the Overclock lane no longer produces on its own - it multiplies
+ // Racks. So a save with overclock nodes and NO racks now has nothing to
+ // amplify and contributes nothing, which is why this test needs racks to
+ // say anything at all. A live heat cooldown still zeroes the lane's
+ // contribution, exactly as it zeroed its output before.
+ it('overclock lane contributes 0 while a heat cooldown is active, and lifts Racks once cleared', () => {
const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 20, manager: true, ready: 0 };
s.run.overclock[0].owned = 100;
s.run.heatCooldownUntil = NOW + 5000;
const onCooldown = goalCtx(s, DEFAULT_CONFIG, NOW);
- expect(onCooldown.totalOutputPerSec).toBe(0);
const s2 = structuredClone(s);
s2.run.heatCooldownUntil = null;
const normal = goalCtx(s2, DEFAULT_CONFIG, NOW);
- expect(normal.totalOutputPerSec).toBeGreaterThan(0);
+
+ // Frozen: the racks lane alone. Cleared: strictly more than that.
+ expect(onCooldown.totalOutputPerSec).toBeGreaterThan(0);
+ expect(normal.totalOutputPerSec).toBeGreaterThan(onCooldown.totalOutputPerSec);
+ });
+
+ it('a lane with nothing to amplify contributes nothing (v1.11)', () => {
+ const s = initialState();
+ s.run.overclock[0].owned = 100; // no racks owned
+ expect(goalCtx(s, DEFAULT_CONFIG, NOW).totalOutputPerSec).toBe(0);
});
it('includes the active boost multiplier in totalOutputPerSec, and excludes it once expired', () => {
diff --git a/tests/outages.test.js b/tests/outages.test.js
new file mode 100644
index 0000000..9e67c19
--- /dev/null
+++ b/tests/outages.test.js
@@ -0,0 +1,386 @@
+import { describe, it, expect } from 'vitest';
+import {
+ scopeCovers, activeAt, pruneExpired, effectiveFactor, laneOutageFor,
+ hazardFrom, scheduleNextHazard, fireDueHazards, hazardRatePerHour, riskOn,
+ HAZARD_KINDS, MAX_HAZARDS_PER_EVALUATION,
+ SUPPLY_IDS, SUPPLY_FOR_KIND, supplyPrice, cureCost,
+ scheduleGridMaintenance, activateDueMaintenance,
+} from '../shared/outages.js';
+import { DEFAULT_CONFIG } from '../shared/configSchema.js';
+import { initialState } from '../shared/state.js';
+
+function stocked() {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ s.run.tiers[3] = { id: 3, owned: 4, manager: true, ready: 0 };
+ s.run.grid[0] = { id: 0, owned: 5 };
+ return s;
+}
+
+const at = (startAt, endAt, factor, scope, extra = {}) => ({
+ id: `o${startAt}-${endAt}`, kind: 'test', scope, factor, startAt, endAt,
+ source: 'hazard', ...extra,
+});
+
+describe('scopeCovers', () => {
+ it('a wildcard covers every lane and index', () => {
+ expect(scopeCovers({ lane: '*' }, 'tiers', 3)).toBe(true);
+ expect(scopeCovers({ lane: '*' }, 'grid', 0)).toBe(true);
+ });
+ it('a bare lane scope covers every index in that lane only', () => {
+ expect(scopeCovers({ lane: 'grid' }, 'grid', 4)).toBe(true);
+ expect(scopeCovers({ lane: 'grid' }, 'tiers', 4)).toBe(false);
+ });
+ it('an indexed scope covers exactly one index', () => {
+ expect(scopeCovers({ lane: 'tiers', index: 2 }, 'tiers', 2)).toBe(true);
+ expect(scopeCovers({ lane: 'tiers', index: 2 }, 'tiers', 3)).toBe(false);
+ });
+ it('never covers coldstorage, whatever the scope', () => {
+ expect(scopeCovers({ lane: '*' }, 'coldstorage', 0)).toBe(false);
+ });
+});
+
+describe('effectiveFactor', () => {
+ it('is exactly 1 with no outages', () => {
+ expect(effectiveFactor([], 'tiers', 0, 0, 1000)).toBe(1);
+ expect(effectiveFactor(undefined, 'tiers', 0, 0, 1000)).toBe(1);
+ });
+
+ it('an outage entirely outside the window contributes nothing', () => {
+ const o = [at(5000, 6000, 0, { lane: '*' })];
+ expect(effectiveFactor(o, 'tiers', 0, 0, 1000)).toBe(1);
+ expect(effectiveFactor(o, 'tiers', 0, 7000, 8000)).toBe(1);
+ });
+
+ it('an outage covering the whole window is its factor', () => {
+ const o = [at(0, 1000, 0.5, { lane: '*' })];
+ expect(effectiveFactor(o, 'tiers', 0, 0, 1000)).toBe(0.5);
+ });
+
+ it('one straddling an edge contributes exactly its overlap', () => {
+ // 0-factor over [500,1500); window [0,1000) -> half the window dark.
+ const o = [at(500, 1500, 0, { lane: '*' })];
+ expect(effectiveFactor(o, 'tiers', 0, 0, 1000)).toBeCloseTo(0.5, 12);
+ // and the leading edge, same shape
+ const p = [at(-500, 500, 0, { lane: '*' })];
+ expect(effectiveFactor(p, 'tiers', 0, 0, 1000)).toBeCloseTo(0.5, 12);
+ });
+
+ it('overlapping outages multiply inside the overlap', () => {
+ // [0,1000) at 0.5 everywhere, plus [0,500) at 0.5 -> 0.25 then 0.5
+ const o = [at(0, 1000, 0.5, { lane: '*' }), at(0, 500, 0.5, { lane: 'tiers' })];
+ // (500*0.25 + 500*0.5) / 1000
+ expect(effectiveFactor(o, 'tiers', 0, 0, 1000)).toBeCloseTo(0.375, 12);
+ });
+
+ it('ransomware during an ISP outage leaves Grid at 0 and racks at 0.5', () => {
+ const o = [at(0, 1000, 0.5, { lane: '*' }), at(0, 1000, 0, { lane: 'grid' })];
+ expect(effectiveFactor(o, 'grid', 0, 0, 1000)).toBe(0);
+ expect(effectiveFactor(o, 'tiers', 0, 0, 1000)).toBe(0.5);
+ });
+
+ it('only the scoped index is affected', () => {
+ const o = [at(0, 1000, 0, { lane: 'tiers', index: 2 })];
+ expect(effectiveFactor(o, 'tiers', 2, 0, 1000)).toBe(0);
+ expect(effectiveFactor(o, 'tiers', 1, 0, 1000)).toBe(1);
+ });
+
+ it('a zero-length or inverted window is 1, never NaN', () => {
+ const o = [at(0, 1000, 0, { lane: '*' })];
+ expect(effectiveFactor(o, 'tiers', 0, 500, 500)).toBe(1);
+ expect(effectiveFactor(o, 'tiers', 0, 600, 500)).toBe(1);
+ });
+
+ // The closed form must be EXACT, not an approximation - this cross-checks a
+ // messy overlapping case against brute-force numeric sampling.
+ it('matches a brute-force integral on overlapping, partially-covering outages', () => {
+ const messy = [
+ at(120, 880, 0.5, { lane: '*' }),
+ at(300, 600, 0.25, { lane: 'tiers' }),
+ at(700, 1400, 0, { lane: 'tiers', index: 0 }),
+ ];
+ const N = 200000;
+ let acc = 0;
+ for (let i = 0; i < N; i++) {
+ const t = 1000 * ((i + 0.5) / N);
+ let f = 1;
+ for (const o of messy) {
+ if (scopeCovers(o.scope, 'tiers', 0) && o.startAt <= t && t < o.endAt) f *= o.factor;
+ }
+ acc += f;
+ }
+ expect(effectiveFactor(messy, 'tiers', 0, 0, 1000)).toBeCloseTo(acc / N, 4);
+ });
+});
+
+describe('activeAt / pruneExpired / laneOutageFor', () => {
+ it('activeAt is half-open [startAt, endAt)', () => {
+ const o = [at(100, 200, 0, { lane: '*' })];
+ expect(activeAt(o, 99)).toHaveLength(0);
+ expect(activeAt(o, 100)).toHaveLength(1);
+ expect(activeAt(o, 199)).toHaveLength(1);
+ expect(activeAt(o, 200)).toHaveLength(0);
+ });
+
+ it('pruneExpired drops the finished and keeps the running, without mutating', () => {
+ const o = [at(0, 100, 0, { lane: '*' }), at(0, 500, 0, { lane: '*' })];
+ const kept = pruneExpired(o, 200);
+ expect(kept).toHaveLength(1);
+ expect(kept[0].endAt).toBe(500);
+ expect(o).toHaveLength(2);
+ });
+
+ it('laneOutageFor returns the most severe cover, or null', () => {
+ const o = [at(0, 500, 0.5, { lane: '*' }), at(0, 500, 0, { lane: 'grid' })];
+ expect(laneOutageFor(o, 'grid', 0, 100).factor).toBe(0);
+ expect(laneOutageFor(o, 'tiers', 0, 100).factor).toBe(0.5);
+ expect(laneOutageFor(o, 'tiers', 0, 900)).toBeNull();
+ });
+});
+
+describe('hazard derivation', () => {
+ it('is deterministic: the same timestamp derives the same hazard twice', () => {
+ const s = stocked();
+ for (const t of [1_700_000_000_000, 1_700_000_123_456, 999_999_999]) {
+ const a = hazardFrom(t, DEFAULT_CONFIG, s);
+ const b = hazardFrom(t, DEFAULT_CONFIG, s);
+ expect(a).toEqual(b);
+ }
+ });
+
+ it('produces different hazards across different timestamps', () => {
+ const s = stocked();
+ const kinds = new Set();
+ for (let i = 0; i < 300; i++) {
+ const h = hazardFrom(1_700_000_000_000 + i * 997, DEFAULT_CONFIG, s);
+ if (h) kinds.add(h.kind);
+ }
+ expect(kinds.size).toBeGreaterThan(1);
+ });
+
+ it('gives every hazard a stable, derived id - never random', () => {
+ const s = stocked();
+ const h = hazardFrom(1_700_000_000_000, DEFAULT_CONFIG, s);
+ expect(h.id).toBe('hazard:1700000000000');
+ });
+
+ it('scopes each kind as the spec table says', () => {
+ const s = stocked();
+ const seen = {};
+ for (let i = 0; i < 500; i++) {
+ const h = hazardFrom(1_700_000_000_000 + i * 8677, DEFAULT_CONFIG, s);
+ if (h) seen[h.kind] = h;
+ }
+ expect(seen.ransomware.scope).toEqual({ lane: '*' });
+ expect(seen.ransomware.factor).toBe(0.5);
+ expect(seen.ispOutage.scope).toEqual({ lane: 'grid' });
+ expect(seen.driveFailure.scope.lane).toBe('tiers');
+ // only an OWNED tier can fail
+ expect([0, 3]).toContain(seen.driveFailure.scope.index);
+ for (const h of Object.values(seen)) expect(h.source).toBe('hazard');
+ });
+
+ it('never derives a disabled kind', () => {
+ const s = stocked();
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.ransomwareEnabled = false;
+ cfg.risk.ispOutageEnabled = false;
+ for (let i = 0; i < 200; i++) {
+ const h = hazardFrom(1_700_000_000_000 + i * 8677, cfg, s);
+ if (h) expect(h.kind).toBe('driveFailure');
+ }
+ });
+
+ it('returns null when every kind is disabled', () => {
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.ransomwareEnabled = false;
+ cfg.risk.ispOutageEnabled = false;
+ cfg.risk.driveFailureEnabled = false;
+ expect(hazardFrom(1_700_000_000_000, cfg, stocked())).toBeNull();
+ });
+});
+
+describe('hazard scheduling and firing', () => {
+ it('scheduleNextHazard lands inside the configured delay band', () => {
+ const server = { nextHazardAt: 0 };
+ scheduleNextHazard(server, DEFAULT_CONFIG, 1000, () => 0);
+ expect(server.nextHazardAt).toBe(1000 + DEFAULT_CONFIG.risk.hazardMinDelayMs);
+ scheduleNextHazard(server, DEFAULT_CONFIG, 1000, () => 1);
+ expect(server.nextHazardAt).toBe(1000 + DEFAULT_CONFIG.risk.hazardMaxDelayMs);
+ });
+
+ it('schedules the NEXT hazard from the fire time, so a long absence fires many', () => {
+ const s = stocked();
+ const t0 = 1_700_000_000_000;
+ s.server.nextHazardAt = t0;
+ // 3 days later, with the shortest possible delay each time
+ const notices = fireDueHazards(s, DEFAULT_CONFIG, t0 + 3 * 24 * 3600 * 1000, () => 0);
+ expect(notices.length).toBeGreaterThan(1);
+ });
+
+ it('terminates and reschedules when nextHazardAt is far in the past', () => {
+ const s = stocked();
+ s.server.nextHazardAt = 1; // 1970
+ const now = 1_700_000_000_000;
+ const notices = fireDueHazards(s, DEFAULT_CONFIG, now, () => 0);
+ expect(notices.length).toBeLessThanOrEqual(MAX_HAZARDS_PER_EVALUATION);
+ expect(s.server.nextHazardAt).toBeGreaterThan(now);
+ });
+
+ it('does nothing when hazards are disabled', () => {
+ const s = stocked();
+ const t0 = 1_700_000_000_000;
+ s.server.nextHazardAt = t0;
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.hazardsEnabled = false;
+ expect(fireDueHazards(s, cfg, t0 + 1000, () => 0)).toEqual([]);
+ expect(s.server.outages).toEqual([]);
+ });
+
+ it('never pushes the same hazard id twice', () => {
+ const s = stocked();
+ const t0 = 1_700_000_000_000;
+ s.server.nextHazardAt = t0;
+ fireDueHazards(s, DEFAULT_CONFIG, t0 + 1, () => 0);
+ s.server.nextHazardAt = t0; // replay the same instant
+ fireDueHazards(s, DEFAULT_CONFIG, t0 + 1, () => 0);
+ const ids = s.server.outages.map((o) => o.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('reports a rate, never a next time', () => {
+ // default band 4h-8h -> mean 6h -> 1/6 per hour
+ expect(hazardRatePerHour(DEFAULT_CONFIG)).toBeCloseTo(1 / 6, 6);
+ });
+});
+
+describe('stockpiles absorb hazards at fire time', () => {
+ // A timestamp that derives a ransomware hazard, so the test can stock
+ // exactly the supply that counters it.
+ const ransomwareAt = [...Array(500)].map((_, i) => 1_700_000_000_000 + i * 8677)
+ .find((t) => hazardFrom(t, DEFAULT_CONFIG, stocked()).kind === 'ransomware');
+
+ function withStock(counts) {
+ const s = stocked();
+ s.meta.supplies = { antivirus: 0, backupIsp: 0, spareDrives: 0, ...counts };
+ return s;
+ }
+
+ it('consumes exactly one supply, applies no penalty, and says so', () => {
+ const s = withStock({ antivirus: 2 });
+ s.server.nextHazardAt = ransomwareAt;
+ const notices = fireDueHazards(s, DEFAULT_CONFIG, ransomwareAt + 1, () => 0);
+
+ expect(s.server.outages).toEqual([]); // no penalty
+ expect(s.meta.supplies.antivirus).toBe(1); // exactly one consumed
+ const n = notices.find((x) => x.absorbed);
+ expect(n).toMatchObject({
+ kind: 'ransomware', absorbed: true, supply: 'antivirus', remaining: 1,
+ });
+ });
+
+ it('cannot absorb with an empty stockpile', () => {
+ const s = withStock({ antivirus: 0 });
+ s.server.nextHazardAt = ransomwareAt;
+ fireDueHazards(s, DEFAULT_CONFIG, ransomwareAt + 1, () => 0);
+ expect(s.server.outages).toHaveLength(1);
+ expect(s.meta.supplies.antivirus).toBe(0); // never goes negative
+ });
+
+ it('every hazard kind maps to a real supply id', () => {
+ for (const kind of HAZARD_KINDS) expect(SUPPLY_IDS).toContain(SUPPLY_FOR_KIND[kind]);
+ });
+
+ it('prices supplies in seconds of output, with a floor', () => {
+ const cfg = DEFAULT_CONFIG;
+ expect(supplyPrice('antivirus', cfg, 0)).toBe(cfg.risk.supplyPriceMin);
+ expect(supplyPrice('antivirus', cfg, 1000)).toBe(1000 * cfg.risk.antivirusPriceSeconds);
+ });
+});
+
+describe('the reactive cure is always worse than preparing', () => {
+ const haz = (kind, startAt, endAt, factor) => ({
+ id: `hazard:${startAt}`, kind, scope: { lane: '*' }, factor,
+ startAt, endAt, source: 'hazard',
+ });
+
+ it('never costs less than the supply that would have prevented it', () => {
+ const cfg = DEFAULT_CONFIG;
+ for (const kind of HAZARD_KINDS) {
+ for (const rate of [0, 1, 1e3, 1e9]) {
+ for (const elapsed of [0, 0.25, 0.5, 0.99]) {
+ const start = 1_000_000;
+ const end = start + 1_800_000;
+ const now = start + (end - start) * elapsed;
+ const cure = cureCost(haz(kind, start, end, 0), cfg, rate, now);
+ const prep = supplyPrice(SUPPLY_FOR_KIND[kind], cfg, rate);
+ expect(cure).toBeGreaterThan(prep);
+ }
+ }
+ }
+ });
+
+ it('costs more the more time is left to buy back', () => {
+ const cfg = DEFAULT_CONFIG;
+ const h = haz('ransomware', 0, 1000, 0.5);
+ expect(cureCost(h, cfg, 1000, 100)).toBeGreaterThan(cureCost(h, cfg, 1000, 900));
+ });
+});
+
+describe('grid maintenance is telegraphed, not sprung', () => {
+ it('schedules a window at least the minimum delay ahead', () => {
+ const server = { gridMaintenance: null };
+ scheduleGridMaintenance(server, DEFAULT_CONFIG, 1000, () => 0);
+ expect(server.gridMaintenance.startAt).toBe(1000 + DEFAULT_CONFIG.risk.maintenanceMinDelayMs);
+ expect(server.gridMaintenance.endAt - server.gridMaintenance.startAt)
+ .toBe(DEFAULT_CONFIG.risk.maintenanceDurationMs);
+ expect(server.gridMaintenance.index).toBeGreaterThanOrEqual(0);
+ });
+
+ it('does not activate before its start time', () => {
+ const s = stocked();
+ s.server.gridMaintenance = { index: 2, startAt: 5000, endAt: 6000 };
+ expect(activateDueMaintenance(s, DEFAULT_CONFIG, 4999)).toBeNull();
+ expect(s.server.outages).toEqual([]);
+ expect(s.server.gridMaintenance).not.toBeNull(); // still telegraphed
+ });
+
+ it('activates into a scoped, zero-factor outage and clears the slot', () => {
+ const s = stocked();
+ s.server.gridMaintenance = { index: 2, startAt: 5000, endAt: 6000 };
+ const o = activateDueMaintenance(s, DEFAULT_CONFIG, 5000);
+ expect(o).toMatchObject({
+ kind: 'maintenance', source: 'scheduled', factor: 0,
+ scope: { lane: 'grid', index: 2 }, startAt: 5000, endAt: 6000,
+ id: 'maintenance:5000',
+ });
+ expect(s.server.outages).toHaveLength(1);
+ expect(s.server.gridMaintenance).toBeNull();
+ });
+
+ it('does nothing when maintenance is disabled', () => {
+ const s = stocked();
+ s.server.gridMaintenance = { index: 2, startAt: 5000, endAt: 6000 };
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.maintenanceEnabled = false;
+ expect(activateDueMaintenance(s, cfg, 9000)).toBeNull();
+ expect(s.server.outages).toEqual([]);
+ });
+});
+
+describe('riskOn ANDs the master switch first', () => {
+ it('is false whenever the master is off, whatever the source says', () => {
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.enabled = false;
+ for (const key of ['hazardsEnabled', 'maintenanceEnabled', 'overheatShutdownEnabled']) {
+ cfg.risk[key] = true;
+ expect(riskOn(cfg, key)).toBe(false);
+ }
+ });
+ it('is true only when both are on', () => {
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ expect(riskOn(cfg, 'hazardsEnabled')).toBe(true);
+ cfg.risk.hazardsEnabled = false;
+ expect(riskOn(cfg, 'hazardsEnabled')).toBe(false);
+ });
+});
diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js
index 52b8d1d..114e130 100644
--- a/tests/reducer.economy.test.js
+++ b/tests/reducer.economy.test.js
@@ -367,3 +367,88 @@ describe('reducer: vent', () => {
expect(s2.run.heat).toBe(0);
});
});
+
+describe('buySupply (v1.11)', () => {
+ it('buys one, charges credits, and stacks', () => {
+ const s = initialState();
+ s.run.credits = 1e9;
+ const { state: s1, result: r1 } = applyAction(s, { type: 'buySupply', id: 'antivirus' }, DEFAULT_CONFIG, 1000);
+ expect(r1.ok).toBe(true);
+ expect(s1.meta.supplies.antivirus).toBe(1);
+ expect(s1.run.credits).toBeLessThan(1e9);
+
+ const { state: s2 } = applyAction(s1, { type: 'buySupply', id: 'antivirus' }, DEFAULT_CONFIG, 1000);
+ expect(s2.meta.supplies.antivirus).toBe(2);
+ });
+
+ it('rejects an unknown supply id without touching anything', () => {
+ const s = initialState();
+ s.run.credits = 1e9;
+ const { state: s1, result } = applyAction(s, { type: 'buySupply', id: '__proto__' }, DEFAULT_CONFIG, 1000);
+ expect(result).toEqual({ ok: false, error: 'invalid_target' });
+ expect(s1.run.credits).toBe(1e9);
+ });
+
+ it('rejects when the player cannot afford it', () => {
+ const s = initialState();
+ s.run.credits = 0;
+ const { result } = applyAction(s, { type: 'buySupply', id: 'backupIsp' }, DEFAULT_CONFIG, 1000);
+ expect(result).toEqual({ ok: false, error: 'insufficient_credits' });
+ });
+
+ it('supplies survive a Migrate', () => {
+ const s = initialState();
+ s.meta.supplies.spareDrives = 3;
+ s.run.lifetimeRun = 1e12;
+ const { state: s1, result } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, 1000);
+ expect(result.ok).toBe(true);
+ expect(s1.meta.supplies.spareDrives).toBe(3);
+ });
+});
+
+describe('resolveOutage (v1.11)', () => {
+ function withOutage(extra = {}) {
+ const s = initialState();
+ s.run.credits = 1e12;
+ s.server.outages = [{
+ id: 'hazard:1000', kind: 'ransomware', scope: { lane: '*' }, factor: 0.5,
+ startAt: 1000, endAt: 100000, source: 'hazard', ...extra,
+ }];
+ return s;
+ }
+
+ it('ends a running hazard early and charges for it', () => {
+ const s = withOutage();
+ const { state: s1, result } = applyAction(s, { type: 'resolveOutage', id: 'hazard:1000' }, DEFAULT_CONFIG, 50000);
+ expect(result.ok).toBe(true);
+ expect(result.cost).toBeGreaterThan(0);
+ expect(s1.run.credits).toBeLessThan(1e12);
+ // truncated, not deleted - a window straddling the cure still sees the
+ // time it was actually down
+ expect(s1.server.outages[0].endAt).toBe(50000);
+ });
+
+ it('refuses a hazard that already ended - no retroactive refunds', () => {
+ const s = withOutage();
+ const { result } = applyAction(s, { type: 'resolveOutage', id: 'hazard:1000' }, DEFAULT_CONFIG, 200000);
+ expect(result).toEqual({ ok: false, error: 'invalid_target' });
+ });
+
+ it('refuses scheduled maintenance and self-inflicted overheats', () => {
+ for (const source of ['scheduled', 'overheat']) {
+ const s = withOutage({ source, kind: source === 'scheduled' ? 'maintenance' : 'overheat' });
+ const { result } = applyAction(s, { type: 'resolveOutage', id: 'hazard:1000' }, DEFAULT_CONFIG, 50000);
+ expect(result).toEqual({ ok: false, error: 'invalid_target' });
+ }
+ });
+
+ it('refuses an unknown id and an unaffordable cure', () => {
+ const s = withOutage();
+ expect(applyAction(s, { type: 'resolveOutage', id: 'nope' }, DEFAULT_CONFIG, 50000).result)
+ .toEqual({ ok: false, error: 'invalid_target' });
+ const poor = withOutage();
+ poor.run.credits = 0;
+ expect(applyAction(poor, { type: 'resolveOutage', id: 'hazard:1000' }, DEFAULT_CONFIG, 50000).result)
+ .toEqual({ ok: false, error: 'insufficient_credits' });
+ });
+});
diff --git a/tests/state.test.js b/tests/state.test.js
index 86d3209..43a298e 100644
--- a/tests/state.test.js
+++ b/tests/state.test.js
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { DEFAULT_CONFIG } from '../shared/configSchema.js';
import { initialState, migrateSave, evaluate } from '../shared/state.js';
+import { GRID_DEFS } from '../shared/gameData.js';
const fixture = JSON.parse(readFileSync(new URL('./fixtures/v11-save.json', import.meta.url)));
@@ -186,3 +187,222 @@ describe('coldStorage state wiring', () => {
expect(s2.run.tiers[0].ready).toBeCloseTo(10 * 0.5 * 9 * 3600, 0); // capped at 9h, not the base 4h
});
});
+
+describe('evaluate with outages (v1.11)', () => {
+ const outage = (startAt, endAt, factor, scope) => ({
+ id: `x${startAt}`, kind: 'test', scope, factor, startAt, endAt, source: 'hazard',
+ });
+
+ it('zero outages leaves online production identical to today', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const t0 = 1_000_000;
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, t0, t0 + 30_000);
+ expect(s2.run.credits).toBeCloseTo(10 + 150);
+ expect(s2.server.outages).toEqual([]);
+ });
+
+ it('a full-window outage at 0 stops that lane dead', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const t0 = 1_000_000;
+ s.server.outages = [outage(t0, t0 + 30_000, 0, { lane: 'tiers', index: 0 })];
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, t0, t0 + 30_000);
+ expect(s2.run.credits).toBeCloseTo(10);
+ });
+
+ it('half a window dark pays exactly half', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const t0 = 1_000_000;
+ s.server.outages = [outage(t0 + 15_000, t0 + 30_000, 0, { lane: '*' })];
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, t0, t0 + 30_000);
+ expect(s2.run.credits).toBeCloseTo(10 + 75);
+ });
+
+ it('the offline cap samples the WHOLE absence proportionally', () => {
+ // 12h absent, 4h capped payout, an outage covering 6h of the absence.
+ // The credited amount is the 4h payout * 0.5, NOT the first 4h unaffected.
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const t0 = 1_000_000;
+ const twelveH = 12 * 3600 * 1000;
+ s.server.outages = [outage(t0 + 6 * 3600 * 1000, t0 + twelveH, 0, { lane: '*' })];
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, t0, t0 + twelveH);
+ // 4h cap * 10 pis * 0.5 F/s = 72000, halved by the sampled factor
+ expect(s2.run.credits).toBeCloseTo(10 + 36000);
+ });
+
+ it('Cold Storage is untouched by a wildcard outage', () => {
+ const mk = () => {
+ const s = initialState();
+ s.meta.coldStorage.job = { type: 'defrag', accruedOfflineSec: 0, startedAt: 0 };
+ return s;
+ };
+ const t0 = 1_000_000;
+ const twelveH = 12 * 3600 * 1000;
+ const clean = evaluate(mk(), DEFAULT_CONFIG, t0, t0 + twelveH).state;
+ const hit = mk();
+ hit.server.outages = [outage(t0, t0 + twelveH, 0, { lane: '*' })];
+ const dark = evaluate(hit, DEFAULT_CONFIG, t0, t0 + twelveH).state;
+ expect(dark.meta.coldStorage.job.accruedOfflineSec)
+ .toBe(clean.meta.coldStorage.job.accruedOfflineSec);
+ expect(dark.meta.coldStorage.tapes).toBe(clean.meta.coldStorage.tapes);
+ });
+
+ it('prunes outages that ended before now', () => {
+ const s = initialState();
+ const t0 = 1_000_000;
+ s.server.outages = [outage(t0, t0 + 1000, 0, { lane: '*' })];
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, t0, t0 + 30_000);
+ expect(s2.server.outages).toEqual([]);
+ });
+
+ it('migrateSave defaults and shape-pins the v1.11 server fields', () => {
+ const pre = { run: { credits: 5 }, meta: {}, server: { outages: 'not-an-array' } };
+ const s = migrateSave(pre);
+ expect(s.server.outages).toEqual([]);
+ expect(s.server.nextHazardAt).toBe(0);
+ expect(s.server.gridMaintenance).toBeNull();
+ });
+
+ it('an activated maintenance window darkens only its own grid node', () => {
+ const s = initialState();
+ s.run.grid[2] = { id: 2, owned: 10 };
+ s.run.grid[0] = { id: 0, owned: 10 };
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.hazardsEnabled = false; // isolate maintenance
+ const t0 = 1_000_000;
+ s.server.gridMaintenance = { index: 2, startAt: t0, endAt: t0 + 30_000 };
+ const { state: s2 } = evaluate(s, cfg, t0, t0 + 30_000);
+ // node 0 paid in full, node 2 paid nothing
+ const expected = 10 * GRID_DEFS[0].baseProd * 30;
+ expect(s2.run.credits).toBeCloseTo(10 + expected);
+ });
+});
+
+describe('the Overclock rework (v1.11)', () => {
+ const quiet = () => {
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.hazardsEnabled = false;
+ cfg.risk.maintenanceEnabled = false;
+ return cfg;
+ };
+
+ it('overheating knocks a rack tier offline instead of freezing the lane', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ s.run.overclock[0] = { id: 0, owned: 200 };
+ const cfg = quiet();
+ cfg.heat.capacity = 100;
+ const t0 = 1_000_000;
+ const { state: s2 } = evaluate(s, cfg, t0, t0 + 10_000);
+ expect(s2.server.overheated).toBe(true);
+ expect(s2.run.heat).toBe(0);
+ expect(s2.run.heatCooldownUntil).toBeNull();
+ const o = s2.server.outages.find((x) => x.source === 'overheat');
+ expect(o).toBeTruthy();
+ expect(o.scope.lane).toBe('tiers');
+ expect(o.factor).toBe(0);
+ });
+
+ it('falls back to the legacy lane freeze when the shutdown is disabled', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ s.run.overclock[0] = { id: 0, owned: 200 };
+ const cfg = quiet();
+ cfg.heat.capacity = 100;
+ cfg.risk.overheatShutdownEnabled = false;
+ const t0 = 1_000_000;
+ const { state: s2 } = evaluate(s, cfg, t0, t0 + 10_000);
+ expect(s2.server.overheated).toBe(true);
+ expect(s2.run.heatCooldownUntil).toBe(t0 + 10_000 + cfg.heat.overheatCooldownMs);
+ expect(s2.server.outages.some((x) => x.source === 'overheat')).toBe(false);
+ });
+
+ it('the overheat victim is derived, so two evaluations agree', () => {
+ const mk = () => {
+ const s = initialState();
+ for (const i of [0, 2, 5]) s.run.tiers[i] = { id: i, owned: 9, manager: true, ready: 0 };
+ s.run.overclock[0] = { id: 0, owned: 200 };
+ return s;
+ };
+ const cfg = quiet();
+ cfg.heat.capacity = 100;
+ const t0 = 1_000_000;
+ const a = evaluate(mk(), cfg, t0, t0 + 10_000).state;
+ const b = evaluate(mk(), cfg, t0, t0 + 10_000).state;
+ const pick = (st) => st.server.outages.find((x) => x.source === 'overheat').scope.index;
+ expect(pick(a)).toBe(pick(b));
+ expect([0, 2, 5]).toContain(pick(a));
+ });
+});
+
+describe('the kill switch and decision 1 (v1.11)', () => {
+ it('the kill switch clears live outages and restores full production', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const t0 = 1_000_000;
+ s.server.outages = [{
+ id: 'hazard:1', kind: 'ransomware', scope: { lane: '*' }, factor: 0,
+ startAt: t0 - 1000, endAt: t0 + 1e9, source: 'hazard',
+ }];
+ s.server.gridMaintenance = { index: 1, startAt: t0, endAt: t0 + 1e6 };
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.enabled = false;
+
+ const { state: s2 } = evaluate(s, cfg, t0, t0 + 30_000);
+ expect(s2.server.outages).toEqual([]); // cleared, not paused
+ expect(s2.server.gridMaintenance).toBeNull();
+ expect(s2.run.credits).toBeCloseTo(10 + 150); // paid in full
+ });
+
+ it('the master switch beats every per-source switch', () => {
+ const s = initialState();
+ s.run.tiers[0] = { id: 0, owned: 10, manager: true, ready: 0 };
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.enabled = false;
+ cfg.risk.hazardsEnabled = true;
+ cfg.risk.maintenanceEnabled = true;
+ cfg.risk.overheatShutdownEnabled = true;
+ const t0 = 1_000_000;
+ s.server.nextHazardAt = t0;
+ const { state: s2 } = evaluate(s, cfg, t0, t0 + 7 * 24 * 3600 * 1000);
+ expect(s2.server.outages).toEqual([]);
+ });
+
+ it('DECISION 1: no hazard ever reduces a stored value', () => {
+ // A randomised sweep. meta.supplies is excluded BY DESIGN - it is a
+ // consumable the player bought to be spent. Everything else is a
+ // guardrail against a later change reintroducing asset loss.
+ const cfg = structuredClone(DEFAULT_CONFIG);
+ cfg.risk.hazardMinDelayMs = 60000;
+ cfg.risk.hazardMaxDelayMs = 120000;
+
+ for (let seed = 0; seed < 60; seed++) {
+ const s = initialState();
+ s.run.credits = 5000;
+ s.run.lifetimeRun = 5000;
+ s.meta.wafers = 40;
+ s.meta.coldStorage.tapes = 25;
+ for (const i of [0, 1, 2]) s.run.tiers[i] = { id: i, owned: 6 + i, manager: i % 2 === 0, ready: 3 };
+ s.run.grid[0] = { id: 0, owned: 4 };
+ s.run.overclock[0] = { id: 0, owned: 2 };
+ s.server.nextHazardAt = 1_000_000 + seed * 1013;
+
+ const before = {
+ credits: s.run.credits, wafers: s.meta.wafers,
+ tapes: s.meta.coldStorage.tapes,
+ owned: s.run.tiers.map((t) => t.owned),
+ lifetime: s.run.lifetimeRun,
+ };
+ const { state: after } = evaluate(s, cfg, 1_000_000, 1_000_000 + 6 * 3600 * 1000);
+
+ expect(after.run.credits).toBeGreaterThanOrEqual(before.credits);
+ expect(after.meta.wafers).toBe(before.wafers);
+ expect(after.meta.coldStorage.tapes).toBe(before.tapes);
+ expect(after.run.lifetimeRun).toBeGreaterThanOrEqual(before.lifetime);
+ after.run.tiers.forEach((t, i) => expect(t.owned).toBe(before.owned[i]));
+ }
+ });
+});
diff --git a/tests/tours.test.js b/tests/tours.test.js
index 7db569c..6fcf0ab 100644
--- a/tests/tours.test.js
+++ b/tests/tours.test.js
@@ -87,8 +87,10 @@ describe('client tour content', () => {
const onboarding = CLIENT_TOURS.onboarding;
const full = resolveSteps(onboarding, FULL_CTX);
const fresh = resolveSteps(onboarding, FRESH_CTX);
- expect(full.length).toBe(17);
- expect(fresh.length).toBe(11);
+ // v1.11 added 2 resilience steps with no visibleWhen - a fresh save can be
+ // hit by a hazard, so they are never gated - hence both counts move by 2.
+ expect(full.length).toBe(19);
+ expect(fresh.length).toBe(13);
expect(fresh.every((s) => s.tab !== 'coldstorage')).toBe(true);
expect(fresh.every((s) => s.tab !== 'event')).toBe(true);
});
@@ -98,7 +100,7 @@ describe('tour selection', () => {
it('selects onboarding for a user who has completed nothing', () => {
const sel = selectTour(CLIENT_TOURS, TOUR_IDS, [], FRESH_CTX);
expect(sel.id).toBe(ONBOARDING_TOUR_ID);
- expect(sel.steps.length).toBe(11);
+ expect(sel.steps.length).toBe(13); // v1.11: +2 resilience steps
});
it('selects nothing once onboarding is complete', () => {