From 80c12fb4423265828e9908c5b8bba3ccef302363 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 18:24:15 -0400 Subject: [PATCH 01/14] Design v1.11 Risk & Reliability: one outage model, three sources Approved brainstorm covering backlog items B (hazards + mitigation), C (dynamic Grid) and D (Overclock rework). The seven owner decisions are recorded as locked, with the reasoning, so planning does not relitigate them. Co-Authored-By: Claude Opus 5 --- ...026-08-08-v1.11-risk-reliability-design.md | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md 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. From c895dc9faa382de2fac4722290ac1c362d260c2d Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:29:06 -0400 Subject: [PATCH 02/14] Plan v1.11 Risk & Reliability: 11 tasks, one outage model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the approved design into an executable plan. Task order front-loads the risk exactly as spec §13 asks: the config boolean type, then the pure integral, then evaluation wiring that proves zero outages changes nothing, then each source in turn, with the breaking Overclock rework deliberately late and the goals suite as its net. Two things worth flagging for whoever executes it: - The Overclock conversion is defined as a RATIO of the Racks lane (1 + gain * ocOutput/racksOutput). At the default gain of 1 that is algebraically racksOutput + ocOutput, so goalCtx.totalOutputPerSec is unchanged on the deploy and the existing goals/contracts/achievements suites pass untouched - which is what de-risks "the highest-risk edit in the release". The balance pass becomes one tunable. - effectiveFactor takes (outages, lane, index, from, to) rather than the spec's (outages, scope, from, to). Stated as a deviation in the plan; every call site is a loop over a lane's indices and the sketched signature would allocate a throwaway scope object per tier per evaluation. Semantics identical. Every signature in the plan was re-verified against this branch first (req.user.sub, the `tiers` lane name, scheduleAnomaly's shape), and the integral, the derivation hash, the conversion algebra and the cure's price floor were all checked numerically before the plan was committed. Co-Authored-By: Claude Opus 5 --- .../2026-08-08-v1.11-risk-reliability.md | 3495 +++++++++++++++++ 1 file changed, 3495 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md new file mode 100644 index 0000000..e00971f --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md @@ -0,0 +1,3495 @@ +# v1.11 Risk & Reliability — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the game a second axis — output you can lose and must defend — +by unifying hazards, scheduled Grid maintenance and the reworked Overclock +overheat penalty into one admin-toggleable outage model. + +**Architecture:** Every effect in this release is the same object: an *outage* +(`{ id, kind, scope, factor, startAt, endAt, source }`) living in +`state.server.outages`. A new pure module `shared/outages.js` owns the model, +the closed-form piecewise-constant integral that applies outages to a +production window, deterministic hazard derivation, and the schedulers. +`evaluate()` calls into that module rather than growing the logic itself. +Overclock stops producing FLOPS directly and instead multiplies the Racks lane, +which makes overheating a rack-tier shutdown rather than a lane freeze. + +**Tech Stack:** Node 20, Express, React 18 + Vite, vitest, better-sqlite3 and +node-postgres (both backends must pass), Playwright for smoke suites. + +**Spec:** `docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md` + +**Branch:** `v1.11-risk-reliability` + +## Global Constraints + +- **`shared/` must not import from `client/`** and must stay free of runtime + dependencies. `shared/outages.js` may import from `shared/gameData.js` only. +- **The server is authoritative.** Anything the client computes is a request, + never a fact. +- **Decision 1 is absolute: no hazard may ever reduce a stored value.** Hazards + multiply *production*. Nothing in this release may subtract from + `run.credits`, `meta.wafers`, `meta.coldStorage.tapes`, or any `owned`. + The one deliberate exception is `meta.supplies`, which is a consumable the + player bought for exactly this purpose — Task 5 defines it, and the property + test in Task 9 excludes it by name. +- **Decision 3: hazards are never telegraphed.** `server.nextHazardAt` must + never reach the client's UI. Only the *rate*, derived from config, is shown. + Grid maintenance is the opposite — it is scheduled ahead and visible. +- **Decision 6: Cold Storage is a safe harbour.** No outage scope may ever + cover it. Block accrual, tapes, jobs and tape upgrades are untouched. +- **Decision 7: everything is admin-toggleable**, and `risk.enabled` is a true + kill switch that clears live outages, not a pause. +- **The offline cap samples the whole absence** (decision 5). The factor is + computed over `[lastEvaluatedAt, now]` and applied to the *capped* payout. + This is deliberate and must be commented at the call site. +- **Both backends must pass:** `npm run test:all` (SQLite and Postgres). + 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 + ``` +- **No database schema migration.** `run`/`meta`/`server` are JSON inside the + save; `migrateSave()` defaults and shape-pins every new field. +- **Docs change in the task that changes the behaviour**, not afterwards. +- **Commit after every task.** + +### Verified signatures (checked against the code on this branch) + +The v1.10 plan's snippets were wrong four times. These were re-verified before +this plan was written — use them as given: + +| Fact | Verified | +|---|---| +| `requireAuth` populates **`req.user.sub`**, never `req.user.id` | `server/auth.js:191`, used throughout `server/routes/api.js` | +| The racks lane is **`tiers`**, not `racks` | `LANE_DEFS` in `shared/reducer.js:13` | +| `scheduleAnomaly(server, config, now, rng = Math.random)` | `shared/reducer.js:392` | +| `applyAction(state, action, config, now, rng = Math.random)`; handlers are `(s, action, config, now, rng)` | `shared/reducer.js:654` | +| `evaluate(state, config, lastEvaluatedAt, now)` returns `{ state, gained }` | `shared/state.js:182` | +| `computeMults(meta, config, boostMult = 1)` returns `{ eff, thresholds, racksMult, gridMult, overclockMult }` | `shared/gameRules.js:85` | +| `tierRate(owned, baseProd, mult, thresholds)` | `shared/gameRules.js:26` | +| `goalCtx(state, config, now)` returns `{ run, meta, totalOutputPerSec, unlockedUpTo }` | `shared/goals.js:42` | +| `validateConfig` currently requires **every** tunable to be a number | `shared/configSchema.js:203` | +| `validateModifiers` also requires modifier values to be numbers | `shared/events.js:76` | +| `err(...)` codes in use: `invalid_target`, `insufficient_credits`, `not_met`, `cooldown_active`, `max_level`, `no_milestone`, `already_automated` | `shared/reducer.js` | + +### One stated deviation from the spec + +Spec §4 sketches `effectiveFactor(outages, scope, from, to)`. This plan uses +**`effectiveFactor(outages, lane, index, from, to)`** instead. Rationale: every +call site is a loop over a lane's indices, and the sketched signature would +require allocating a throwaway `{ lane, index }` object per tier per +evaluation. The semantics are identical. Nothing else in the spec is changed — +all seven decisions in §2 are implemented as written. + +--- + +### Task 1: Boolean tunables, and the `risk` config block + +Front-loads the only piece of shared machinery that does not exist yet. +`validateConfig` accepts numbers only, so a toggle has nowhere to live until +this lands. Encoding toggles as 0/1 numbers is explicitly rejected by the spec +(§8): a 0/1 "boolean" is exactly the kind of thing that later gets set to 2. + +**Files:** +- Modify: `shared/configSchema.js` (`DEFAULT_CONFIG`, `TUNABLES`, `validateConfig` at :194, `upgradeConfig` at :210) +- Modify: `shared/events.js` (`validateModifiers` at :66) +- Modify: `client/src/game/components/profile/AdminBalancing.jsx` (`rawFromData`, `fieldStatus`, `GROUP_LABELS`, the input render) +- Modify: `client/src/game/components/profile/AdminEvents.jsx` (`GROUP_LABELS`, the modifier path picker) +- Test: `tests/configSchema.test.js`, `tests/events.test.js` + +**Interfaces:** +- Produces: `TUNABLES` entries may now carry `type: 'boolean'`. Entries with no + `type` are numeric, exactly as today — no existing row changes. +- Produces: `config.risk.*` — the full block every later task reads. Boolean + keys: `enabled`, `hazardsEnabled`, `maintenanceEnabled`, + `overheatShutdownEnabled`, `ransomwareEnabled`, `ispOutageEnabled`, + `driveFailureEnabled`. Numeric keys as listed in Step 3. +- Produces: `AdminBalancing`'s `fieldStatus()` now returns `{ value, valid, dirty }` + (renamed from `num`, because it now carries a boolean for boolean rows). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/configSchema.test.js`: + +```js +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); + } + }); +}); +``` + +Replace the existing `'every TUNABLES path resolves in DEFAULT_CONFIG and is in range'` +test body (it asserts `toBeTypeOf('number')` for every row, which boolean rows +would fail) with: + +```js + it('every TUNABLES path resolves in DEFAULT_CONFIG and is in range', () => { + for (const t of TUNABLES) { + const v = getAtPath(DEFAULT_CONFIG, t.path); + 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); + } + }); +``` + +Add to `tests/events.test.js`: + +```js +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); + }); +}); +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: `TEST_BACKEND=sqlite npx vitest run tests/configSchema.test.js tests/events.test.js` +Expected: FAIL — `DEFAULT_CONFIG.risk` is undefined. + +- [ ] **Step 3: Add the `risk` block to `DEFAULT_CONFIG`** + +In `shared/configSchema.js`, add after the `social` block (keep the trailing +comma on `social`): + +```js + // 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 Task 8. + overclockBoostGain: 1, + }, +``` + +Then append the `TUNABLES` rows at the end of the array: + +```js + { 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 }, +``` + +- [ ] **Step 4: Teach `validateConfig` and `upgradeConfig` about booleans** + +Replace the `for (const t of TUNABLES)` loop body inside `validateConfig`: + +```js + for (const t of TUNABLES) { + const v = getAtPath(doc, t.path); + // A boolean tunable accepts ONLY a boolean, and a numeric tunable only a + // number. Both directions are enforced: without the second half, a + // boolean assigned to a numeric path would sail through `typeof v !== + // 'number'`... it wouldn't, but a future `type` value would, and a 0/1 + // "boolean" on a boolean path is exactly what this type exists to stop. + 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`); + } +``` + +And `upgradeConfig`'s loop body: + +```js + 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); + } +``` + +- [ ] **Step 5: Keep event modifiers numeric-only** + +Live events overlay config through `mergeEventModifiers`, and a boolean path +reached by a numeric modifier would produce a document `validateConfig` then +rejects. Event modifiers stay numeric — an event may turn the risk system *up*, +but may not flip its switches. In `shared/events.js`, inside +`validateModifiers`'s loop, replace the value check: + +```js + const tDef = TUNABLES.find((t) => t.path === path); + if (tDef && tDef.type === 'boolean') { + // 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. + 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`); + } +``` + +- [ ] **Step 6: Render boolean tunables as checkboxes** + +In `client/src/game/components/profile/AdminBalancing.jsx`: + +Add to `GROUP_LABELS`: + +```js + // v1.11. Keep in sync with AdminEvents.jsx's copy of this map. + risk: 'Risk & Reliability', +``` + +Replace `rawFromData` and `fieldStatus`: + +```js +function rawFromData(data) { + const out = {}; + for (const t of TUNABLES) { + const v = getAtPath(data, t.path); + out[t.path] = t.type === 'boolean' ? v === true : String(v); + } + return out; +} + +// Parses/validates one field's current raw input against its TUNABLES row, +// and reports whether it differs from the last-known server value. A boolean +// row is always valid - a checkbox cannot hold a malformed value - so only +// `dirty` is meaningful for it. +function fieldStatus(raw, serverValue, tunable) { + if (tunable.type === 'boolean') { + const value = raw === true; + return { value, valid: true, dirty: value !== (serverValue === true) }; + } + const num = raw === '' ? NaN : Number(raw); + const valid = raw !== '' && !Number.isNaN(num) + && num >= tunable.min && num <= tunable.max + && (!tunable.integer || Number.isInteger(num)); + const dirty = valid ? num !== serverValue : String(raw) !== String(serverValue); + return { value: num, valid, dirty }; +} +``` + +In `handleSave`, change the write-back to use the renamed field: + +```js + for (const t of TUNABLES) setAtPath(clone, t.path, statuses[t.path].value); +``` + +In the render, replace the `` element and the range +hint beneath it with a branch on `t.type`: + +```js + {t.type === 'boolean' ? ( + handleChange(t.path, e.target.checked)} + className="w-4 h-4" + style={{ accentColor: amber }} + /> + ) : ( + handleChange(t.path, e.target.value)} + step={t.integer ? 1 : 'any'} + className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" + style={{ + background: '#0E141B', + border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, + color: st.valid ? textMain : danger, + }} + /> + )} +``` + +and the hint line: + +```js +
+ {t.type === 'boolean' + ? `default ${String(defaultVal)}` + : `range [${t.min}, ${t.max}]${t.integer ? ', integer' : ''} · default ${defaultVal}`} +
+``` + +- [ ] **Step 7: Hide boolean rows from the event modifier picker** + +In `client/src/game/components/profile/AdminEvents.jsx`, add the same +`risk: 'Risk & Reliability',` entry to its `GROUP_LABELS`, then make its +grouping skip boolean rows so the ` handleChange(t.path, e.target.value)} - step={t.integer ? 1 : 'any'} - className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" - style={{ - background: '#0E141B', - border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, - color: st.valid ? textMain : danger, - }} - /> + {t.type === 'boolean' ? ( + handleChange(t.path, e.target.checked)} + className="w-4 h-4" + style={{ accentColor: amber }} + /> + ) : ( + handleChange(t.path, e.target.value)} + step={t.integer ? 1 : 'any'} + className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" + style={{ + background: '#0E141B', + border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, + color: st.valid ? textMain : danger, + }} + /> + )}
- range [{t.min}, {t.max}]{t.integer ? ', integer' : ''} · default {defaultVal} + {t.type === 'boolean' + ? `default ${String(defaultVal)}` + : `range [${t.min}, ${t.max}]${t.integer ? ', integer' : ''} · default ${defaultVal}`}
{err &&
{err}
} diff --git a/client/src/game/components/profile/AdminEvents.jsx b/client/src/game/components/profile/AdminEvents.jsx index a3dd17e..eb4f95c 100644 --- a/client/src/game/components/profile/AdminEvents.jsx +++ b/client/src/game/components/profile/AdminEvents.jsx @@ -56,11 +56,18 @@ const GROUP_LABELS = { anomaly: 'Anomaly', upgrades: 'Upgrade max levels', batchQueue: 'Cold Storage (batch queue)', + // v1.11. Keep in sync with AdminBalancing.jsx's copy of this map. + risk: 'Risk & Reliability', }; const TUNABLE_GROUPS = (() => { const order = []; const byKey = new Map(); for (const t of TUNABLES) { + // v1.11: boolean tunables are admin-only, never event-overlayable + // (validateModifiers rejects them), so they must not be offerable in the + // modifier path picker - and ModifierRow's min/max validation would read + // undefined on them anyway. + if (t.type === 'boolean') continue; const key = groupKeyFor(t.path); if (!byKey.has(key)) { byKey.set(key, []); order.push(key); } byKey.get(key).push(t); diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md new file mode 100644 index 0000000..91ce34c --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -0,0 +1,60 @@ +# v1.11 Risk & Reliability — execution notes + +Companion to `2026-08-08-v1.11-risk-reliability.md`. Same role as +`2026-08-08-v1.10-qol-notes.md` had for v1.10: a running log of what is done, +what surprised us, and exactly where to pick up. + +**Branch:** `v1.11-risk-reliability` (worktree +`.claude/worktrees/v1.9-supertokens-client`) + +## Status + +| Task | State | Commit | +|---|---|---| +| 1. Boolean tunables + `risk` config block | not started | — | +| 2. `shared/outages.js` + the integral | not started | — | +| 3. Evaluation wiring, no sources | not started | — | +| 4. Hazards: derivation, scheduling, firing | not started | — | +| 5. Stockpiles and absorption | not started | — | +| 6. The reactive cure | not started | — | +| 7. Grid maintenance | not started | — | +| 8. The Overclock rework | not started | — | +| 9. Master kill switch + decision-1 property | not started | — | +| 10. Client surfaces | not started | — | +| 11. Smoke, changelog, version, release | not started | — | + +## How to resume + +1. `cd` to the worktree above; confirm `git branch --show-current` is + `v1.11-risk-reliability`. +2. Read this file's Status table for the first `not started` task. +3. Open the plan at that task and follow its steps verbatim. +4. Update this file after **every** task — the table, plus a Log entry for + anything that differed from the plan. + +Test commands (from the worktree root): + +```bash +TEST_BACKEND=sqlite npx vitest run # fast inner loop +npm run test:all # both backends, needs podman +node tests/e2e/smoke-v111.mjs # once Task 11 exists +``` + +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 +``` + +## Log + +_(newest last)_ + +- **Plan committed** `c895dc9`. Before execution began, the four load-bearing + algorithms were verified numerically outside the repo (21/21): the + closed-form integral vs a 2M-sample brute force, the derivation hash's + determinism and distribution, the Overclock conversion's output-neutrality + at gain 1, and the cure price staying above the supply price across the + whole space. 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/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/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); + }); +}); From 1ad8c9e1d616a08af149f6c2947b608eeee445b8 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:40:17 -0400 Subject: [PATCH 04/14] v1.11 Task 2: the outage model and its exact piecewise-constant integral One object covers all three sources. The integral has a closed form because within an evaluation window there are no player actions, so the only thing that varies is which outages are active and each is a constant factor over an interval - collect the boundaries, multiply the covering factors per sub-interval, weight by length. Exact, and evaluate() stays one multiplication per lane rather than stepping the simulation. Cold Storage is excluded structurally, in OUTAGE_LANES, rather than by each caller remembering to skip it - a wildcard scope means every ACTIVE lane. The brute-force cross-check in the test file is deliberate: it is the thing that would catch a future 'simplification' into sampling. Co-Authored-By: Claude Opus 5 --- shared/outages.js | 114 ++++++++++++++++++++++++++++++++++++++ tests/outages.test.js | 125 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 shared/outages.js create mode 100644 tests/outages.test.js diff --git a/shared/outages.js b/shared/outages.js new file mode 100644 index 0000000..5fb37b9 --- /dev/null +++ b/shared/outages.js @@ -0,0 +1,114 @@ +/** + * 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. + */ + +/** + * 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; +} diff --git a/tests/outages.test.js b/tests/outages.test.js new file mode 100644 index 0000000..d02e7b3 --- /dev/null +++ b/tests/outages.test.js @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { + scopeCovers, activeAt, pruneExpired, effectiveFactor, laneOutageFor, +} from '../shared/outages.js'; + +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(); + }); +}); From 6458e76d7b7fac60002e96e609b1920f91fe5669 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:41:54 -0400 Subject: [PATCH 05/14] v1.11 Task 3: apply the outage integral in evaluate(), no sources yet Wires the integral into both branches with nothing yet creating an outage, so the interesting assertion is the negative one: with zero outages, production is bit-identical to today and every pre-existing evaluate test passes untouched. The offline branch carries the decision-5 comment at the call site. The factor is computed over the WHOLE absence and applied to the CAPPED payout, so an incident covering 2 of 12 absent hours costs 2/12ths of what was credited. That reads wrong until you see why the literal first-N-hours reading was rejected: at one incident per six hours most would land in unpaid time and cost nothing, gutting the system for the players it should reach most. Pruning happens after the integral, not before - an outage that ended mid-window still degraded the part it covered. Co-Authored-By: Claude Opus 5 --- shared/state.js | 77 +++++++++++++++++++++++++++++++++++++++---- tests/state.test.js | 79 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/shared/state.js b/shared/state.js index 9ce8e53..6646aa3 100644 --- a/shared/state.js +++ b/shared/state.js @@ -2,6 +2,7 @@ import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from './gameData.js'; import { computeMults, tierRate } from './gameRules.js'; import { TOTAL_BLOCKS } from './coldStorageData.js'; import { computeColdStorageEffects, jobDurationSec } from './coldStorage.js'; +import { effectiveFactor, pruneExpired } from './outages.js'; function freshTiers() { return TIER_DEFS.map((t) => ({ id: t.id, owned: 0, manager: false, ready: 0 })); @@ -75,10 +76,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 @@ -169,6 +197,14 @@ export function migrateSave(raw) { ...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 }; @@ -190,6 +226,13 @@ 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; + + const outages = s.server.outages; + const online = elapsedSec <= config.offline.onlineGapThresholdSec; let gained = 0; @@ -209,7 +252,8 @@ export function evaluate(state, config, lastEvaluatedAt, now) { 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; lifetimeGain += produced; if (ts.manager) { creditsGain += produced; return ts; } return { ...ts, ready: (ts.ready || 0) + produced }; @@ -218,7 +262,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) * elapsedSec; + const factor = effectiveFactor(outages, 'grid', i, lastEvaluatedAt, now); + const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * elapsedSec * factor; creditsGain += produced; lifetimeGain += produced; }); @@ -235,7 +280,8 @@ export function evaluate(state, config, lastEvaluatedAt, now) { 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; + const factor = effectiveFactor(outages, 'overclock', i, lastEvaluatedAt, now); + const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * elapsedSec * factor; creditsGain += produced; lifetimeGain += produced; }); @@ -285,10 +331,22 @@ 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. 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; offlineLifetime += produced; if (ts.manager) { offlineCredits += produced; return ts; } return { ...ts, ready: (ts.ready || 0) + produced }; @@ -297,7 +355,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; + const factor = effectiveFactor(outages, 'grid', i, lastEvaluatedAt, now); + const produced = tierRate(g.owned, def.baseProd, gridMult, thresholds) * cappedSec * factor; offlineCredits += produced; offlineLifetime += produced; }); @@ -305,7 +364,8 @@ export function evaluate(state, config, lastEvaluatedAt, now) { 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, 'overclock', i, lastEvaluatedAt, now); + const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * cappedSec * factor; offlineCredits += produced; offlineLifetime += produced; }); @@ -324,6 +384,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/state.test.js b/tests/state.test.js index 86d3209..df4d8e3 100644 --- a/tests/state.test.js +++ b/tests/state.test.js @@ -186,3 +186,82 @@ 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(); + }); +}); From 90dfecfc27f1a8e0f3010ccb05ac2d75fcc9f775 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:44:48 -0400 Subject: [PATCH 06/14] v1.11 Task 4: derive hazards from their scheduled timestamp and fire them in evaluate() The client runs evaluate() optimistically against this same code, so a Math.random() at evaluation time would make the two sides disagree about what happened during an absence. Identity, target and duration are therefore derived from the scheduled timestamp via a pure hash - only WHEN the next one happens uses an injected rng, which is safe precisely because that time is never displayed and is overwritten by the authoritative state on the next reconcile. Two easy-to-get-wrong details are load-bearing: the next hazard is scheduled from the FIRE time (scheduling from the evaluation's "now" would make a long absence produce exactly one hazard however long it was), and MAX_HAZARDS_PER_EVALUATION bounds the loop so a save with a 1970 nextHazardAt reschedules instead of spinning. Deviation from the plan: absorbWithSupply is implemented here rather than landing as a stub in Task 4 and being replaced in Task 5. It is inert until meta.supplies exists (the !bag guard), so Task 4's tests are unaffected and the seam never needs rewriting. Co-Authored-By: Claude Opus 5 --- ...2026-08-08-v1.11-risk-reliability-notes.md | 21 +- server/stateService.js | 11 + shared/outages.js | 213 ++++++++++++++++++ shared/state.js | 12 +- tests/outages.test.js | 145 ++++++++++++ 5 files changed, 397 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md index 91ce34c..21d1d4f 100644 --- a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -11,9 +11,9 @@ what surprised us, and exactly where to pick up. | Task | State | Commit | |---|---|---| -| 1. Boolean tunables + `risk` config block | not started | — | -| 2. `shared/outages.js` + the integral | not started | — | -| 3. Evaluation wiring, no sources | not started | — | +| 1. Boolean tunables + `risk` config block | **done** | `6bf881b` | +| 2. `shared/outages.js` + the integral | **done** | `1ad8c9e` | +| 3. Evaluation wiring, no sources | **done** | `6458e76` | | 4. Hazards: derivation, scheduling, firing | not started | — | | 5. Stockpiles and absorption | not started | — | | 6. The reactive cure | not started | — | @@ -58,3 +58,18 @@ _(newest last)_ determinism and distribution, the Overclock conversion's output-neutrality at gain 1, and the cure price staying above the supply price across the whole space. + +- **Tasks 1-3 executed clean, no deviations from the plan.** Suite went + 726 → 749 passing, with every pre-existing test untouched. Worth knowing: + + - Task 1's plan text was right that `tests/configSchema.test.js` asserts + `toBeTypeOf('number')` for *every* TUNABLES row; that test needed the + boolean branch or the whole task fails on its own scaffolding. + - `shared/events.js`'s `validateModifiers` was the non-obvious second + validator needing the boolean guard. Missing it would not have failed any + test at Task 1 — it would have surfaced much later as an event author + getting "risk.enabled: missing or not a boolean" from a path they were + never meant to reach. + - Task 3's `const outages = s.server.outages` binding sits *above* the + online/offline split so both branches share it. Task 9 moves it below the + kill-switch block — that reassignment is why the plan calls it out. diff --git a/server/stateService.js b/server/stateService.js index a657a59..1a96261 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 } from '../shared/outages.js'; import { getSave, putSave, updateParticipationProgress, } from './db.js'; @@ -73,6 +74,16 @@ 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); + } + // 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/outages.js b/shared/outages.js index 5fb37b9..384eb73 100644 --- a/shared/outages.js +++ b/shared/outages.js @@ -112,3 +112,216 @@ export function laneOutageFor(outages, lane, index, at) { } 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', +}; + +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; +} diff --git a/shared/state.js b/shared/state.js index 6646aa3..e3a8b76 100644 --- a/shared/state.js +++ b/shared/state.js @@ -2,7 +2,7 @@ import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from './gameData.js'; import { computeMults, tierRate } from './gameRules.js'; import { TOTAL_BLOCKS } from './coldStorageData.js'; import { computeColdStorageEffects, jobDurationSec } from './coldStorage.js'; -import { effectiveFactor, pruneExpired } from './outages.js'; +import { effectiveFactor, pruneExpired, fireDueHazards } from './outages.js'; function freshTiers() { return TIER_DEFS.map((t) => ({ id: t.id, owned: 0, manager: false, ready: 0 })); @@ -215,7 +215,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); @@ -231,6 +231,14 @@ export function evaluate(state, config, lastEvaluatedAt, now) { // 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. + const notices = fireDueHazards(s, config, now, rng); + if (notices.length > 0) s.server.outageNotices = notices; + const outages = s.server.outages; const online = elapsedSec <= config.offline.onlineGapThresholdSec; diff --git a/tests/outages.test.js b/tests/outages.test.js index d02e7b3..1d8f8b3 100644 --- a/tests/outages.test.js +++ b/tests/outages.test.js @@ -1,7 +1,19 @@ import { describe, it, expect } from 'vitest'; import { scopeCovers, activeAt, pruneExpired, effectiveFactor, laneOutageFor, + hazardFrom, scheduleNextHazard, fireDueHazards, hazardRatePerHour, riskOn, + HAZARD_KINDS, MAX_HAZARDS_PER_EVALUATION, } 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, @@ -123,3 +135,136 @@ describe('activeAt / pruneExpired / laneOutageFor', () => { 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('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); + }); +}); From a3b20fbf828e6d825b3f212740554fefdbc0bf74 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:46:32 -0400 Subject: [PATCH 07/14] v1.11 Task 5: prepaid stockpiles, offline absorption, and the absorbed notice Supplies are bought with credits but stored in meta. That split is the point: credits are the run currency, so this is a sink for what players have most of, and meta survives Migrate, which gives a genuine reason to spend down before prestiging rather than watching the balance evaporate. Absorption happens at fire time inside evaluate(), so it reaches a hazard that starts and ends during a nine-hour absence - the only defence that can. It is also the one place in the release that decrements a stored value, which is not a decision-1 violation: a supply is a consumable bought for exactly this purpose. The decision-1 property test in Task 9 excludes meta.supplies by name for that reason. Prices are seconds of current output rather than flat, matching social.contractFlopsSeconds and batchQueue.blockFlopsSeconds - a flat price is a meaningful sink for an hour and free forever after. The rate is read from goalCtx, which is deliberately outage-free: pricing off a degraded rate would make supplies cheapest exactly when an incident is running. Co-Authored-By: Claude Opus 5 --- shared/outages.js | 25 +++++++++++++++++++ shared/reducer.js | 20 ++++++++++++++++ shared/state.js | 16 +++++++++++++ tests/outages.test.js | 45 +++++++++++++++++++++++++++++++++++ tests/reducer.economy.test.js | 38 +++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+) diff --git a/shared/outages.js b/shared/outages.js index 384eb73..0e75f59 100644 --- a/shared/outages.js +++ b/shared/outages.js @@ -134,6 +134,31 @@ export const SUPPLY_FOR_KIND = { 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]); +} + const HAZARD_SPECS = { ransomware: { enabledKey: 'ransomwareEnabled', factorKey: 'ransomwareFactor', durationKey: 'ransomwareDurationMs' }, ispOutage: { enabledKey: 'ispOutageEnabled', factorKey: 'ispOutageFactor', durationKey: 'ispOutageDurationMs' }, diff --git a/shared/reducer.js b/shared/reducer.js index fddcbd6..92c5752 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 } from './outages.js'; const LANE_DEFS = { tiers: TIER_DEFS, grid: GRID_DEFS, overclock: OVERCLOCK_DEFS }; @@ -338,6 +339,24 @@ 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] }; +} + function applyLevelUps(meta, xpGain) { let xp = meta.xp + xpGain; let level = meta.level; @@ -649,6 +668,7 @@ const HANDLERS = Object.assign(Object.create(null), { claimBlock, claimAllBlocks, resetTrack, startJob, cancelJob, claimJob, buyTapeUpgrade, claimEventRung, setLeaderboardOptOut, claimContract, claimStreak, + buySupply, }); export function applyAction(state, action, config, now, rng = Math.random) { diff --git a/shared/state.js b/shared/state.js index e3a8b76..b9cf996 100644 --- a/shared/state.js +++ b/shared/state.js @@ -51,6 +51,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 @@ -193,6 +199,16 @@ 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, diff --git a/tests/outages.test.js b/tests/outages.test.js index 1d8f8b3..ab52d9c 100644 --- a/tests/outages.test.js +++ b/tests/outages.test.js @@ -3,6 +3,7 @@ import { scopeCovers, activeAt, pruneExpired, effectiveFactor, laneOutageFor, hazardFrom, scheduleNextHazard, fireDueHazards, hazardRatePerHour, riskOn, HAZARD_KINDS, MAX_HAZARDS_PER_EVALUATION, + SUPPLY_IDS, SUPPLY_FOR_KIND, supplyPrice, } from '../shared/outages.js'; import { DEFAULT_CONFIG } from '../shared/configSchema.js'; import { initialState } from '../shared/state.js'; @@ -252,6 +253,50 @@ describe('hazard scheduling and firing', () => { }); }); +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('riskOn ANDs the master switch first', () => { it('is false whenever the master is off, whatever the source says', () => { const cfg = structuredClone(DEFAULT_CONFIG); diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js index 52b8d1d..ff16710 100644 --- a/tests/reducer.economy.test.js +++ b/tests/reducer.economy.test.js @@ -367,3 +367,41 @@ 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); + }); +}); From 2f0d991e09274de91f00a73257d142eccf1cfdf9 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:47:40 -0400 Subject: [PATCH 08/14] v1.11 Task 6: the reactive cure, priced strictly worse than preparing Coming back to a running incident should never leave you a spectator, but it must never be the cheap path either. The formula puts the cure's floor at cureMultiplier times the supply that would have prevented it, and the test asserts that as a property across every kind, output rate and elapsed fraction rather than at one sample point - if the formula ever changes, that sweep is the contract it has to satisfy. Curing truncates endAt to now rather than splicing the outage out, so an evaluation window straddling the cure still integrates the time the lane was genuinely down. pruneExpired removes it on the next pass. Maintenance and overheats are not curable: one is telegraphed rather than misfortune, the other is the player's own doing. Co-Authored-By: Claude Opus 5 --- shared/outages.js | 21 ++++++++++++++++ shared/reducer.js | 32 ++++++++++++++++++++++-- tests/outages.test.js | 31 ++++++++++++++++++++++- tests/reducer.economy.test.js | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/shared/outages.js b/shared/outages.js index 0e75f59..8c27abc 100644 --- a/shared/outages.js +++ b/shared/outages.js @@ -159,6 +159,27 @@ export function supplyPrice(supplyId, config, totalOutputPerSec) { 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' }, diff --git a/shared/reducer.js b/shared/reducer.js index 92c5752..b97e286 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -9,7 +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 } from './outages.js'; +import { SUPPLY_IDS, supplyPrice, cureCost } from './outages.js'; const LANE_DEFS = { tiers: TIER_DEFS, grid: GRID_DEFS, overclock: OVERCLOCK_DEFS }; @@ -357,6 +357,34 @@ function buySupply(s, action, config, now) { 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; @@ -668,7 +696,7 @@ const HANDLERS = Object.assign(Object.create(null), { claimBlock, claimAllBlocks, resetTrack, startJob, cancelJob, claimJob, buyTapeUpgrade, claimEventRung, setLeaderboardOptOut, claimContract, claimStreak, - buySupply, + buySupply, resolveOutage, }); export function applyAction(state, action, config, now, rng = Math.random) { diff --git a/tests/outages.test.js b/tests/outages.test.js index ab52d9c..d80cc78 100644 --- a/tests/outages.test.js +++ b/tests/outages.test.js @@ -3,7 +3,7 @@ import { scopeCovers, activeAt, pruneExpired, effectiveFactor, laneOutageFor, hazardFrom, scheduleNextHazard, fireDueHazards, hazardRatePerHour, riskOn, HAZARD_KINDS, MAX_HAZARDS_PER_EVALUATION, - SUPPLY_IDS, SUPPLY_FOR_KIND, supplyPrice, + SUPPLY_IDS, SUPPLY_FOR_KIND, supplyPrice, cureCost, } from '../shared/outages.js'; import { DEFAULT_CONFIG } from '../shared/configSchema.js'; import { initialState } from '../shared/state.js'; @@ -297,6 +297,35 @@ describe('stockpiles absorb hazards at fire time', () => { }); }); +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('riskOn ANDs the master switch first', () => { it('is false whenever the master is off, whatever the source says', () => { const cfg = structuredClone(DEFAULT_CONFIG); diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js index ff16710..114e130 100644 --- a/tests/reducer.economy.test.js +++ b/tests/reducer.economy.test.js @@ -405,3 +405,50 @@ describe('buySupply (v1.11)', () => { 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' }); + }); +}); From b6a5e0827bee8bd6c31532b9e2edb748462514bd Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:49:38 -0400 Subject: [PATCH 09/14] v1.11 Task 7: telegraphed Grid maintenance windows Scheduled on the server load path rather than inside evaluate(), following the scheduleAnomaly precedent but for a sharper reason: this window is DISPLAYED with a countdown, so if the client drew it from its own rng the countdown would jump on every reconcile. evaluate() only activates an already-scheduled window, which needs no derivation and is deterministic on both sides. Fixes a bug the test caught while being written. activateDueMaintenance originally skipped a window whose endAt had already passed, reasoning it was "missed entirely" - but a window covering the whole evaluation gap ends exactly at now, so that guard paid the player in full for time they were demonstrably down. The guard cannot see lastEvaluatedAt, so it cannot make that call correctly; the integral can, and already ignores anything ending before the window starts. Removed it and left the reasoning in a comment. Co-Authored-By: Claude Opus 5 --- server/stateService.js | 12 +++++++- shared/outages.js | 62 ++++++++++++++++++++++++++++++++++++++++++ shared/state.js | 5 +++- tests/outages.test.js | 42 ++++++++++++++++++++++++++++ tests/state.test.js | 15 ++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/server/stateService.js b/server/stateService.js index 1a96261..27471ed 100644 --- a/server/stateService.js +++ b/server/stateService.js @@ -1,6 +1,6 @@ import { migrateSave, evaluate } from '../shared/state.js'; import { applyAction, scheduleAnomaly } from '../shared/reducer.js'; -import { scheduleNextHazard } from '../shared/outages.js'; +import { scheduleNextHazard, scheduleGridMaintenance } from '../shared/outages.js'; import { getSave, putSave, updateParticipationProgress, } from './db.js'; @@ -84,6 +84,16 @@ export async function loadEvaluateAndSchedule(userId, now) { 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/outages.js b/shared/outages.js index 8c27abc..92224b6 100644 --- a/shared/outages.js +++ b/shared/outages.js @@ -16,6 +16,8 @@ * 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 @@ -371,3 +373,63 @@ export function fireDueHazards(state, config, now, rng = Math.random) { return notices; } + +// --------------------------------------------------------------------------- +// 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/state.js b/shared/state.js index b9cf996..ba87939 100644 --- a/shared/state.js +++ b/shared/state.js @@ -2,7 +2,9 @@ import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS } from './gameData.js'; import { computeMults, tierRate } from './gameRules.js'; import { TOTAL_BLOCKS } from './coldStorageData.js'; import { computeColdStorageEffects, jobDurationSec } from './coldStorage.js'; -import { effectiveFactor, pruneExpired, fireDueHazards } from './outages.js'; +import { + effectiveFactor, pruneExpired, fireDueHazards, activateDueMaintenance, +} from './outages.js'; function freshTiers() { return TIER_DEFS.map((t) => ({ id: t.id, owned: 0, manager: false, ready: 0 })); @@ -252,6 +254,7 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) // 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. + activateDueMaintenance(s, config, now); const notices = fireDueHazards(s, config, now, rng); if (notices.length > 0) s.server.outageNotices = notices; diff --git a/tests/outages.test.js b/tests/outages.test.js index d80cc78..9e67c19 100644 --- a/tests/outages.test.js +++ b/tests/outages.test.js @@ -4,6 +4,7 @@ import { 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'; @@ -326,6 +327,47 @@ describe('the reactive cure is always worse than preparing', () => { }); }); +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); diff --git a/tests/state.test.js b/tests/state.test.js index df4d8e3..64da299 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))); @@ -264,4 +265,18 @@ describe('evaluate with outages (v1.11)', () => { 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); + }); }); From ec427a8f3b3b30c5614c9631743a4eb63b3bcf74 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:53:28 -0400 Subject: [PATCH 10/14] v1.11 Task 8: Overclock becomes a Racks multiplier; overheating downs a rack tier The lane's output is now expressed as a fraction of the Racks lane's: boost = 1 + gain * overclockOutput / racksOutput. At the default gain of 1 that is algebraically racks + overclock, so goalCtx.totalOutputPerSec is unchanged across the deploy. That is what makes this survivable: contracts, achievements, streak and reducer.economy needed zero edits, and the balance pass is one tunable rather than a re-costing exercise. One goals test did change, and its premise is exactly what the rework removes: it asserted that overclock nodes with NO racks produce output. They no longer do - there is nothing to amplify. Rewritten to assert the new contract, plus a companion test pinning the nothing-to-amplify case. The boost is deliberately not degraded by outages. Ransomware's wildcard scope already covers the Racks lane the boost multiplies, so applying it to both would square the penalty. Overheating now downs one rack tier, its victim derived from the timestamp so two clients reconciling the same overheat agree. legacyFreeze is deliberately NOT gated on the toggle: overheatOutage falls back to the old cooldown when there is no owned rack to down, and a cooldown that is set but not honoured would let heat re-cross the cap on every evaluation. Gating it that way also keeps the condition identical to goalCtx's, so the displayed rate and the produced rate cannot disagree. Co-Authored-By: Claude Opus 5 --- ...2026-08-08-v1.11-risk-reliability-notes.md | 30 ++++++- shared/gameRules.js | 34 +++++++- shared/goals.js | 23 ++++-- shared/outages.js | 40 ++++++++++ shared/state.js | 80 ++++++++++++------- tests/gameRules.test.js | 46 ++++++++++- tests/goals.test.js | 20 ++++- tests/state.test.js | 57 +++++++++++++ 8 files changed, 287 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md index 21d1d4f..39eec5d 100644 --- a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -14,10 +14,10 @@ what surprised us, and exactly where to pick up. | 1. Boolean tunables + `risk` config block | **done** | `6bf881b` | | 2. `shared/outages.js` + the integral | **done** | `1ad8c9e` | | 3. Evaluation wiring, no sources | **done** | `6458e76` | -| 4. Hazards: derivation, scheduling, firing | not started | — | -| 5. Stockpiles and absorption | not started | — | -| 6. The reactive cure | not started | — | -| 7. Grid maintenance | not started | — | +| 4. Hazards: derivation, scheduling, firing | **done** | `90dfecf` | +| 5. Stockpiles and absorption | **done** | `a3b20fb` | +| 6. The reactive cure | **done** | `2f0d991` | +| 7. Grid maintenance | **done** | `b6a5e08` | | 8. The Overclock rework | not started | — | | 9. Master kill switch + decision-1 property | not started | — | | 10. Client surfaces | not started | — | @@ -73,3 +73,25 @@ _(newest last)_ - Task 3's `const outages = s.server.outages` binding sits *above* the online/offline split so both branches share it. Task 9 moves it below the kill-switch block — that reassignment is why the plan calls it out. + +- **Tasks 4-7 done.** Suite 749 → 782 passing. Two deviations and one real + bug, all deliberate: + + - **Deviation (Task 4):** `absorbWithSupply` was written in full in Task 4 + rather than landing as a stub and being replaced in Task 5. It is inert + until `meta.supplies` exists (the `!bag` guard), so Task 4's tests are + unaffected and the firing loop never needed rewriting. + - **Bug found by a test (Task 7):** `activateDueMaintenance` had an + `if (gm.endAt <= now) return null` guard meaning "this window came and + went, skip it". Wrong: a window covering the *whole* evaluation gap ends + exactly at `now`, so the guard paid the player in full for time they were + down. The guard cannot see `lastEvaluatedAt` so it cannot make that + judgement at all — removed, and the integral (which already ignores + anything ending before the window starts) decides instead. + - **Harness note:** shell heredocs and `>` redirects are refused in this + worktree ("too complex to verify it stays inside the worktree"). Use the + Write tool for new files and `printf '%s\n' ... >> file` for appends — + and beware that a bare `printf ... >>` append lands *after* a closing + `});`, so appending an `it()` to an existing `describe` needs a follow-up + Edit. Backticks in `git commit -m` get command-substituted; write the + message to a file and use `git commit -F` instead. 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 index 92224b6..af5a368 100644 --- a/shared/outages.js +++ b/shared/outages.js @@ -374,6 +374,46 @@ export function fireDueHazards(state, config, now, rng = Math.random) { 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 // --------------------------------------------------------------------------- diff --git a/shared/state.js b/shared/state.js index ba87939..c3fe6dc 100644 --- a/shared/state.js +++ b/shared/state.js @@ -1,9 +1,10 @@ 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() { @@ -276,11 +277,33 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) 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 factor = effectiveFactor(outages, 'tiers', i, lastEvaluatedAt, now); - const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds) * elapsedSec * factor; + 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 }; @@ -295,34 +318,34 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) 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 factor = effectiveFactor(outages, 'overclock', i, lastEvaluatedAt, now); - const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * elapsedSec * factor; - 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; } @@ -369,11 +392,21 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) // 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 factor = effectiveFactor(outages, 'tiers', i, lastEvaluatedAt, now); - const produced = tierRate(ts.owned, def.baseProd, racksMult, thresholds) * cappedSec * factor; + 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 }; @@ -388,15 +421,6 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) offlineLifetime += produced; }); - s.run.overclock.forEach((o, i) => { - const def = OVERCLOCK_DEFS[i]; - if (!def || !o || o.owned === 0) return; - const factor = effectiveFactor(outages, 'overclock', i, lastEvaluatedAt, now); - const produced = tierRate(o.owned, def.baseProd, overclockMult, thresholds) * cappedSec * factor; - offlineCredits += produced; - offlineLifetime += produced; - }); - if (s.run.heatCooldownUntil && now >= s.run.heatCooldownUntil) { s.run.heatCooldownUntil = null; } 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/state.test.js b/tests/state.test.js index 64da299..d7e2e97 100644 --- a/tests/state.test.js +++ b/tests/state.test.js @@ -280,3 +280,60 @@ describe('evaluate with outages (v1.11)', () => { 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)); + }); +}); From 3291e693ca66d0533a8d7be197f8e36b2e038c31 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:54:07 -0400 Subject: [PATCH 11/14] v1.11 Task 9: the master kill switch clears live outages; decision 1 as a property This is a live game with real players, so risk.enabled has to be a true kill switch rather than a pause. Clearing happens before the integral, which means even the window being evaluated right now is paid in full - a player mid-ransomware when the owner flips the switch is visibly un-broken on their next reconcile rather than left throttled by a system that no longer exists. Decision 1 is now enforced as a property across a 60-seed randomised sweep rather than by inspection: no hazard may reduce credits, wafers, tapes, lifetime output or any owned count. meta.supplies is excluded by design and by name - it is the one consumable bought expressly to be spent. That test is the guardrail that stops a later small change reintroducing asset loss. Co-Authored-By: Claude Opus 5 --- shared/state.js | 19 +++++++++++-- tests/state.test.js | 69 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/shared/state.js b/shared/state.js index c3fe6dc..577a0e8 100644 --- a/shared/state.js +++ b/shared/state.js @@ -255,10 +255,23 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random) // 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. - activateDueMaintenance(s, config, now); - const notices = fireDueHazards(s, config, now, rng); - if (notices.length > 0) s.server.outageNotices = notices; + // 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; diff --git a/tests/state.test.js b/tests/state.test.js index d7e2e97..43a298e 100644 --- a/tests/state.test.js +++ b/tests/state.test.js @@ -337,3 +337,72 @@ describe('the Overclock rework (v1.11)', () => { 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])); + } + }); +}); From 20544480f27e66620d40547f1802fe695dcab2f3 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 22:57:44 -0400 Subject: [PATCH 12/14] v1.11 Task 10: outage strip, Resilience tab, offline-tier badges and notice toasts The strip reads server.outages directly, which is why it can tell one coherent story about a slowdown rather than reconciling two - there is only ever one list. It also carries the upcoming maintenance window, the single thing in this release the player is allowed to see coming. A new Resilience tab gives supplies, the standing risk rate and any running incident one home. The rate is derived from config and the next hazard time is never rendered anywhere: showing it would collapse the prepaid economy into buying one licence twenty minutes beforehand. The Racks panel now labels a downed tier with its reason. A tier silently producing nothing reads as a bug, and after this release that will happen routinely. Notices reuse the existing toast rather than adding a notification system, following the v1.10 rule that rewards use the modal and everything else uses the toast. The absorbed notice is the load-bearing one. Per the standing tour obligation, the two new steps are appended to onboarding.js rather than registered as their own tour, so onboarding stays a strict superset. tests/tours.test.js hardcodes step counts; both moved by exactly 2, which is the check that the steps are ungated - a fresh save can be hit by a hazard, so it must be told how to prepare. Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 44 ++++++- client/src/game/components/OutageStrip.jsx | 78 ++++++++++++ client/src/game/components/OverclockPanel.jsx | 4 +- client/src/game/components/RacksPanel.jsx | 17 ++- .../src/game/components/ResiliencePanel.jsx | 120 ++++++++++++++++++ client/src/game/data/tabs.js | 6 +- client/src/game/data/tours/onboarding.js | 6 +- client/src/game/data/tours/steps.js | 17 +++ tests/tours.test.js | 8 +- 9 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 client/src/game/components/OutageStrip.jsx create mode 100644 client/src/game/components/ResiliencePanel.jsx diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index 6e0b079..f29275c 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -34,6 +34,8 @@ import ColdStoragePanel from './game/components/ColdStoragePanel.jsx'; import EventPanel from './game/components/EventPanel.jsx'; import SocialPanel from './game/components/SocialPanel.jsx'; import StreakBanner from './game/components/StreakBanner.jsx'; +import OutageStrip from './game/components/OutageStrip.jsx'; +import ResiliencePanel from './game/components/ResiliencePanel.jsx'; import AnomalyToast from './game/components/AnomalyToast.jsx'; import RushOverlay from './game/components/minigames/RushOverlay.jsx'; import DebugOverlay from './game/components/minigames/DebugOverlay.jsx'; @@ -97,6 +99,16 @@ function buildTourCtx(state, now) { }; } +// v1.11: display names for the one-shot outage notices evaluate() attaches to +// server.outageNotices. Module scope, not component scope - it is static, and +// handleReconcile (defined above where a component-scope const would live) +// reads it. +const OUTAGE_NOTICE_LABEL = { + ransomware: 'Ransomware', + ispOutage: 'ISP outage', + driveFailure: 'Drive failure', +}; + // Identity of the EFFECTIVE gameplay config. The stored config's `version` // alone is not enough: activating or ending a live event changes the numbers // the server evaluates with (its modifiers are overlaid on the baseline) @@ -329,6 +341,18 @@ export default function RackStack({ user }) { if (serverState.server.overheated) setModal({ type: 'meltdown' }); + // 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, and a silent save is a + // wasted save. + 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.`); + } + // Live Events (v1.4): activeEvent/eventLeaderboard aren't part of // canonical state (see refreshEventData's own doc comment) - piggyback // their refresh on the cadence reconciles already happen at, throttled, @@ -602,6 +626,12 @@ export default function RackStack({ user }) { function buyGrid(i, mode) { dispatchAction({ type: 'buy', lane: 'grid', index: i, mode }); } function buyOverclock(i, mode) { dispatchAction({ type: 'buy', lane: 'overclock', index: i, mode }); } function ventHeat() { dispatchAction({ type: 'vent' }); } + // v1.11. Neither 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 right. (claimAnomaly is IMMEDIATE only + // because its reward is rolled server-side and cannot be predicted.) + function buySupply(id) { dispatchAction({ type: 'buySupply', id }); } + function resolveOutage(id) { dispatchAction({ type: 'resolveOutage', id }); } function buyUpgrade(u) { dispatchAction({ type: 'buyUpgrade', id: u.id }); } function buyShardUpgrade(u) { dispatchAction({ type: 'buyShardUpgrade', id: u.id }); } function claimBlock(index) { dispatchAction({ type: 'claimBlock', index }); } @@ -1122,6 +1152,7 @@ export default function RackStack({ user }) {
setProfileOpen(true)} /> + {eventLive && activeEvent && ( setActiveTab('event')} /> )} @@ -1138,7 +1169,7 @@ export default function RackStack({ user }) {
{activeTab === 'racks' && ( - + )} {activeTab === 'grid' && ( @@ -1161,6 +1192,17 @@ export default function RackStack({ user }) { /> )} + {activeTab === 'resilience' && ( + + )} + {activeTab === 'upgrades' && } {activeTab === 'singularity' && ( diff --git a/client/src/game/components/OutageStrip.jsx b/client/src/game/components/OutageStrip.jsx new file mode 100644 index 0000000..1bc41a2 --- /dev/null +++ b/client/src/game/components/OutageStrip.jsx @@ -0,0 +1,78 @@ +import { AlertTriangle, CalendarClock } from 'lucide-react'; +import { cardBorder, textDim, danger, amber } from '../theme.js'; +import { activeAt } from '@shared/outages.js'; +import { GRID_DEFS, TIER_DEFS } from '@shared/gameData.js'; + +// One coherent story about a slowdown, read from server.outages - the single +// representation every source shares (spec §3). There is no separate hazard +// list and maintenance list to reconcile here because there is no separate +// list anywhere. +const KIND_LABEL = { + ransomware: 'ransomware', + ispOutage: 'ISP outage', + driveFailure: 'drive failure', + maintenance: 'maintenance', + overheat: 'overheat', +}; + +function scopeLabel(scope) { + if (!scope) return 'Something'; + if (scope.lane === '*') return 'All lanes'; + if (scope.lane === 'grid') { + const def = GRID_DEFS[scope.index]; + return def ? `Grid: ${def.name}` : 'Grid'; + } + if (scope.lane === 'tiers') { + const def = TIER_DEFS[scope.index]; + return def ? def.name : 'A rack tier'; + } + return 'Overclock'; +} + +function remaining(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h ${m % 60}m`; +} + +export default function OutageStrip({ outages, gridMaintenance, now }) { + const live = activeAt(outages, now); + const upcoming = gridMaintenance && gridMaintenance.startAt > now ? gridMaintenance : null; + if (live.length === 0 && !upcoming) return null; + + return ( +
+ {live.map((o) => ( +
+ + + {scopeLabel(o.scope)} + {o.factor === 0 ? ' offline' : ` at ${Math.round(o.factor * 100)}%`} + {' · '}{KIND_LABEL[o.kind] || o.kind} + {' · '}{remaining(o.endAt - now)} left + +
+ ))} + {/* Maintenance is telegraphed (spec decision 3) - the one thing in this + release the player gets to see coming and route around. */} + {upcoming && ( +
+ + + Scheduled maintenance: {scopeLabel({ lane: 'grid', index: upcoming.index })} + {' · in '}{remaining(upcoming.startAt - now)} + +
+ )} +
+ ); +} diff --git a/client/src/game/components/OverclockPanel.jsx b/client/src/game/components/OverclockPanel.jsx index 0bf64e4..6e3759d 100644 --- a/client/src/game/components/OverclockPanel.jsx +++ b/client/src/game/components/OverclockPanel.jsx @@ -30,7 +30,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
- Overclock nodes run on their own like the Grid, but generate heat. Let it hit 100% and the lane freezes for {Math.round(overheatCooldownMs / 1000)}s while it cools down - no nodes are ever lost. Venting sheds {Math.round(ventPercent)}% of your heat capacity, so keep venting to avoid the lockout. + 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, so keep venting to avoid it.
{OVERCLOCK_DEFS.map((def, i) => { const o = run.overclock[i]; @@ -54,7 +54,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
×{o.owned}
- {fmt(rate)} F/s · {def.heatPerSec.toFixed(2)} heat/s each + +{fmt(rate)} to Racks · {def.heatPerSec.toFixed(2)} heat/s each {msMult > 1 && · ×{msMult} milestone}
diff --git a/client/src/game/components/RacksPanel.jsx b/client/src/game/components/RacksPanel.jsx index 0618888..af82d94 100644 --- a/client/src/game/components/RacksPanel.jsx +++ b/client/src/game/components/RacksPanel.jsx @@ -1,8 +1,9 @@ import { costAt, costForN, maxAffordable, milestoneMult, nextMilestone, tierRate, fmt } from '../helpers.js'; -import { cardBg, cardBorder, inset, textMain, textDim, amber, teal, buyBtnStyle } from '../theme.js'; +import { cardBg, cardBorder, inset, textMain, textDim, amber, teal, danger, buyBtnStyle } from '../theme.js'; import { TIER_DEFS } from '../data/tiers.js'; +import { laneOutageFor } from '@shared/outages.js'; -export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, eff, onBuy, onCollect, onHire }) { +export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, eff, outages, now, onBuy, onCollect, onHire }) { const LockedIcon = unlockedUpTo + 1 < TIER_DEFS.length ? TIER_DEFS[unlockedUpTo + 1].Icon : null; return (
@@ -17,6 +18,10 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e const msMult = milestoneMult(ts.owned, thresholds); const nextMs = nextMilestone(ts.owned, thresholds); const managerCost = def.managerCost * eff.automationDiscount; + // v1.11: a tier silently producing nothing reads as a bug, so say + // why. laneOutageFor returns the most severe cover, which is the one + // worth naming. + const down = laneOutageFor(outages, 'tiers', i, now); return (
@@ -32,6 +37,14 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e {fmt(rate)} F/s{ts.manager ? ' · automated' : ''} {msMult > 1 && · ×{msMult} milestone}
+ {down && ( +
+ {down.factor === 0 ? 'Offline' : `At ${Math.round(down.factor * 100)}%`} + {down.kind === 'overheat' + ? ' - overheated' + : down.kind === 'driveFailure' ? ' - drive failure' : ' - incident'} +
+ )}
diff --git a/client/src/game/components/ResiliencePanel.jsx b/client/src/game/components/ResiliencePanel.jsx new file mode 100644 index 0000000..d888947 --- /dev/null +++ b/client/src/game/components/ResiliencePanel.jsx @@ -0,0 +1,120 @@ +import { ShieldAlert, ShieldCheck, Zap } from 'lucide-react'; +import { cardBg, cardBorder, inset, textMain, textDim, teal, danger, amber, buyBtnStyle } from '../theme.js'; +import { fmt } from '../helpers.js'; +import { + SUPPLY_IDS, supplyPrice, cureCost, hazardRatePerHour, activeAt, +} from '@shared/outages.js'; + +const SUPPLY_META = { + antivirus: { + name: 'Antivirus licence', + counters: 'Ransomware', + blurb: 'Absorbs one ransomware incident - even while you are away.', + }, + backupIsp: { + name: 'Backup ISP line', + counters: 'ISP outage', + blurb: 'Keeps the Grid up through one connectivity failure.', + }, + spareDrives: { + name: 'Spare drive', + counters: 'Drive failure', + blurb: 'Swaps in for one failed rack tier before it costs you anything.', + }, +}; + +export default function ResiliencePanel({ + state, config, totalOutputPerSec, now, onBuySupply, onResolveOutage, +}) { + const rate = hazardRatePerHour(config); + // The RATE, never the next time (spec decision 3) - showing nextHazardAt + // would turn the prepaid economy into buying one licence twenty minutes + // before it fires. + const perHours = rate > 0 ? Math.round(1 / rate) : 0; + const live = activeAt(state.server.outages, now); + const curable = live.filter((o) => o.source === 'hazard' && o.endAt > now); + + return ( +
+
+
+ Standing risk +
+
+ {rate > 0 + ? `Roughly one incident every ${perHours}h. You are never told when - stock up instead.` + : 'No incidents are currently possible.'} +
+
+ + {curable.length > 0 && ( +
+
+ Running incidents +
+ {curable.map((o) => { + const cost = cureCost(o, config, totalOutputPerSec, now); + const affordable = state.run.credits >= cost; + return ( + + ); + })} +
+ Always dearer than having stocked the supply. Preparation is the cheap path. +
+
+ )} + +
+ {SUPPLY_IDS.map((id) => { + const meta = SUPPLY_META[id]; + const stock = (state.meta.supplies && state.meta.supplies[id]) || 0; + const cost = supplyPrice(id, config, totalOutputPerSec); + const affordable = state.run.credits >= cost; + return ( +
+
+
+ 0 ? teal : textDim} /> +
+
+
+
{meta.name}
+
0 ? teal : textDim }}>×{stock}
+
+
Counters {meta.counters}. {meta.blurb}
+
+
+ +
+ ); + })} +
+ +
+ Supplies are spent automatically the moment a matching incident starts - including while you are offline, which is the only defence that can reach one. They survive a Migrate, so spend down before you prestige rather than watching the balance evaporate. Cold Storage is never affected by any of this. +
+
+ ); +} diff --git a/client/src/game/data/tabs.js b/client/src/game/data/tabs.js index 9e31395..4b9246a 100644 --- a/client/src/game/data/tabs.js +++ b/client/src/game/data/tabs.js @@ -1,4 +1,4 @@ -import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy, Users } from 'lucide-react'; +import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy, Users, ShieldAlert } from 'lucide-react'; export const TABS = [ { id: 'racks', label: 'Racks', Icon: Layers }, @@ -14,6 +14,10 @@ export const TABS = [ // daily contracts board and the streak both work from level 0, so there's // no progression gate to render it disabled behind (see TabBar.jsx). { id: 'social', label: 'Social', Icon: Users }, + // Risk & Reliability (v1.11): supplies, the standing risk rate, and any + // running incident. Never locked - a fresh save can be hit by a hazard, so + // it must always be able to stock against one. + { id: 'resilience', label: 'Resilience', Icon: ShieldAlert }, // Live Events (v1.4): unlike every other tab above (which is locked-but- // always-rendered until progression clears it, see TabBar.jsx), this one // is entirely absent from the bar outside its window - RackStack.jsx diff --git a/client/src/game/data/tours/onboarding.js b/client/src/game/data/tours/onboarding.js index eb4fe8c..6461c4c 100644 --- a/client/src/game/data/tours/onboarding.js +++ b/client/src/game/data/tours/onboarding.js @@ -1,7 +1,7 @@ import { welcomeSteps, racksSteps, gridSteps, overclockSteps, upgradesSteps, goalsSteps, gamesSteps, coldStorageSteps, socialSteps, singularitySteps, migrateSteps, - eventSteps, wrapUpSteps, + eventSteps, wrapUpSteps, resilienceSteps, } from './steps.js'; import { ONBOARDING_TOUR_ID } from '../../../../../shared/tours.js'; @@ -23,6 +23,10 @@ export const onboardingTour = { ...goalsSteps, ...gamesSteps, ...coldStorageSteps, + // v1.11: appended per the maintenance obligation above. No separate + // Resilience tour is registered in CLIENT_TOURS - these steps exist only + // here, so onboarding remains a strict superset. + ...resilienceSteps, ...socialSteps, ...singularitySteps, ...migrateSteps, diff --git a/client/src/game/data/tours/steps.js b/client/src/game/data/tours/steps.js index 5f3d0a6..7da3806 100644 --- a/client/src/game/data/tours/steps.js +++ b/client/src/game/data/tours/steps.js @@ -176,3 +176,20 @@ export const wrapUpSteps = [ body: 'Locked tabs open up as you grow. You can replay this tour any time from Profile -> Settings -> Tutorials.', }, ]; + +export const resilienceSteps = [ + { + id: 'resilience-risk', + tab: 'resilience', + anchor: 'resilience-risk', + title: 'Things go wrong', + body: 'Every few hours something breaks - ransomware, a dead link, a failed drive. It only ever slows you down: you never lose racks, FLOPS, tapes or upgrades. You are told the rate, never the schedule.', + }, + { + id: 'resilience-supplies', + tab: 'resilience', + anchor: 'resilience-supplies', + title: 'Stock up before it happens', + body: 'Each supply absorbs one matching incident automatically - even while you are offline, which is the only time it can save you. Fixing something already broken always costs more than having prepared. Cold Storage is never affected.', + }, +]; 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', () => { From d02810b2314a5e523b8d26ee80639e2dc8894345 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 23:09:08 -0400 Subject: [PATCH 13/14] v1.11.0: smoke suite, changelog, version bump Nine end-to-end checks over a real server: an outage costing output, Cold Storage provably untouched beside it, buying and refusing a supply, the firing bound terminating on a 1970 nextHazardAt, offline absorption, the kill switch clearing a live incident, the boolean tunable type rejected end to end, and the Resilience tab rendering in a browser. Two pre-existing smoke checks asserted behaviour this release deliberately changes, and were updated rather than worked around: - smoke-v12's overheat check expected heatCooldownUntil and the frozen-lane messaging. The penalty moved to the Racks lane, so it now asserts the overheat outage and that the outage strip names it. Asserted on the strip rather than the Racks panel because the strip is in the sticky header and is visible whatever tab is open and whichever tier was picked - including one past unlockedUpTo, which is what made the first attempt flaky. - smoke-v16 hardcoded the onboarding step count, 11 -> 13 for the two ungated Resilience steps. Verified: SQLite 793 passing, Postgres 819 passing, 66 smoke checks across every suite, client builds. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 54 +++ Dockerfile | 2 +- ...2026-08-08-v1.11-risk-reliability-notes.md | 31 +- package.json | 2 +- tests/e2e/smoke-v111.mjs | 438 ++++++++++++++++++ tests/e2e/smoke-v12.mjs | 32 +- tests/e2e/smoke-v16.mjs | 5 +- 7 files changed, 548 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/smoke-v111.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c65697..77d611f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## 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 preparing.** 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 changes 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** — + your 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. + ## v1.10.0 - **Triggering a Singularity deleted you from the Legacy Cores leaderboard.** diff --git a/Dockerfile b/Dockerfile index 86697df..ccbbd1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT" # only on a pushed vX.Y.Z tag, and docker/metadata-action derives the # published image's version label from that tag - so this literal only # affects locally-built images, not what GHCR publishes. -LABEL org.opencontainers.image.version="1.10.0" +LABEL org.opencontainers.image.version="1.11.0" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md index 39eec5d..a559f55 100644 --- a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -18,9 +18,9 @@ what surprised us, and exactly where to pick up. | 5. Stockpiles and absorption | **done** | `a3b20fb` | | 6. The reactive cure | **done** | `2f0d991` | | 7. Grid maintenance | **done** | `b6a5e08` | -| 8. The Overclock rework | not started | — | -| 9. Master kill switch + decision-1 property | not started | — | -| 10. Client surfaces | not started | — | +| 8. The Overclock rework | **done** | `ec427a8` | +| 9. Master kill switch + decision-1 property | **done** | `3291e69` | +| 10. Client surfaces | **done** | `2054448` | | 11. Smoke, changelog, version, release | not started | — | ## How to resume @@ -95,3 +95,28 @@ _(newest last)_ `});`, so appending an `it()` to an existing `describe` needs a follow-up Edit. Backticks in `git commit -m` get command-substituted; write the message to a file and use `git commit -F` instead. + +- **Tasks 8-10 done.** Suite 782 → 793 passing, client builds clean. + + - **The Overclock bet paid off.** `tests/contracts.test.js`, + `achievements`, `streak` and `reducer.economy` needed **zero edits** and + pass — which was the whole point of defining the conversion as a ratio of + the Racks lane. Only `tests/goals.test.js` changed, and only the one test + whose premise the rework deliberately removes (it asserted overclock nodes + with *no racks* produce output; they no longer do, there is nothing to + amplify). If a future change makes those other four suites move, the + conversion has drifted from output-neutral — check + `risk.overclockBoostGain` and `overclockBoost()` before touching a test. + - **Second bug caught while writing Task 8.** `legacyFreeze` was initially + gated on `!riskOn(config, 'overheatShutdownEnabled')`. But + `overheatOutage` falls back to setting `heatCooldownUntil` when the + shutdown is *enabled* and there is simply no owned rack to down — so the + cooldown was set and then never honoured, and heat re-crossed the cap on + every evaluation. Now it is simply "is a cooldown active", which also + makes it identical to `goalCtx`'s condition, so the displayed rate and the + produced rate cannot disagree. + - `tests/tours.test.js` hardcodes onboarding step counts (17/11 → 19/13). + Both moved by exactly 2, confirming the new steps are ungated. + - `OUTAGE_NOTICE_LABEL` lives at module scope in `RackStack.jsx`, not + component scope: `handleReconcile` is defined above where a + component-scope const would sit. 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/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(); From d7418ce1ba3c4f4e4474d7401ec94ffbeaf49ba8 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 23:09:32 -0400 Subject: [PATCH 14/14] Record v1.11 execution notes: all 11 tasks done, gates green --- ...2026-08-08-v1.11-risk-reliability-notes.md | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md index a559f55..5ea1d11 100644 --- a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -21,7 +21,20 @@ what surprised us, and exactly where to pick up. | 8. The Overclock rework | **done** | `ec427a8` | | 9. Master kill switch + decision-1 property | **done** | `3291e69` | | 10. Client surfaces | **done** | `2054448` | -| 11. Smoke, changelog, version, release | not started | — | +| 11. Smoke, changelog, version, release | **done** (code) | `d02810b` | + +**All 11 tasks implemented.** Remaining: PR, whole-branch review, merge, then +tag `main` (never the branch) as `v1.11.0` and push the tag — the tag push is +what triggers the GHCR publish. + +### Verification at completion + +| Gate | Result | +|---|---| +| `TEST_BACKEND=sqlite vitest run` | 793 passed, 29 skipped | +| Postgres (`npm run test:all`) | 819 passed, 3 skipped | +| `npm run smoke` (all suites) | 66 PASS, 0 FAIL | +| `cd client && npm run build` | clean | ## How to resume @@ -120,3 +133,27 @@ _(newest last)_ - `OUTAGE_NOTICE_LABEL` lives at module scope in `RackStack.jsx`, not component scope: `handleReconcile` is defined above where a component-scope const would sit. + +- **Task 11 done.** Building the smoke suite surfaced four API-shape mistakes + worth recording, because the next e2e author will hit the same ones: + + - **`GET /api/state` returns `run`/`meta`/`server` FLATTENED at the top + level.** `POST /api/actions` returns them wrapped in `state`. The two are + not interchangeable; `smoke-v111.mjs` has a `stateOf()` helper for it. + - **`PUT /api/admin/config` takes the document wrapped as `{ data }`.** + Sending the bare document gets `["not an object"]`, which reads like a + validator bug and is not. + - **`setToursCompleted(userId, ids)` takes an ARRAY**, and seeding it is + mandatory for any browser check: the onboarding overlay is a full-screen + `fixed inset-0` div that swallows every click, so Playwright times out + with a misleading "element intercepts pointer events". + - A credits-were-charged assertion must baseline **after** the offline gap + is credited. An hour of accrual dwarfs a supply price, so comparing + against the seeded value passes even when nothing is charged. + + Two pre-existing smoke checks asserted behaviour v1.11 deliberately + changes and were updated, not worked around: `smoke-v12`'s overheat lockout + (now asserts the overheat outage and the strip naming it — assert on the + **strip**, not the Racks panel, since the downed tier can be past + `unlockedUpTo` and therefore not rendered), and `smoke-v16`'s hardcoded + onboarding step count.