diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77d611f..5afffa1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,61 @@
# Changelog
+## v1.12.0
+
+- **The whole economy is retuned to a weeks-long curve.** A daily player now
+ reaches tiers 0-9 in the first four days, tier 10 around day 18, and the top
+ of the ladder over the following weeks. Previously the entire 14-tier ladder
+ fell inside two weeks and a Singularity was available *every single day*.
+
+ The rack ladder itself was never the problem - in isolation it was a clean
+ doubling curve. What broke it was every reward system layered on top, which
+ together multiplied progression by about 21x.
+
+- **Signal Boost no longer makes the anomaly boost permanent.** It scaled the
+ boost's *duration* as well as its payout, so at max level a 2-4x global
+ multiplier lasted longer than the interval between anomalies. Measured boost
+ uptime was 55% of session time. Signal Boost now scales the payout only, and
+ anomalies are rarer, more valuable, and give you twice as long to catch one.
+
+- **Legacy Cores plateau, and Singularity is finally worth taking.** Cores
+ stop buying output past a cap; beyond it they are fuel for the next
+ Singularity. That plateau is the point - Singularity used to be a strict
+ downgrade at every scale (it zeroes cores and returned far less than it
+ destroyed), and was only survivable because cores regrew within a day.
+ Singularity yield is now linear in cores rather than a square root, and the
+ Engine upgrade has a longer tail so the shard tree stays a long-term goal.
+
+- **Quantum Bootstrap is x3 per level, not x10.** Maxed alongside Deep Cache it
+ handed you 11,000,000 credits at every Migrate - enough to buy straight back
+ into the mid-game, so Migrate had stopped being a reset. Echo Cores now
+ grants a share of the Migrate gain instead of a flat amount.
+
+- **Incidents are twice as frequent but individually softer, and preparing is
+ now clearly worth it.** Supplies used to be break-even at best and an
+ outright loss for two of the three, so the rational play was to ignore the
+ entire prepaid economy. Drive failures and overheats now hit your *top* rack
+ tier - a random victim was both unpredictable and usually trivial.
+
+- **Overheating is a real trade-off instead of a cliff.** Venting used to
+ supply far more cooling than any fleet could generate, so an attentive player
+ could never overheat and an inattentive one was punished constantly. Venting
+ is slower now, passive Auto-Vent is much stronger, and the meltdown notice
+ tells you which rack went dark and why.
+
+- **Live Event ladders scale with your output.** Their FLOPS rungs were fixed
+ numbers, so every seasonal event's FLOPS ladder cleared in a fraction of a
+ second. Targets are now expressed in seconds of your own production and
+ snapshotted when you join.
+
+- **Minigames pay far fewer wafers.** The whole permanent upgrade tree used to
+ be reachable in about two and a half hours of minigames.
+
+- **Reward magnitudes are now admin-tunable.** The root cause of most of the
+ above was that rate curves were tunable while payout sizes were hardcoded
+ constants calibrated for the early game. 21 new tunables cover them.
+
+Existing saves are unaffected in balance terms - no progress is rewritten.
+
## v1.11.0
- **Things can now go wrong.** Every few hours something breaks: ransomware
diff --git a/README.md b/README.md
index bdbe2b3..8bd6825 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,15 @@ global leaderboards, and a badge case of achievements that unlock on their own
as you play. A daily login streak sits in the header. See
[Social & Retention](#social--retention) below.
+As of v1.12, the economy is retuned to a weeks-long curve: tiers 0-9 in the
+first few days, tier 10 as a wall around day 18, and the top of the ladder over
+the weeks after. Legacy Cores plateau at a cap, which is what makes Singularity
+worth taking; anomaly boosts no longer run permanently; incidents are more
+frequent but softer and preparing for them now pays; overheating is a genuine
+trade-off; and Live Event ladders scale with your output instead of using fixed
+targets. Reward magnitudes that used to be hardcoded are admin-tunable. Existing
+saves keep their progress - nothing is rewritten.
+
## Architecture
- `shared/` - a package used by both server and client (via a `@shared` Vite
diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx
index f29275c..d8baa3b 100644
--- a/client/src/RackStack.jsx
+++ b/client/src/RackStack.jsx
@@ -339,7 +339,7 @@ export default function RackStack({ user }) {
lastTickAtRef.current = now;
setState(next);
- if (serverState.server.overheated) setModal({ type: 'meltdown' });
+ if (serverState.server.overheated) setModal({ type: 'meltdown', tierIndex: serverState.server.overheated?.tierIndex });
// v1.11: one-shot outage notices, same lifecycle as `overheated` above.
// Toast, not modal - these are information, not a reward (the v1.10 rule:
@@ -526,7 +526,7 @@ export default function RackStack({ user }) {
if (stateRes.offlineGain > 1) {
setModal({ type: 'welcome', amount: stateRes.offlineGain });
} else if (initial.server.overheated) {
- setModal({ type: 'meltdown' });
+ setModal({ type: 'meltdown', tierIndex: initial.server.overheated?.tierIndex });
}
// Live Events (v1.4): GET /api/state already carries this player's
// claimable event identity - seed activeEvent from it directly so the
@@ -566,7 +566,7 @@ export default function RackStack({ user }) {
lastTickAtRef.current = now;
stateRef.current = next;
setState(next);
- if (next.server.overheated) setModal({ type: 'meltdown' });
+ if (next.server.overheated) setModal({ type: 'meltdown', tierIndex: next.server.overheated?.tierIndex });
}, TICK_MS);
return () => clearInterval(iv);
}, [loaded]);
@@ -1093,8 +1093,8 @@ export default function RackStack({ user }) {
const runForOverclock = { ...state.run, heat: heatPct };
const ctx = goalCtx(state, config.data, now);
- const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult);
- const singularityGain = Math.floor(Math.sqrt(state.meta.legacyCores || 0));
+ const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data);
+ const singularityGain = Math.floor((state.meta.legacyCores || 0) * config.data.prestige.shardsPerCore);
// Single source of truth with the tour's auto-start effect above.
const { gridUnlocked, overclockUnlocked, singularityUnlocked, coldStorageUnlocked } = buildTourCtx(state, now);
diff --git a/client/src/game/components/modals/MessageModal.jsx b/client/src/game/components/modals/MessageModal.jsx
index e74305b..4dbb727 100644
--- a/client/src/game/components/modals/MessageModal.jsx
+++ b/client/src/game/components/modals/MessageModal.jsx
@@ -1,4 +1,5 @@
import { AlertTriangle } from 'lucide-react';
+import { TIER_DEFS } from '@shared/gameData.js';
import { amber, violet, danger, textDim, textMain } from '../../theme.js';
import { fmt } from '../../helpers.js';
@@ -38,14 +39,24 @@ export default function MessageModal({ modal, onClose }) {
>
);
- case 'meltdown':
+ // The pre-v1.12 copy here described PRE-v1.11 behaviour ("the lane is
+ // frozen ... no nodes were lost"), which stopped being true when the
+ // overheat penalty moved to the Racks lane. Name the actual victim.
+ case 'meltdown': {
+ const downed = typeof modal.tierIndex === 'number' ? TIER_DEFS[modal.tierIndex] : null;
return (
<>
Overheated!
-
Your Overclock Bay hit 100% heat and the lane is frozen for a short cooldown - no nodes were lost. Keep an eye on the heat gauge and vent regularly, or invest in Thermal Regulators / Auto-Vent upgrades to avoid the lockout.
+
+ {downed
+ ? `Your Overclock Bay hit 100% heat and took your ${downed.name} offline while it cools. Overclocking is what multiplies your racks, so running it hot risks the very thing it amplifies.`
+ : 'Your Overclock Bay hit 100% heat and the lane is frozen while it cools.'}
+ {' '}Vent before the gauge fills, or invest in Thermal Regulators / Auto-Vent to raise the fleet you can run unattended.
+
>
);
+ }
case 'singularityDone':
return (
<>
diff --git a/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md b/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md
new file mode 100644
index 0000000..d0713d1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md
@@ -0,0 +1,1651 @@
+# v1.12 Economy Rebalance 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:** Retune the RackStack economy to AdVenture-Communist pacing — tiers 0–9 in the first four days, tier 10 at ~day 18, tier 13 at ~day 44 — by applying the calibrated constants and eight formula fixes from the design spec.
+
+**Architecture:** Almost every change lands in `shared/`, which is the single source of truth for game math (the client imports it via the `@shared` Vite alias; there is no second copy). Config values go in `DEFAULT_CONFIG` + `TUNABLES` in `shared/configSchema.js` so they stay admin-tunable and live-event overlayable. Formula fixes go in `shared/gameRules.js`, `shared/reducer.js`, `shared/outages.js`, and `shared/events.js`. Two small client changes fix call sites and stale copy. No save migration: `migrateSave` already defaults new fields and `upgradeConfig` folds new tunables into stored configs on read.
+
+**Tech Stack:** Node 20+, ES modules, vitest, Express, React (Vite), better-sqlite3 / pg.
+
+**Spec:** `docs/superpowers/specs/2026-08-09-economy-rebalance-design.md`. Section references below (§4.1, §4.3a…) point at it.
+
+## Global Constraints
+
+- **Branch:** `v1.12-balance-audit`. It already contains the spec and the `tools/` harness. Do not branch off `main` (which is v1.10.0 and lacks v1.11).
+- **Never re-implement game math outside `shared/`.** The client and server both import it. If a formula needs a new input, thread `config` through rather than duplicating a constant.
+- **Every new config leaf needs a matching `TUNABLES` row.** `validateConfig` rejects unknown leaf paths *and* requires every `TUNABLES` entry to be present, so a leaf without a row (or a row without a leaf) fails the whole config.
+- **Boolean tunables use `type: 'boolean'`**, never 0/1 numbers. `validateConfig` enforces both directions.
+- **Do not change `GROWTH` (1.14) or `MILESTONES`.** Tested during calibration; neither moves pacing, and raising `GROWTH` collapses within-tier depth (§6.2).
+- **Run tests with `npx vitest run `** for a single file. Full suite is `npm test`.
+- **Commit after every task.** Do not squash tasks together.
+- **No save migration and no rewriting of player balances** (§4.9 — the owner chose to grandfather).
+- **Appendix A lists every existing test these changes break.** It was produced
+ empirically, by running the suite against the finished math — not guessed. Each
+ task's final step updates the rows assigned to it. Do not skip them and leave
+ the suite red for later tasks to trip over.
+
+---
+
+## File Structure
+
+| File | Responsibility | Tasks |
+|---|---|---|
+| `shared/configSchema.js` | `DEFAULT_CONFIG` values + `TUNABLES` rows | 1 |
+| `shared/gameData.js` | `TIER_DEFS` costs, `SINGULARITY_DEFS` costs/levels/copy | 2, 6 |
+| `shared/gameRules.js` | `computeEffects`, `computeMults`, `migrateGain`, `minigameWafers` | 3, 4, 5, 6, 9 |
+| `shared/reducer.js` | `migrate`, `singularity`, `claimAnomaly`, `claimEventRung` | 5, 6, 7, 8, 11 |
+| `shared/outages.js` | `hazardFrom`, `overheatOutage` targeting | 10 |
+| `shared/events.js` | `validateLadder`, `rungProgress` — rate-scaled rungs | 11 |
+| `shared/state.js` | `overheated` signal carries the downed tier | 12 |
+| `server/eventService.js` | materialises rung targets at join time | 11 |
+| `server/data/seasonalEvents.js` | reseeded seasonal ladders | 11 |
+| `client/src/RackStack.jsx` | two call sites that duplicate shared math | 5, 7, 12 |
+| `client/src/game/components/modals/MessageModal.jsx` | meltdown copy | 12 |
+
+---
+
+## Task 1: Config surface — new tunables and recalibrated values
+
+Everything downstream reads these, so this lands first. Values are the converged set from spec §6.1.
+
+**Files:**
+- Modify: `shared/configSchema.js` (`DEFAULT_CONFIG` and `TUNABLES`)
+- Test: `tests/configSchema.test.js`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `config.heat.{autoVentPerLevel,thermalPerLevel,heatsinkPerLevel,discountFloor}`, `config.anomaly.{creditsSecondsMin,creditsSecondsMax,boostDurationMinMs,boostDurationMaxMs,boostMultMin,boostMultMax}`, `config.production.{levelBonusPerLevel,levelBonusMaxLevel}`, `config.prestige.{migrateDivisor,migrateExponent,corePercentPerCore,coreBonusCap,echoPercentPerLevel,shardsPerCore}`, `config.minigames.balance.waferPerPoint`, `config.risk.{driveFailureTargetsTopTier,overheatTargetsTopTier}`.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/configSchema.test.js`. The file already imports
+`DEFAULT_CONFIG, TUNABLES, validateConfig, upgradeConfig, getAtPath` — do **not**
+re-import them, that is a duplicate-declaration error.
+
+```js
+describe('v1.12 config surface', () => {
+ it('DEFAULT_CONFIG still validates with the new leaves', () => {
+ expect(validateConfig(DEFAULT_CONFIG)).toEqual({ ok: true });
+ });
+
+ it('every new v1.12 path exists and has a TUNABLES row', () => {
+ const paths = [
+ 'heat.autoVentPerLevel', 'heat.thermalPerLevel', 'heat.heatsinkPerLevel', 'heat.discountFloor',
+ 'anomaly.creditsSecondsMin', 'anomaly.creditsSecondsMax',
+ 'anomaly.boostDurationMinMs', 'anomaly.boostDurationMaxMs',
+ 'anomaly.boostMultMin', 'anomaly.boostMultMax',
+ 'production.levelBonusPerLevel', 'production.levelBonusMaxLevel',
+ 'prestige.migrateDivisor', 'prestige.migrateExponent', 'prestige.corePercentPerCore',
+ 'prestige.coreBonusCap', 'prestige.echoPercentPerLevel', 'prestige.shardsPerCore',
+ 'minigames.balance.waferPerPoint',
+ 'risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier',
+ ];
+ const rows = new Set(TUNABLES.map((t) => t.path));
+ for (const p of paths) {
+ expect(getAtPath(DEFAULT_CONFIG, p), `missing DEFAULT_CONFIG leaf ${p}`).toBeDefined();
+ expect(rows.has(p), `missing TUNABLES row ${p}`).toBe(true);
+ }
+ });
+
+ it('the two new risk switches are boolean-typed tunables', () => {
+ for (const p of ['risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier']) {
+ expect(TUNABLES.find((t) => t.path === p).type).toBe('boolean');
+ }
+ });
+
+ it('upgradeConfig folds the new paths into a stored pre-v1.12 config', () => {
+ // a stored config written before v1.12 simply lacks these leaves
+ const old = structuredClone(DEFAULT_CONFIG);
+ delete old.prestige;
+ delete old.production.levelBonusPerLevel;
+ const upgraded = upgradeConfig(old);
+ expect(upgraded.prestige.coreBonusCap).toBe(400);
+ expect(upgraded.production.levelBonusPerLevel).toBe(0.02);
+ expect(validateConfig(upgraded)).toEqual({ ok: true });
+ });
+
+ it('carries the recalibrated v1.12 values', () => {
+ expect(DEFAULT_CONFIG.anomaly.minDelayMs).toBe(420000);
+ expect(DEFAULT_CONFIG.heat.ventCooldownMs).toBe(15000);
+ expect(DEFAULT_CONFIG.risk.hazardMinDelayMs).toBe(7200000);
+ expect(DEFAULT_CONFIG.minigames.winCooldownMs).toBe(300000);
+ expect(DEFAULT_CONFIG.upgrades.maxLevels.engine).toBe(12);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/configSchema.test.js`
+Expected: FAIL — `missing DEFAULT_CONFIG leaf heat.autoVentPerLevel`.
+
+- [ ] **Step 3: Update `DEFAULT_CONFIG`**
+
+In `shared/configSchema.js`, replace these lines:
+
+```js
+ heat: { capacity: 2000, ventPercent: 35, ventCooldownMs: 15000, overheatCooldownMs: 10000, overheatPopupMs: 15000,
+ // v1.12: the heat curve is config-driven so a rebalance never needs a deploy.
+ autoVentPerLevel: 4.0, thermalPerLevel: 0.05, heatsinkPerLevel: 0.15, discountFloor: 0.40 },
+ anomaly: { windowMs: 30000, minDelayMs: 420000, maxDelayMs: 900000,
+ // v1.12: payout magnitudes were hardcoded in claimAnomaly. Note the
+ // boost DURATION is deliberately separate from the payout, and must
+ // never be scaled by Signal Boost - that made the boost permanent.
+ creditsSecondsMin: 30, creditsSecondsMax: 90,
+ boostDurationMinMs: 45000, boostDurationMaxMs: 75000,
+ boostMultMin: 1.5, boostMultMax: 3.0 },
+```
+
+Replace the `production` line with:
+
+```js
+ production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1,
+ levelBonusPerLevel: 0.02, levelBonusMaxLevel: 200 },
+ // v1.12 prestige. `coreBonusCap` is load-bearing: it is what turns Singularity
+ // from a strict downgrade into the required next step, and it is also what makes
+ // a near-linear `migrateExponent` safe (it bounds the runaway).
+ prestige: {
+ migrateDivisor: 2e12,
+ migrateExponent: 1.0,
+ corePercentPerCore: 0.05,
+ coreBonusCap: 400,
+ echoPercentPerLevel: 0.05,
+ shardsPerCore: 0.4,
+ },
+```
+
+In the `minigames` block set `winCooldownMs: 300000`, `rush.waferDivisor: 6`, `debug.waferDivisor: 3`, and add `waferPerPoint: 0.20` to the `balance` object.
+
+In `upgrades.maxLevels` set `engine: 12`.
+
+In the `risk` block set: `hazardMinDelayMs: 7200000`, `hazardMaxDelayMs: 14400000`, `ransomwareFactor: 0.35`, `ransomwareDurationMs: 2700000`, `ispOutageDurationMs: 2400000`, `driveFailureDurationMs: 2700000`, `antivirusPriceSeconds: 500`, `backupIspPriceSeconds: 200`, `spareDrivesPriceSeconds: 250`, `overheatOutageMs: 900000`, and add:
+
+```js
+ // v1.12: a random victim made both hazards unpredictable AND usually trivial.
+ // The top owned tier is legible ("your Quantum Foam Harvester lost a drive")
+ // and actually consequential.
+ driveFailureTargetsTopTier: true,
+ overheatTargetsTopTier: true,
+```
+
+- [ ] **Step 4: Add the `TUNABLES` rows**
+
+Append after the `risk.overclockBoostGain` row:
+
+```js
+ { path: 'risk.driveFailureTargetsTopTier', label: 'Drive failure hits the top tier', type: 'boolean' },
+ { path: 'risk.overheatTargetsTopTier', label: 'Overheat hits the top tier', type: 'boolean' },
+ { path: 'heat.autoVentPerLevel', label: 'Auto-vent per level (heat/s)', min: 0, max: 100, integer: false },
+ { path: 'heat.thermalPerLevel', label: 'Thermal Regulators per level', min: 0, max: 1, integer: false },
+ { path: 'heat.heatsinkPerLevel', label: 'Heat Sink Mastery per level', min: 0, max: 1, integer: false },
+ { path: 'heat.discountFloor', label: 'Heat generation discount floor', min: 0, max: 1, integer: false },
+ { path: 'anomaly.creditsSecondsMin', label: 'Anomaly credits (min seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.creditsSecondsMax', label: 'Anomaly credits (max seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.boostDurationMinMs', label: 'Anomaly boost duration min (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostDurationMaxMs', label: 'Anomaly boost duration max (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostMultMin', label: 'Anomaly boost multiplier min', min: 1, max: 100, integer: false },
+ { path: 'anomaly.boostMultMax', label: 'Anomaly boost multiplier max', min: 1, max: 100, integer: false },
+ { path: 'production.levelBonusPerLevel', label: 'Output bonus per account level', min: 0, max: 1, integer: false },
+ { path: 'production.levelBonusMaxLevel', label: 'Account level bonus cap (levels)', min: 1, max: 10000, integer: true },
+ { path: 'prestige.migrateDivisor', label: 'Migrate: lifetime divisor', min: 1, max: 1e18, integer: false },
+ { path: 'prestige.migrateExponent', label: 'Migrate: gain exponent', min: 0.05, max: 2, integer: false },
+ { path: 'prestige.corePercentPerCore', label: 'Output per Legacy Core', min: 0, max: 1, integer: false },
+ { path: 'prestige.coreBonusCap', label: 'Legacy Core bonus cap (cores)', min: 1, max: 1e9, integer: true },
+ { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of Migrate gain per level', min: 0, max: 1, integer: false },
+ { path: 'prestige.shardsPerCore', label: 'Singularity: shards per Legacy Core', min: 0, max: 10, integer: false },
+ { path: 'minigames.balance.waferPerPoint', label: 'Balance wafers per point', min: 0, max: 100, integer: false },
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run tests/configSchema.test.js tests/reducer.economy.test.js tests/reducer.meta.test.js`
+Expected: the new block PASSES. Nine pre-existing tests now fail on the changed
+values — **fix them now**, they are the Task 1 rows in Appendix A: three in
+`configSchema.test.js` (§3.6 defaults, the v1.6 vent default, the v1.11 risk
+defaults), four `reducer: vent` tests (vent is 700 per 15000ms, was 500 per
+2500ms), `buySupply > supplies survive a Migrate` (antivirus is 500s), and
+`scheduleAnomaly > mutates the passed server object` (`now + 420000 + rng*480000`,
+window 30000).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/configSchema.js tests/configSchema.test.js tests/reducer.economy.test.js tests/reducer.meta.test.js
+git commit -m "v1.12 Task 1: config surface for the rebalance"
+```
+
+---
+
+## Task 2: Tier cost curve (§4.1)
+
+**Files:**
+- Modify: `shared/gameData.js` (`TIER_DEFS` `baseCost` only)
+- Test: `tests/gameData.test.js`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: recalibrated `TIER_DEFS[i].baseCost`. Every `baseProd` and `managerCost` is unchanged.
+
+- [ ] **Step 1: Write the failing test**
+
+First fix the two assertions in the existing `'matches the v1.1 numeric content'`
+test that pin the old costs — they break as soon as Step 3 lands:
+
+```js
+ expect(TIER_DEFS[0]).toEqual({ id: 0, name: 'Spare Raspberry Pi', baseCost: 5, baseProd: 0.5, managerCost: 500 });
+ expect(TIER_DEFS[13].baseCost).toBe(3e17);
+```
+
+Then append (the file already imports `TIER_DEFS` — do not re-import):
+
+```js
+describe('v1.12 tier cost curve', () => {
+ it('cost:production ratio grows ~2.5x per tier', () => {
+ const ratios = TIER_DEFS.map((d) => d.baseCost / d.baseProd);
+ for (let i = 1; i < ratios.length; i++) {
+ const step = ratios[i] / ratios[i - 1];
+ expect(step, `tier ${i} step ${step}`).toBeGreaterThan(2.2);
+ expect(step, `tier ${i} step ${step}`).toBeLessThan(2.8);
+ }
+ });
+
+ it('keeps the opening cheap and makes the top tier the long goal', () => {
+ expect(TIER_DEFS[0].baseCost).toBe(5);
+ expect(TIER_DEFS[13].baseCost).toBe(3e17);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/gameData.test.js`
+Expected: FAIL — the shipped ratio step is ~1.95, below the 2.2 floor.
+
+- [ ] **Step 3: Replace the `baseCost` values**
+
+In `shared/gameData.js`, set `TIER_DEFS` baseCosts to (leaving `name`, `baseProd`, `managerCost` untouched):
+
+```
+ 0: 5 7: 5500000000
+ 1: 150 8: 110000000000
+ 2: 2800 9: 2100000000000
+ 3: 50000 10: 41000000000000
+ 4: 860000 11: 790000000000000
+ 5: 16000000 12: 15000000000000000
+ 6: 290000000 13: 300000000000000000
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/gameData.test.js tests/gameRules.test.js tests/reducer.economy.test.js`
+Expected: PASS. Two further pre-existing tests break on the new tier 0 cost —
+the Task 2 rows in Appendix A. Fix both now: `gameRules > cost math matches v1.1
+formulas` (rescale the expected `costAt`/`costForN`/`maxAffordable` values by
+5/4 — the formulas are unchanged) and `reducer: buy (tiers) > buy 1 tier deducts
+exact cost` (tier 0 now costs 5).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/gameData.js tests/gameData.test.js tests/gameRules.test.js tests/reducer.economy.test.js
+git commit -m "v1.12 Task 2: recalibrated tier cost curve"
+```
+
+---
+
+## Task 3: Config-driven heat curve (§4.5)
+
+**Files:**
+- Modify: `shared/gameRules.js` (`computeEffects`)
+- Test: `tests/gameRules.test.js`
+
+**Interfaces:**
+- Consumes: `config.heat.{discountFloor,thermalPerLevel,heatsinkPerLevel,autoVentPerLevel}` (Task 1).
+- Produces: `computeEffects(meta, config).heatDiscount` and `.autoVentPerSec`, unchanged names.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/gameRules.test.js`:
+
+```js
+describe('v1.12 heat curve is config-driven', () => {
+ const meta = (upgrades = {}, shardUpgrades = {}) => ({
+ upgrades, shardUpgrades, level: 0, legacyCores: 0,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('reads per-level rates and the floor from config', () => {
+ const eff = computeEffects(meta({ thermal: 8, autovent: 8 }, { heatsink: 4 }), DEFAULT_CONFIG);
+ // 1 - 0.05*8 - 0.15*4 = 0, clamped to the 0.40 floor
+ expect(eff.heatDiscount).toBeCloseTo(0.40);
+ expect(eff.autoVentPerSec).toBeCloseTo(32);
+ });
+
+ it('an un-upgraded save generates full heat and vents nothing passively', () => {
+ const eff = computeEffects(meta(), DEFAULT_CONFIG);
+ expect(eff.heatDiscount).toBeCloseTo(1);
+ expect(eff.autoVentPerSec).toBe(0);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: FAIL — `heatDiscount` is 0.15 (hardcoded floor), `autoVentPerSec` is 4.
+
+- [ ] **Step 3: Make the heat curve read config**
+
+In `shared/gameRules.js`, inside `computeEffects`, replace:
+
+```js
+ heatDiscount: Math.max(0.15, 1 - 0.08 * (lv.thermal || 0) - 0.25 * (sv.heatsink || 0)),
+```
+
+with:
+
+```js
+ heatDiscount: Math.max(config.heat.discountFloor,
+ 1 - config.heat.thermalPerLevel * (lv.thermal || 0)
+ - config.heat.heatsinkPerLevel * (sv.heatsink || 0)),
+```
+
+and replace:
+
+```js
+ autoVentPerSec: 0.5 * (lv.autovent || 0),
+```
+
+with:
+
+```js
+ autoVentPerSec: config.heat.autoVentPerLevel * (lv.autovent || 0),
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/gameRules.js tests/gameRules.test.js
+git commit -m "v1.12 Task 3: config-driven heat curve"
+```
+
+---
+
+## Task 4: Cap the account-level output bonus (§4.8)
+
+`levelBonusMult` was `1 + 0.02 * level` with no cap, and the repeatable goals never run out — so level, and therefore output, grew forever.
+
+**Files:**
+- Modify: `shared/gameRules.js` (`computeEffects`)
+- Test: `tests/gameRules.test.js`
+
+**Interfaces:**
+- Consumes: `config.production.{levelBonusPerLevel,levelBonusMaxLevel}` (Task 1).
+- Produces: `computeEffects(...).levelBonusMult`, unchanged name.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/gameRules.test.js`:
+
+```js
+describe('v1.12 level bonus is capped', () => {
+ const meta = (level) => ({
+ upgrades: {}, shardUpgrades: {}, level, legacyCores: 0,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('scales below the cap', () => {
+ expect(computeEffects(meta(50), DEFAULT_CONFIG).levelBonusMult).toBeCloseTo(1 + 0.02 * 50);
+ });
+
+ it('stops scaling at the cap', () => {
+ const atCap = computeEffects(meta(200), DEFAULT_CONFIG).levelBonusMult;
+ const wayPast = computeEffects(meta(5000), DEFAULT_CONFIG).levelBonusMult;
+ expect(atCap).toBeCloseTo(1 + 0.02 * 200);
+ expect(wayPast).toBeCloseTo(atCap);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: FAIL — level 5000 yields 101, not 5.
+
+- [ ] **Step 3: Apply the cap**
+
+In `computeEffects`, replace:
+
+```js
+ levelBonusMult: 1 + 0.02 * (meta.level || 0),
+```
+
+with:
+
+```js
+ levelBonusMult: 1 + config.production.levelBonusPerLevel
+ * Math.min(meta.level || 0, config.production.levelBonusMaxLevel),
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/gameRules.js tests/gameRules.test.js
+git commit -m "v1.12 Task 4: cap the account-level output bonus"
+```
+
+---
+
+## Task 5: Migrate gain and the Legacy Core cap (§4.3a, §4.3b)
+
+The heart of the release. `migrateGain` gains a `config` parameter — update **all three** call sites.
+
+**Files:**
+- Modify: `shared/gameRules.js` (`migrateGain`, `computeMults`)
+- Modify: `shared/reducer.js:143` (pass `config`)
+- Modify: `client/src/RackStack.jsx:1096` (pass `config.data`)
+- Test: `tests/gameRules.test.js`
+
+**Interfaces:**
+- Consumes: `config.prestige.{migrateDivisor,migrateExponent,corePercentPerCore,coreBonusCap}` (Task 1).
+- Produces: `migrateGain(lifetimeRun, legacyGainMult, config) -> number`. `computeMults(meta, config, boostMult)` return shape unchanged.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace the two `migrateGain` assertions in the existing `'xp and migrate math'` test in `tests/gameRules.test.js` with:
+
+```js
+ it('xp and migrate math', () => {
+ expect(xpForLevel(0)).toBe(50);
+ // v1.12: (L / 2e12) ** 1.0. Below the divisor there is nothing to claim yet.
+ expect(migrateGain(1e6, 1, DEFAULT_CONFIG)).toBe(0);
+ expect(migrateGain(2e12, 1, DEFAULT_CONFIG)).toBe(1);
+ expect(migrateGain(4e13, 1, DEFAULT_CONFIG)).toBe(20);
+ expect(migrateGain(0, 1, DEFAULT_CONFIG)).toBe(0);
+ expect(migrateGain(-5, 1, DEFAULT_CONFIG)).toBe(0);
+ });
+```
+
+And append:
+
+```js
+describe('v1.12 Legacy Core bonus is capped', () => {
+ const meta = (legacyCores) => ({
+ upgrades: {}, shardUpgrades: {}, level: 0, legacyCores,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('scales below the cap', () => {
+ const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult;
+ const b = computeMults(meta(100), DEFAULT_CONFIG).racksMult;
+ expect(b / a).toBeCloseTo(1 + 0.05 * 100);
+ });
+
+ it('plateaus at the cap - this is what makes Singularity necessary', () => {
+ const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult;
+ const atCap = computeMults(meta(400), DEFAULT_CONFIG).racksMult;
+ const wayPast = computeMults(meta(1e9), DEFAULT_CONFIG).racksMult;
+ expect(atCap / a).toBeCloseTo(1 + 0.05 * 400);
+ expect(wayPast).toBeCloseTo(atCap);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: FAIL — `migrateGain(1e6, 1, ...)` returns 1 (old `sqrt(L/1e6)`), and the cap test shows an uncapped multiplier.
+
+- [ ] **Step 3: Rewrite `migrateGain`**
+
+In `shared/gameRules.js` replace:
+
+```js
+export function migrateGain(lifetimeRun, legacyGainMult) {
+ return Math.floor(Math.sqrt(lifetimeRun / 1e6) * legacyGainMult);
+}
+```
+
+with:
+
+```js
+/**
+ * v1.12: was sqrt(lifetimeRun / 1e6), which handed out thousands of cores after
+ * a single day and made every later prestige explosive.
+ *
+ * `migrateDivisor` is fixed by one requirement - the first Migrate should land
+ * on day 4-8 - which leaves `migrateExponent` as the only dial controlling how
+ * fast cores climb toward `coreBonusCap`. It has to be near-linear to reach the
+ * cap at all, and that is safe ONLY because computeMults caps the payoff. If you
+ * ever remove that cap, this exponent restores the runaway.
+ */
+export function migrateGain(lifetimeRun, legacyGainMult, config) {
+ if (!(lifetimeRun > 0)) return 0;
+ const p = config.prestige;
+ return Math.floor(Math.pow(lifetimeRun / p.migrateDivisor, p.migrateExponent) * legacyGainMult);
+}
+```
+
+- [ ] **Step 4: Cap the core bonus in `computeMults`**
+
+Replace:
+
+```js
+ const base = (1 + (meta.legacyCores || 0) * 0.05) * eff.firmwareMult * eff.engineMult
+```
+
+with:
+
+```js
+ // v1.12: the core bonus PLATEAUS. Past the cap, extra cores buy no output at
+ // all - they are only fuel for the next Singularity. That plateau is the gate
+ // that makes Singularity worth taking (see the spec's finding 2.5).
+ const pr = config.prestige;
+ const coreMult = 1 + pr.corePercentPerCore
+ * Math.min(meta.legacyCores || 0, pr.coreBonusCap);
+ const base = coreMult * eff.firmwareMult * eff.engineMult
+```
+
+- [ ] **Step 5: Update both call sites**
+
+`shared/reducer.js` line ~143:
+
+```js
+ const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult, config);
+```
+
+`client/src/RackStack.jsx` line ~1096:
+
+```js
+ const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data);
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `npx vitest run tests/gameRules.test.js tests/reducer.meta.test.js`
+Expected: `gameRules` PASS. `reducer.meta` may still fail on the Task 6 assertions — that is expected and fixed next.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add shared/gameRules.js shared/reducer.js client/src/RackStack.jsx tests/gameRules.test.js
+git commit -m "v1.12 Task 5: migrate gain curve and the Legacy Core cap"
+```
+
+---
+
+## Task 6: Shard tree re-pricing, bootstrap, and echo cores (§4.3c, §4.3e)
+
+**Files:**
+- Modify: `shared/gameData.js` (`SINGULARITY_DEFS` costs, `engine` maxLevel, stale copy)
+- Modify: `shared/gameRules.js` (`bootstrapMult`)
+- Modify: `shared/reducer.js` (`migrate`'s `echoBonus`)
+- Test: `tests/reducer.meta.test.js`
+
+**Interfaces:**
+- Consumes: `config.prestige.echoPercentPerLevel` (Task 1), `migrateGain(...)` (Task 5).
+- Produces: `SINGULARITY_DEFS` with `engine.maxLevel === 12`; `computeEffects(...).bootstrapMult === 3 ** level`.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace the existing `'applies deepCacheBonus and bootstrapMult...'` test in `tests/reducer.meta.test.js` with:
+
+```js
+ it('applies deepCacheBonus and bootstrapMult to start credits, and echoCores as a share of gain', () => {
+ const s = initialState();
+ s.run.lifetimeRun = 4e13; // migrateGain = floor((4e13/2e12)^1) = 20
+ s.meta.upgrades.deepcache = 2; // +10 each => +20
+ s.meta.shardUpgrades.bootstrap = 1; // v1.12: x3, not x10
+ s.meta.shardUpgrades.echocores = 3; // v1.12: +5% of gain per level => +15% of 20 = 3
+ const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW);
+ expect(s2.run.credits).toBe((10 + 20) * 3);
+ expect(s2.meta.legacyCores).toBe(20 + 3);
+ });
+
+ it('echoCores cannot be farmed by cheap repeat Migrates', () => {
+ const s = initialState();
+ s.run.lifetimeRun = 2e12; // gain = 1
+ s.meta.shardUpgrades.echocores = 10; // 10 levels => +50% of gain => floor(0.5) = 0
+ const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW);
+ expect(s2.meta.legacyCores).toBe(1);
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: FAIL — credits are `(10+20)*10` and cores are `20 + 3` via the flat bonus.
+
+- [ ] **Step 3: Re-price the shard tree and fix the stale copy**
+
+In `shared/gameData.js`, replace `SINGULARITY_DEFS` with:
+
+```js
+export const SINGULARITY_DEFS = [
+ { id: 'bootstrap', name: 'Quantum Bootstrap', desc: 'Starting Compute Balance after Migrate x3 per level', baseCost: 3, costMult: 2.2, maxLevel: 5 },
+ { id: 'temporal', name: 'Temporal Compression', desc: 'Legacy Core gain from Migrate +25% per level', baseCost: 4, costMult: 2.4, maxLevel: 5 },
+ // v1.12: maxLevel 8 -> 12. At 8 levels "tier 13 is reachable" and "the shard
+ // tree is still a goal at day 45" are mutually exclusive - every setting that
+ // reached tier 13 also maxed the tree. The longer tail decouples them: the x5
+ // that powers the late tiers costs ~7% of the tree.
+ { id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 1.9, maxLevel: 12 },
+ { id: 'heatsink', name: 'Heat Sink Mastery', desc: 'Overclock Bay heat generation -15% per level', baseCost: 3, costMult: 2.2, maxLevel: 4 },
+ { id: 'infiniteloop', name: 'Infinite Loop', desc: 'Milestone thresholds -10% per level, easier to reach', baseCost: 5, costMult: 2.5, maxLevel: 5 },
+ { id: 'echocores', name: 'Echo Cores', desc: 'Migrate grants +5% bonus Legacy Cores per level', baseCost: 4, costMult: 1.8, maxLevel: 10 },
+];
+```
+
+Note the three copy fixes: bootstrap is now x3, Heat Sink is -15% (Task 3), and Echo Cores is proportional. Also update `UPGRADE_DEFS`' `thermal` description to `'Overclock Bay heat generation -5% per level'`.
+
+- [ ] **Step 4: Weaken bootstrap and make echo cores proportional**
+
+`shared/gameRules.js`:
+
+```js
+ bootstrapMult: Math.pow(3, sv.bootstrap || 0),
+```
+
+`shared/reducer.js`, in `migrate`, replace:
+
+```js
+ const echoBonus = eff.echoCoresBonus || 0;
+```
+
+with:
+
+```js
+ // v1.12: a share of the gain, not a flat grant. A flat +10 cores per Migrate is
+ // farmable once cores are scarce - migrate cheaply, repeatedly, for free cores.
+ const echoBonus = Math.floor(gain * config.prestige.echoPercentPerLevel * (eff.echoCoresBonus || 0));
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: the two tests above PASS. Also fix the Task 6 row in Appendix A:
+`reducer: migrate > happy path: fresh run with deepcache/bootstrap start
+credits...` breaks because bootstrap is now x3 and echo cores are proportional —
+recompute its expected credits and cores against the new formulas. The
+singularity tests still fail; that is Task 7.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/gameData.js shared/gameRules.js shared/reducer.js tests/reducer.meta.test.js
+git commit -m "v1.12 Task 6: shard tree re-pricing, bootstrap, proportional echo cores"
+```
+
+---
+
+## Task 7: Singularity yield linear in cores (§4.3d)
+
+With cores capped at 400, `floor(sqrt(cores))` returns 20 shards against a 17,277-shard tree — the meta layer can never progress. `singularity` gains `action` and `config` params; it is registered in `HANDLERS` and already invoked as `handler(s, action, config, now, rng)`, so no call-site change is needed on the server.
+
+**Files:**
+- Modify: `shared/reducer.js` (`singularity`)
+- Modify: `client/src/RackStack.jsx:1097` (duplicated formula)
+- Test: `tests/reducer.meta.test.js`
+
+**Interfaces:**
+- Consumes: `config.prestige.shardsPerCore` (Task 1).
+- Produces: `singularity(s, action, config) -> { ok, shardsGained }`.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace the `'happy path: resets run + legacyCores...'` test in `tests/reducer.meta.test.js` with:
+
+```js
+ it('happy path: resets run + legacyCores, grants shards, bumps stats.singularities', () => {
+ const s = initialState();
+ s.meta.legacyCores = 400; // v1.12: floor(400 * 0.4) = 160
+ s.run.tiers[0].owned = 3;
+ s.meta.wafers = 42; // untouched
+ const { state: s2, result } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW);
+ expect(result.ok).toBe(true);
+ expect(s2.meta.legacyCores).toBe(0);
+ expect(s2.meta.singularityShards).toBe(160);
+ expect(s2.meta.stats.singularities).toBe(1);
+ expect(s2.run.tiers[0].owned).toBe(0);
+ expect(s2.meta.wafers).toBe(42);
+ });
+
+ it('yield is linear in cores, so a capped core pool still funds the tree', () => {
+ const s = initialState();
+ s.meta.legacyCores = 800;
+ const { state: s2 } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW);
+ expect(s2.meta.singularityShards).toBe(320); // 2x the cores => 2x the shards
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: FAIL — 400 cores yields 20 shards, not 160.
+
+- [ ] **Step 3: Make the yield linear**
+
+In `shared/reducer.js` replace:
+
+```js
+function singularity(s) {
+ const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0));
+```
+
+with:
+
+```js
+// v1.12: linear in cores, not sqrt. Once legacyCores is capped (computeMults),
+// the square root has nothing left to damp and only starves the shard tree -
+// 400 cores returned 20 shards against a 17k-shard tree, so the tree could never
+// progress and the late tiers kept no engine.
+function singularity(s, action, config) {
+ const shardsGained = Math.floor((s.meta.legacyCores || 0) * config.prestige.shardsPerCore);
+```
+
+- [ ] **Step 4: Update the client's duplicate**
+
+`client/src/RackStack.jsx` line ~1097:
+
+```js
+ const singularityGain = Math.floor((state.meta.legacyCores || 0) * config.data.prestige.shardsPerCore);
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: PASS, once you also fix the Task 7 row in Appendix A —
+`bestLegacyCores > survives Migrate then Singularity applied in ONE batch`
+asserts specific core/shard numbers that move with the new yield. The behaviour
+under test (the peak survives a batched Migrate+Singularity) must still hold;
+only the numbers change.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/reducer.js client/src/RackStack.jsx tests/reducer.meta.test.js
+git commit -m "v1.12 Task 7: Singularity yield linear in cores"
+```
+
+---
+
+## Task 8: Decouple anomaly payout from boost duration (§4.2)
+
+The headline defect: `eventRewardMult` (Signal Boost) scaled the boost's *duration* as well as its payout, so at max level a 2–4x global multiplier ran permanently.
+
+**Files:**
+- Modify: `shared/reducer.js` (`claimAnomaly`)
+- Test: `tests/reducer.meta.test.js`
+
+**Interfaces:**
+- Consumes: `config.anomaly.*` (Task 1).
+- Produces: unchanged reward shapes — `{ kind: 'credits', amount }` and `{ kind: 'boost', mult, until }`.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace the two anomaly tests at `tests/reducer.meta.test.js:223` and `:241` with:
+
+```js
+ it('deterministic credits branch (rng=0.1), grants credits, reschedules, then re-claim is cooldown_active', () => {
+ const s = openState();
+ const { state: s2, result } = applyAction(s, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1);
+ expect(result.ok).toBe(true);
+ expect(result.reward.kind).toBe('credits');
+ // totalOutputPerSec is 0 (no lanes owned) -> amount = max(0, 20) * eventRewardMult(1) = 20
+ expect(result.reward.amount).toBeCloseTo(20);
+ expect(s2.run.credits).toBeCloseTo(10 + 20);
+
+ // v1.12 anomaly cadence: next = now + 420000 + 0.1*(900000-420000) = now + 468000
+ expect(s2.server.nextAnomalyAt).toBeCloseTo(NOW + 468000);
+ expect(s2.server.anomalyExpiresAt).toBeCloseTo(NOW + 468000 + 30000);
+
+ const { result: result2 } = applyAction(s2, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1);
+ expect(result2).toEqual({ ok: false, error: 'cooldown_active' });
+ });
+
+ it('boost branch (rng=0.9) stores a mult/until boost on server', () => {
+ const s = openState();
+ const { state: s2, result } = applyAction(s, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9);
+ expect(result.ok).toBe(true);
+ expect(result.reward.kind).toBe('boost');
+ // mult = 1.5 + 0.9*(3.0-1.5) = 2.85
+ expect(result.reward.mult).toBeCloseTo(2.85);
+ // duration = 45000 + 0.9*(75000-45000) = 72000ms
+ expect(s2.server.boost.until).toBeCloseTo(NOW + 72000);
+ });
+
+ it('Signal Boost scales the PAYOUT but never the boost duration', () => {
+ const withSignal = openState();
+ withSignal.meta.upgrades.signal = 10; // eventRewardMult = 3
+ const { state: sBoost } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9);
+ // identical duration to the un-upgraded save above - this is the whole fix
+ expect(sBoost.server.boost.until).toBeCloseTo(NOW + 72000);
+
+ const { result: credits } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1);
+ expect(credits.reward.amount).toBeCloseTo(60); // 20 * 3
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: FAIL — mult is 4 (from the `[2,3,4]` literal) and the Signal Boost test shows a 216000ms duration.
+
+- [ ] **Step 3: Read the reward magnitudes from config**
+
+In `shared/reducer.js`, in `claimAnomaly`, replace:
+
+```js
+ const seconds = 30 + rng() * 60;
+```
+
+with:
+
+```js
+ const ac = config.anomaly;
+ const seconds = ac.creditsSecondsMin + rng() * (ac.creditsSecondsMax - ac.creditsSecondsMin);
+```
+
+and replace:
+
+```js
+ const mult = [2, 3, 4][Math.floor(rng() * 3)];
+ const duration = (45 + rng() * 30) * eff.eventRewardMult;
+```
+
+with:
+
+```js
+ // v1.12: the boost's DURATION must never be scaled by eventRewardMult. It was,
+ // and at max Signal Boost that made the duration exceed the respawn interval -
+ // a permanent 2-4x global multiplier. Signal Boost scales the payout only.
+ const ab = config.anomaly;
+ const mult = ab.boostMultMin + rng() * (ab.boostMultMax - ab.boostMultMin);
+ const duration = (ab.boostDurationMinMs + rng() * (ab.boostDurationMaxMs - ab.boostDurationMinMs)) / 1000;
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/reducer.meta.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/reducer.js tests/reducer.meta.test.js
+git commit -m "v1.12 Task 8: Signal Boost no longer scales anomaly boost duration"
+```
+
+---
+
+## Task 9: Balance minigame wafer coefficient (§4.7)
+
+**Files:**
+- Modify: `shared/gameRules.js` (`minigameWafers`)
+- Test: `tests/gameRules.test.js`
+
+**Interfaces:**
+- Consumes: `config.minigames.balance.waferPerPoint` (Task 1).
+- Produces: `minigameWafers(game, metric, meta, config)` — signature unchanged.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace the `'minigame payouts'` test in `tests/gameRules.test.js` with:
+
+```js
+ it('minigame payouts', () => {
+ // v1.12 divisors: rush 6, debug 3; balance is a config coefficient
+ expect(minigameWafers('rush', 40, meta0, DEFAULT_CONFIG)).toBe(6);
+ expect(minigameWafers('match', 10, meta0, DEFAULT_CONFIG)).toBe(20);
+ expect(minigameWafers('balance', 150, meta0, DEFAULT_CONFIG)).toBe(30); // 150 * 0.20
+ });
+
+ it('balance payout is config-driven, not a hardcoded 1.5', () => {
+ const doubled = structuredClone(DEFAULT_CONFIG);
+ doubled.minigames.balance.waferPerPoint = 0.40;
+ expect(minigameWafers('balance', 150, meta0, doubled)).toBe(60);
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: FAIL — balance returns 225 (`150 * 1.5`).
+
+- [ ] **Step 3: Read the coefficient from config**
+
+In `shared/gameRules.js` replace:
+
+```js
+ if (game === 'balance') return Math.max(1, Math.floor(metric * 1.5 * lucky));
+```
+
+with:
+
+```js
+ if (game === 'balance') return Math.max(1, Math.floor(metric * mg.balance.waferPerPoint * lucky));
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/gameRules.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/gameRules.js tests/gameRules.test.js
+git commit -m "v1.12 Task 9: balance minigame wafer coefficient is tunable"
+```
+
+---
+
+## Task 10: Drive failure and overheat hit the top owned tier (§4.4)
+
+**Files:**
+- Modify: `shared/outages.js` (`hazardFrom`, `overheatOutage`)
+- Test: `tests/outages.test.js`
+
+**Interfaces:**
+- Consumes: `config.risk.{driveFailureTargetsTopTier,overheatTargetsTopTier}` (Task 1).
+- Produces: `hazardFrom(scheduledAt, config, state)` and `overheatOutage(state, config, now)` — signatures unchanged.
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/outages.test.js` already imports `hazardFrom` from `shared/outages.js`
+but does **not** import `overheatOutage`, `DEFAULT_CONFIG` or `initialState`.
+Add `overheatOutage` to the existing `shared/outages.js` import list, and add
+these two new lines below it:
+
+```js
+import { DEFAULT_CONFIG } from '../shared/configSchema.js';
+import { initialState } from '../shared/state.js';
+```
+
+Then append:
+
+```js
+function stateWithTiers(indices) {
+ const s = initialState();
+ for (const i of indices) s.run.tiers[i].owned = 5;
+ return s;
+}
+
+describe('v1.12 hazards target the top owned tier', () => {
+ const driveOnly = () => {
+ const c = structuredClone(DEFAULT_CONFIG);
+ c.risk.ransomwareEnabled = false;
+ c.risk.ispOutageEnabled = false;
+ return c;
+ };
+
+ it('drive failure always picks the highest owned tier', () => {
+ const c = driveOnly();
+ const s = stateWithTiers([0, 3, 7]);
+ for (const at of [1e12, 1e12 + 137, 1e12 + 9999]) {
+ const h = hazardFrom(at, c, s);
+ expect(h.kind).toBe('driveFailure');
+ expect(h.scope).toEqual({ lane: 'tiers', index: 7 });
+ }
+ });
+
+ it('the switch restores random targeting', () => {
+ const c = driveOnly();
+ c.risk.driveFailureTargetsTopTier = false;
+ const s = stateWithTiers([0, 3, 7]);
+ const picks = new Set([1e12, 2e12, 3e12, 4e12, 5e12].map((at) => hazardFrom(at, c, s).scope.index));
+ expect([...picks].every((i) => [0, 3, 7].includes(i))).toBe(true);
+ });
+
+ it('overheat downs the top owned tier', () => {
+ const s = stateWithTiers([0, 2, 9]);
+ const o = overheatOutage(s, DEFAULT_CONFIG, 1e12);
+ expect(o.scope).toEqual({ lane: 'tiers', index: 9 });
+ expect(o.factor).toBe(0);
+ expect(o.endAt - o.startAt).toBe(DEFAULT_CONFIG.risk.overheatOutageMs);
+ });
+
+ it('overheat with no owned tier still returns null', () => {
+ expect(overheatOutage(initialState(), DEFAULT_CONFIG, 1e12)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/outages.test.js`
+Expected: FAIL — the victim varies with the timestamp.
+
+- [ ] **Step 3: Target the top owned tier**
+
+In `shared/outages.js`, in `hazardFrom`'s drive-failure branch, replace:
+
+```js
+ scope = { lane: 'tiers', index: owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)] };
+```
+
+with:
+
+```js
+ // v1.12: the TOP owned tier. A random victim was both unpredictable and
+ // usually trivial (~1/14 of output); the top tier is legible in the UI and
+ // actually worth insuring against. `owned` is built in ascending order.
+ scope = {
+ lane: 'tiers',
+ index: config.risk.driveFailureTargetsTopTier
+ ? owned[owned.length - 1]
+ : owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)],
+ };
+```
+
+In `overheatOutage`, replace:
+
+```js
+ const index = owned[Math.floor(unitAt(now, 2) * owned.length)];
+```
+
+with:
+
+```js
+ const index = config.risk.overheatTargetsTopTier
+ ? owned[owned.length - 1]
+ : owned[Math.floor(unitAt(now, 2) * owned.length)];
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run tests/outages.test.js`
+Expected: PASS, including the pre-existing cure-vs-supply property test. Two
+pre-existing tests need updating first — the Task 10 rows in Appendix A:
+`scopes each kind as the spec table says` (drive failure now targets the highest
+owned index) and `reports a rate, never a next time` (`hazardRatePerHour` is now
+1/3, was 1/6).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/outages.js tests/outages.test.js
+git commit -m "v1.12 Task 10: drive failure and overheat hit the top owned tier"
+```
+
+---
+
+## Task 11: Rate-scaled Live Event ladders (§4.6)
+
+Ladder targets are absolute constants, so every FLOPS rung of every seasonal event clears in under 0.02 seconds. Targets become *seconds of output*, materialised at join time — the same snapshot pattern `rolloverContracts` already uses, and for the same reason: a rate-scaled target recomputed on every read would recede as fast as the player approached it.
+
+**Files:**
+- Modify: `shared/events.js` (`validateLadder`, `rungProgress`)
+- Modify: `server/eventService.js` (`joinEventIfEligible`)
+- Modify: `shared/reducer.js` (`claimEventRung`)
+- Modify: `server/data/seasonalEvents.js`
+- Test: `tests/events.test.js`
+
+**Interfaces:**
+- Consumes: `goalCtx(state, config, now).totalOutputPerSec`.
+- Produces: `rungProgress(rung, meta, baseline, materialisedTarget)` — a 4th optional param; `meta.eventProgress.targets: number[]` parallel to the ladder.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/events.test.js`. It already imports `validateLadder` and
+`rungProgress` — do not re-import them.
+
+```js
+describe('v1.12 rate-scaled event rungs', () => {
+ it('accepts a secondsOfOutput unit', () => {
+ expect(validateLadder([
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 10 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ ])).toEqual({ ok: true });
+ });
+
+ it('rejects an unknown unit', () => {
+ const r = validateLadder([{ metric: 'flopsEarned', target: 600, unit: 'furlongs', reward: { wafers: 10 } }]);
+ expect(r.ok).toBe(false);
+ expect(r.errors[0]).toMatch(/unit/);
+ });
+
+ it('still requires targets to strictly increase within a (metric, unit) pair', () => {
+ const r = validateLadder([
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 10 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ ]);
+ expect(r.ok).toBe(false);
+ });
+
+ it('uses the materialised target when one is supplied', () => {
+ const rung = { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: {} };
+ const meta = { stats: { lifetimeFlopsAllTime: 5_000_000 } };
+ // 600s of output at 10k/s = 6,000,000 - not met by 5,000,000
+ expect(rungProgress(rung, meta, {}, 6_000_000).met).toBe(false);
+ expect(rungProgress(rung, meta, {}, 4_000_000).met).toBe(true);
+ });
+
+ it('falls back to the literal target for absolute rungs', () => {
+ const rung = { metric: 'minigamesWon', target: 5, reward: {} };
+ expect(rungProgress(rung, { stats: { minigamesWon: 5 } }, {}).met).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/events.test.js`
+Expected: FAIL — `validateLadder` has no `unit` concept and `rungProgress` takes three params.
+
+- [ ] **Step 3: Teach `validateLadder` about `unit`**
+
+In `shared/events.js`, add near `REWARD_KEYS`:
+
+```js
+const RUNG_UNITS = ['absolute', 'secondsOfOutput'];
+```
+
+Inside `validateLadder`'s `forEach`, after the `metric` check add:
+
+```js
+ const unit = rung.unit === undefined ? 'absolute' : rung.unit;
+ if (!RUNG_UNITS.includes(unit)) {
+ errors.push(`rung ${i}: unknown unit ${rung.unit}`);
+ }
+```
+
+and change the strictly-increasing key from `metric` to `` `${metric}:${unit}` `` so a ladder may carry both an absolute and a rate-scaled series for the same metric:
+
+```js
+ const key = `${metric}:${unit}`;
+ const prev = Object.prototype.hasOwnProperty.call(lastTargetByMetric, key) ? lastTargetByMetric[key] : -Infinity;
+ if (target <= prev) errors.push(`rung ${i}: target must strictly increase within metric ${metric}`);
+ lastTargetByMetric[key] = target;
+```
+
+- [ ] **Step 4: Let `rungProgress` take a materialised target**
+
+Replace `rungProgress` with:
+
+```js
+/**
+ * `materialisedTarget` is the join-time snapshot from meta.eventProgress.targets
+ * (see server/eventService.js). It is passed for EVERY rung, absolute or not, so
+ * this function never needs to know the player's output. When it is absent - an
+ * older save that joined before v1.12 - fall back to the literal target, which
+ * is exactly the pre-v1.12 behaviour.
+ */
+export function rungProgress(rung, meta, baseline, materialisedTarget) {
+ const value = eventMetricValue(rung.metric, meta) ?? 0;
+ const hasBaseline = baseline && typeof rung.metric === 'string'
+ && Object.prototype.hasOwnProperty.call(baseline, rung.metric);
+ const base = hasBaseline ? baseline[rung.metric] : 0;
+ const current = Math.max(0, value - (typeof base === 'number' ? base : 0));
+ const target = Number.isFinite(materialisedTarget) ? materialisedTarget : rung.target;
+ return { current, target, met: current >= target };
+}
+```
+
+- [ ] **Step 5: Materialise targets at join time**
+
+In `server/eventService.js`, add the import:
+
+```js
+import { goalCtx } from '../shared/goals.js';
+```
+
+`joinEventIfEligible` needs the config to build `goalCtx`. Change its signature to
+`joinEventIfEligible(userId, state, now = Date.now(), config = null)` and update the
+single call site in `server/stateService.js` to `joinEventIfEligible(userId, state, now, config)`.
+
+Then, just before the `state.meta.eventProgress = {...}` assignment, add:
+
+```js
+ // Materialise each rung's effective target ONCE, at join. A rate-scaled target
+ // recomputed on every read would recede as fast as the player approached it -
+ // the same reasoning as rolloverContracts' snapshot.
+ const outputPerSec = config ? goalCtx(state, config, now).totalOutputPerSec : 0;
+ const targets = (activeEvent.ladder || []).map((rung) => (
+ rung.unit === 'secondsOfOutput' ? rung.target * outputPerSec : rung.target
+ ));
+```
+
+and include `targets` in the record:
+
+```js
+ state.meta.eventProgress = {
+ eventId: activeEvent.id,
+ joinedAt: now,
+ endsAt,
+ baseline,
+ targets,
+ rungsClaimed: [],
+ };
+```
+
+- [ ] **Step 6: Pass the materialised target through `claimEventRung`**
+
+In `shared/reducer.js`, in `claimEventRung`, replace:
+
+```js
+ } else if (!rungProgress(ladder[index], s.meta, ep.baseline).met) {
+```
+
+with:
+
+```js
+ } else if (!rungProgress(ladder[index], s.meta, ep.baseline,
+ Array.isArray(ep.targets) ? ep.targets[index] : undefined).met) {
+```
+
+- [ ] **Step 7: Reseed the seasonal ladders**
+
+In `server/data/seasonalEvents.js`, convert every `flopsEarned` rung to `unit: 'secondsOfOutput'` and raise the count-based rungs to match the retuned economy. For Summer Surge (14 days) the ladder becomes:
+
+```js
+ ladder: [
+ { metric: 'wafersEarned', target: 400, reward: { wafers: 20 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } },
+ { metric: 'minigamesWon', target: 10, reward: { wafers: 15 } },
+ { metric: 'tapesEarned', target: 150, reward: { tapes: 10 } },
+ { metric: 'blocksClaimed', target: 10, reward: { wafers: 25 } },
+ { metric: 'wafersEarned', target: 1500, reward: { wafers: 40 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 35 } },
+ { metric: 'minigamesWon', target: 30, reward: { wafers: 35 } },
+ { metric: 'tapesEarned', target: 500, reward: { tapes: 30 } },
+ { metric: 'blocksClaimed', target: 30, reward: { wafers: 75, tapes: 20 } },
+ ],
+```
+
+Spooky Packets (8 days, minigame-themed):
+
+```js
+ ladder: [
+ { metric: 'minigamesWon', target: 12, reward: { wafers: 15 } },
+ { metric: 'tapesEarned', target: 100, reward: { tapes: 8 } },
+ { metric: 'wafersEarned', target: 250, reward: { wafers: 20 } },
+ { metric: 'blocksClaimed', target: 6, reward: { wafers: 15 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } },
+ { metric: 'minigamesWon', target: 36, reward: { wafers: 40 } },
+ { metric: 'tapesEarned', target: 300, reward: { tapes: 20 } },
+ { metric: 'wafersEarned', target: 900, reward: { wafers: 45 } },
+ { metric: 'blocksClaimed', target: 18, reward: { tapes: 35 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 60 } },
+ ],
+```
+
+Black Frame Friday (4 days, short and brutal):
+
+```js
+ ladder: [
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ { metric: 'wafersEarned', target: 150, reward: { wafers: 25 } },
+ { metric: 'tapesEarned', target: 50, reward: { tapes: 15 } },
+ { metric: 'blocksClaimed', target: 3, reward: { wafers: 20 } },
+ { metric: 'minigamesWon', target: 4, reward: { wafers: 20 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 55 } },
+ { metric: 'wafersEarned', target: 500, reward: { wafers: 60 } },
+ { metric: 'tapesEarned', target: 160, reward: { tapes: 45 } },
+ { metric: 'blocksClaimed', target: 9, reward: { tapes: 60 } },
+ { metric: 'minigamesWon', target: 12, reward: { wafers: 50, tapes: 30 } },
+ ],
+```
+
+Frost Uptime (21 days, cold-storage themed):
+
+```js
+ ladder: [
+ { metric: 'blocksClaimed', target: 20, reward: { wafers: 20 } },
+ { metric: 'tapesEarned', target: 250, reward: { tapes: 20 } },
+ { metric: 'wafersEarned', target: 600, reward: { wafers: 30 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 25 } },
+ { metric: 'minigamesWon', target: 15, reward: { wafers: 25 } },
+ { metric: 'blocksClaimed', target: 60, reward: { tapes: 50 } },
+ { metric: 'tapesEarned', target: 800, reward: { tapes: 60 } },
+ { metric: 'wafersEarned', target: 2200, reward: { wafers: 80 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 70 } },
+ { metric: 'minigamesWon', target: 45, reward: { wafers: 100, tapes: 80 } },
+ ],
+```
+
+Note every `flops` **reward** has been converted to wafers/tapes. A literal FLOPS
+reward has exactly the scaling problem the targets did — 8,000 FLOPS is
+meaningless the moment output passes a few thousand per second.
+
+- [ ] **Step 8: Run tests to verify they pass**
+
+Run: `npx vitest run tests/events.test.js tests/db.events.test.js tests/api.events.test.js tests/eventService.test.js`
+Expected: PASS. `tests/db.events.test.js` asserts every seeded ladder passes `validateLadder`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add shared/events.js shared/reducer.js server/eventService.js server/stateService.js server/data/seasonalEvents.js tests/events.test.js
+git commit -m "v1.12 Task 11: event ladders scale with output"
+```
+
+---
+
+## Task 12: The meltdown modal names the cause and the victim (§4.5)
+
+Finding 2.7 is as much a legibility failure as a math one — and the shipped copy is now actively wrong: it says *"the lane is frozen for a short cooldown - no nodes were lost"*, which describes pre-v1.11 behaviour. Since v1.11 an overheat downs a rack tier.
+
+**Files:**
+- Modify: `shared/state.js` (`overheated` carries the downed tier)
+- Modify: `client/src/RackStack.jsx` (three `setModal({ type: 'meltdown' })` sites)
+- Modify: `client/src/game/components/modals/MessageModal.jsx`
+- Test: `tests/state.test.js`
+
+**Interfaces:**
+- Consumes: `overheatOutage(...)` returning the outage (Task 10).
+- Produces: `state.server.overheated` is now `{ tierIndex: number } | true` — still truthy, so existing `if (server.overheated)` checks keep working.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/state.test.js`. It already imports `DEFAULT_CONFIG`,
+`initialState` and `evaluate` — no new imports are needed.
+
+```js
+describe('v1.12 overheat reports which tier went dark', () => {
+ it('carries the downed tier index on the one-shot signal', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 10;
+ s.run.tiers[4].owned = 3;
+ s.run.overclock[0].owned = 500; // enough heat to cross the cap
+ s.run.heat = DEFAULT_CONFIG.heat.capacity - 1;
+ const now = Date.now();
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, now - 5000, now);
+ expect(s2.server.overheated).toEqual({ tierIndex: 4 });
+ expect(s2.server.outages.some((o) => o.kind === 'overheat' && o.scope.index === 4)).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/state.test.js`
+Expected: FAIL — `overheated` is `true`, not `{ tierIndex: 4 }`.
+
+- [ ] **Step 3: Carry the tier index on the signal**
+
+In `shared/state.js`, in the online branch's overheat handling, replace:
+
+```js
+ s.run.heat = 0;
+ s.server.overheated = true;
+ // ... comment ...
+ if (!overheatOutage(s, config, now)) {
+ s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs;
+ }
+```
+
+with:
+
+```js
+ s.run.heat = 0;
+ // v1.12: carry WHICH tier went dark so the client can name it. Still
+ // truthy either way, so existing `if (server.overheated)` checks hold.
+ const downed = overheatOutage(s, config, now);
+ s.server.overheated = downed ? { tierIndex: downed.scope.index } : true;
+ if (!downed) {
+ s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs;
+ }
+```
+
+- [ ] **Step 4: Pass it to the modal**
+
+In `client/src/RackStack.jsx`, at each of the three sites, replace
+`setModal({ type: 'meltdown' })` with:
+
+```js
+setModal({ type: 'meltdown', tierIndex: serverState.server.overheated?.tierIndex });
+```
+
+(at line ~529 the variable is `initial`, and at ~569 it is `next` — use the matching name).
+
+- [ ] **Step 5: Rewrite the copy**
+
+In `client/src/game/components/modals/MessageModal.jsx`, the `meltdown` case must accept the tier. Add `TIER_DEFS` to the imports:
+
+```js
+import { TIER_DEFS } from '@shared/gameData.js';
+```
+
+and replace the case body with:
+
+```js
+ case 'meltdown': {
+ const downed = typeof modal.tierIndex === 'number' ? TIER_DEFS[modal.tierIndex] : null;
+ return (
+ <>
+
Overheated!
+
+ {downed
+ ? `Your Overclock Bay hit 100% heat and took your ${downed.name} offline while it cools. Overclocking is what multiplies your racks, so running it hot risks the very thing it amplifies.`
+ : 'Your Overclock Bay hit 100% heat and the lane is frozen while it cools.'}
+ {' '}Vent before the gauge fills, or invest in Thermal Regulators / Auto-Vent to raise the fleet you can run unattended.
+
+
+ >
+ );
+ }
+```
+
+The component signature is `MessageModal({ modal, onClose })`, so `modal.tierIndex` is already in scope — no prop plumbing is needed beyond what Step 4 puts on the modal object. `ModalRoot` passes `modal` straight through.
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `npx vitest run tests/state.test.js && npm run build --prefix client`
+Expected: tests PASS and the client builds (catches a bad `@shared` import).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add shared/state.js client/src/RackStack.jsx client/src/game/components/modals/MessageModal.jsx tests/state.test.js
+git commit -m "v1.12 Task 12: meltdown modal names the downed tier"
+```
+
+---
+
+## Task 13: Verify against the acceptance harness, then ship
+
+The harness has been measuring sandboxes; now it measures `shared/` itself.
+
+**Files:**
+- Modify: `CHANGELOG.md`, `package.json` (version), `README.md`
+- Test: the whole suite plus `tools/pace.mjs`
+
+**Interfaces:**
+- Consumes: every prior task.
+- Produces: a verified release.
+
+- [ ] **Step 1: Run the full test suite**
+
+Run: `npm test`
+Expected: PASS. Investigate every failure — do not update an assertion without understanding whether it encodes a real behaviour change (Tasks 5–10 legitimately change several) or a genuine regression.
+
+- [ ] **Step 2: Run the acceptance harness against the real `shared/`**
+
+```bash
+mkdir -p /tmp/v112 && SHARED=shared DAYS=45 node tools/pace.mjs > /tmp/v112/FINAL.txt
+python3 tools/score.py /tmp/v112
+```
+
+Expected, matching spec §6.1:
+
+| criterion | target | expected |
+|---|---|---|
+| A1 tier 4 | day 1–2 | day 1 |
+| A2 tier 7 | day 2–4 | day 2 |
+| A3 tier 10 | day 18–25 | day 18 |
+| A4 tier 13 | day 28–45 | ~day 44 |
+| A5 first Migrate | day 4–8 | day 4 |
+| A6 first Singularity | day 11–21 | day 21 |
+| A7 Singularities | 2–4 | 4 |
+| A8 shard tree | < 40% | ~38% |
+
+`score.py` should report **7/8** (A2 is expected to read "too fast" — the target was
+revised in §6.2 but the scorer's band was not).
+
+If A3/A4 drift by more than ~3 days, the likely cause is a mistyped `TIER_DEFS`
+value in Task 2 or a `prestige` value in Task 1 — diff `shared/` against a freshly
+generated reference:
+
+```bash
+python3 tools/mksandbox.py /tmp/ref 2.50 10 1.00 400 2e12 1.14 0.4 12
+diff /tmp/ref/gameData.js shared/gameData.js
+```
+
+- [ ] **Step 3: Confirm the non-pacing criteria**
+
+```bash
+node -e "
+const {DEFAULT_CONFIG:c}=await import('./shared/configSchema.js');
+const r=c.risk, hz=(r.hazardMinDelayMs+r.hazardMaxDelayMs)/2/3600000;
+const rw=(1-r.ransomwareFactor)*r.ransomwareDurationMs/3600000;
+const isp=(1-r.ispOutageFactor)*r.ispOutageDurationMs/3600000*0.20;
+const dr=(1-r.driveFailureFactor)*r.driveFailureDurationMs/3600000*0.30;
+console.log('A10 drag', (100*((rw+isp+dr)/3)/hz).toFixed(1)+'% (target 8-12)');
+console.log('A11 EV', (rw/(r.antivirusPriceSeconds/3600)).toFixed(2), (isp/(r.backupIspPriceSeconds/3600)).toFixed(2), (dr/(r.spareDrivesPriceSeconds/3600)).toFixed(2), ' (all >= 2.0)');
+"
+```
+
+Expected: drag 9.4%, EV 3.51 / 2.40 / 3.24.
+
+- [ ] **Step 4: Update the changelog, version and README**
+
+`package.json`: `"version": "1.12.0"`.
+
+Prepend to `CHANGELOG.md`:
+
+```markdown
+## v1.12.0 - Economy Rebalance
+
+Retunes the whole economy to AdVenture-Communist pacing. A daily player now
+reaches tiers 0-9 in the first four days, tier 10 around day 18, and tier 13
+around day 44 - previously the entire 14-tier ladder fell inside two weeks and a
+Singularity was available every single day.
+
+- Signal Boost no longer scales the anomaly boost's DURATION, only its payout.
+ At max level it previously made a 2-4x global multiplier permanent.
+- Legacy Cores now plateau at a cap, which is what makes Singularity worth
+ taking. Singularity yield is linear in cores rather than sqrt.
+- Quantum Bootstrap is x3 per level (was x10, which handed you 11M credits at
+ every Migrate). Echo Cores grants a share of the Migrate gain, not a flat
+ amount.
+- Hazards are twice as frequent but individually softer, supplies are far
+ cheaper, and drive failures and overheats now hit your TOP rack tier. Buying
+ supplies is now clearly worth it; previously it was break-even at best.
+- Venting is slower but passive Auto-Vent is much stronger, so overheating is a
+ real trade-off instead of "free if you tap, catastrophic if you don't".
+- Live Event ladders scale with your output. Their FLOPS rungs previously
+ cleared instantly.
+- Minigames pay far fewer wafers.
+- Reward magnitudes that were hardcoded are now admin-tunable.
+
+Existing saves are unaffected in balance terms - no progress is rewritten.
+```
+
+Update the README's opening section to mention v1.12 in the same style as the
+v1.3 / v1.4 paragraphs.
+
+- [ ] **Step 5: Full verification before shipping**
+
+```bash
+npm test && npm run build --prefix client
+```
+
+Expected: both PASS.
+
+- [ ] **Step 6: Commit and open a draft PR**
+
+```bash
+git add -A
+git commit -m "v1.12.0: economy rebalance"
+git push
+gh pr create --draft --title "v1.12.0 Economy Rebalance" \
+ --body "Implements docs/superpowers/specs/2026-08-09-economy-rebalance-design.md. Verified against tools/pace.mjs: 7/8 acceptance criteria (A2 revised in spec §6.2). See CHANGELOG.md."
+```
+
+---
+
+---
+
+## Appendix A: existing tests these changes break
+
+Produced empirically: the suite was run against the completed math and every
+failure recorded. **22 tests across 6 files.** Each row names the task that
+causes the break and must therefore fix it.
+
+Baselines for the subset that exercises game math (17 files, 355 tests) are all
+green before any change, so anything else that goes red is a genuine regression.
+
+| Test | Task | Why it breaks | Fix |
+|---|---|---|---|
+| `configSchema > has the spec §3.6 defaults` | 1 | pins `heat.ventPercent` 25, `anomaly.minDelayMs` 70000, `minigames.winCooldownMs` 30000 | update to 35 / 420000 / 300000 |
+| `configSchema > v1.6 heat tunables > drops a stored ventAmount and adopts the ventPercent default` | 1 | asserts the default `ventPercent` is 25 | expect 35 |
+| `configSchema > boolean tunables (v1.11) > has the v1.11 risk defaults and every risk leaf is a TUNABLES row` | 1 | risk values changed and two boolean leaves are new | update the expected values; the "every risk leaf has a row" assertion should now pass for the two new switches |
+| `gameData > matches the v1.1 numeric content` | 2 | pins `TIER_DEFS[0]` (baseCost 4) and `TIER_DEFS[13].baseCost` 4.6e15 | baseCost 5 and 3e17 (Task 2 Step 1 already covers this) |
+| `gameRules > cost math matches v1.1 formulas` | 2 | `costAt`/`costForN`/`maxAffordable` are computed from `TIER_DEFS[0].baseCost`, now 5 not 4 | rescale the expected values by 5/4; the formulas themselves are unchanged |
+| `gameRules > xp and migrate math` | 5 | new `migrateGain` curve and signature | Task 5 Step 1 replaces it |
+| `gameRules > minigame payouts` | 9 | rush/debug divisors and the balance coefficient | Task 9 Step 1 replaces it |
+| `outages > hazard derivation > scopes each kind as the spec table says` | 10 | drive failure now targets the top owned tier | expect the highest owned index rather than a derived one |
+| `outages > hazard scheduling and firing > reports a rate, never a next time` | 10 | `hazardRatePerHour` is now 3600000/3h = 0.333, was 0.167 | expect 1/3 |
+| `reducer: buy (tiers) > buy 1 tier deducts exact cost` | 2 | tier 0 costs 5, not 4 | update the expected credit deduction |
+| `reducer: vent > vents heat and starts the cooldown` | 1 | vent is 35% of capacity (700, was 500) and the cooldown is 15000ms (was 2500) | update both |
+| `reducer: vent > respects cooldown and overheat lockout` | 1 | cooldown 2500 → 15000 | advance the clock by the new cooldown |
+| `reducer: vent > scales with a raised heat capacity` | 1 | 35% of the raised capacity | recompute |
+| `reducer: vent > includes the Cold Storage heatCapacityBonus in the capacity it vents against` | 1 | 35% of (capacity + bonus) | recompute |
+| `buySupply (v1.11) > supplies survive a Migrate` | 1 | antivirus is 500s of output, was 900 | update the expected cost |
+| `reducer: migrate > happy path: fresh run with deepcache/bootstrap start credits, +gain+echo cores, stats.migrates+1` | 6 | bootstrap is ×3 (was ×10), echo cores are proportional, and the gain curve changed | recompute against the new formulas |
+| `reducer: migrate > applies deepCacheBonus and bootstrapMult...` | 6 | same | Task 6 Step 1 replaces it |
+| `reducer: singularity > happy path` | 7 | yield is linear in cores | Task 7 Step 1 replaces it |
+| `reducer: claimAnomaly > deterministic credits branch (rng=0.1)...` | 8 | new anomaly cadence | Task 8 Step 1 replaces it |
+| `reducer: claimAnomaly > boost branch (rng=0.9)...` | 8 | mult is now a continuous range | Task 8 Step 1 replaces it |
+| `bestLegacyCores > survives Migrate then Singularity applied in ONE batch` | 7 | the shard yield changed, so the asserted peak/shard numbers move | recompute; the *behaviour* under test (the peak survives) must still hold |
+| `scheduleAnomaly > mutates the passed server object with next/expires derived from config + rng` | 1 | delays are 420000–900000, window 30000 | expect `now + 420000 + rng*480000` |
+
+### Running the suite in a fresh worktree
+
+`npm test` targets the Postgres backend and needs `@testcontainers/postgresql`
+plus a container runtime. If the worktree has no `node_modules`, symlink the main
+checkout's, then use the sqlite backend:
+
+```bash
+ln -sfn /home/nec/Code/rackstack-server/node_modules node_modules
+TEST_BACKEND=sqlite npx vitest run
+```
+
+If some DB/auth suites fail to *load* (missing `supertokens-node`,
+`@testcontainers/postgresql`), that is an environment gap in the linked
+`node_modules`, not a regression — confirm by checking those same files fail on
+an unmodified tree before investigating. The 17 math files listed at the top of
+this appendix must be green either way.
+
+---
+
+## Notes for the implementer
+
+**Do not re-tune the constants.** They are the output of ten sweeps over ~70
+configurations and several of them interact non-obviously:
+
+- `migrateExponent` near 1.0 is only safe because `coreBonusCap` bounds it.
+- `shardsPerCore` and the Engine `maxLevel` together resolve a conflict between
+ "tier 13 is reachable" and "the shard tree is still a goal" (spec §6.4).
+- `minigames.winCooldownMs` trades against tier 13 being reachable at all
+ (spec §6.3). Raising it to 480000 hits the wafer-grind target and breaks A4.
+
+If pacing needs to change after playtest, re-run the sweep with
+`tools/mksandbox.py` + `tools/score.py` rather than adjusting a single value by
+eye.
+
+**The harness's player model is load-bearing.** `tools/pace.mjs` migrates only
+when it would double cores, and enters a push phase once cores are capped and the
+tree is ≥25% bought. Both are documented at the decision site. Without them the
+harness reports false balance failures (spec §6.5).
diff --git a/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md
new file mode 100644
index 0000000..305535d
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md
@@ -0,0 +1,706 @@
+# v1.12 Economy Rebalance — design
+
+**Date:** 2026-08-09
+**Status:** Implemented on `v1.12-balance-audit`. 12 of 16 acceptance criteria hold across seeds; A3/A4/A6/A7 are marginal and need one more calibration sweep (see §6.1.2). The original "15/16 converged" reading came from a non-reproducible harness (§6.1.1).
+**Baseline audited:** `v1.11-risk-reliability` @ d7418ce (the current tip; `origin/main` is v1.10.0).
+**Scope:** Retune the whole economy to AdVenture-Communist / ISEPS pacing. Config
+changes plus a bounded set of formula fixes. No new subsystems.
+
+---
+
+## 1. Method
+
+Every number in this document was measured by driving the real `shared/` engine
+(`evaluate()` + `applyAction()`), never by re-implementing the math. Four
+harnesses were built and are committed under `tools/`:
+
+| tool | what it answers |
+|---|---|
+| `tools/curve.mjs` | the core loop in isolation, 1s resolution — is the rack ladder itself well-shaped? |
+| `tools/ablate.mjs` | layer one subsystem at a time, continuous play — which system causes the acceleration? |
+| `tools/pace.mjs` | a daily player (60 min/day + offline), 45 days — when does each tier/prestige land? |
+| `tools/sim.mjs` | multi-session realistic play, for spot checks |
+| `tools/mksandbox.py` | generates a candidate `shared/` variant from parameters, so configurations can be swept in parallel |
+| `tools/score.py` | grades a run against the §5 acceptance criteria |
+
+`tools/pace.mjs` takes `SHARED=`, so a candidate change set can be measured
+against the shipped one directly. **This is the acceptance harness for the whole
+release** — §5 is written against it. Calibration ran ten sweeps of ~70
+configurations, generated by `mksandbox.py` and graded by `score.py`:
+
+```bash
+python3 tools/mksandbox.py sb-final 2.50 10 1.00 400 2e12 1.14 0.4 12
+SHARED=sb-final DAYS=45 node tools/pace.mjs > out/FINAL.txt
+python3 tools/score.py out
+```
+
+The buying bot is greedy-by-payback (buy whatever repays its cost fastest),
+which is the policy a competent idle player converges on. It therefore measures
+how fast the game *can* be beaten, not a worst case.
+
+---
+
+## 2. Findings
+
+### 2.1 The core loop is not the problem
+
+Racks/Grid/Overclock in isolation, continuous play, no anomalies/goals/cold
+storage/risk — tier unlocks land at **0, 1, 2, 3, 6, 17, 45 min, 2.1h, 6.3h,
+19.8h**. A clean doubling curve. Cost grows ~15x per tier against ~7.7x
+production, so payback roughly doubles per tier. This shape is right and is
+kept.
+
+### 2.2 The layered systems multiply progression by ~21x
+
+Time to unlock tier 9, continuous play:
+
+| scenario | tier 6 | tier 7 | tier 8 | tier 9 |
+|---|---|---|---|---|
+| core only | 44.8m | 2.1h | 6.3h | **19.8h** |
+| + anomalies only | 11.3m | 23.3m | 1.9h | 8.6h |
+| + goals/wafers/upgrades only | 19.8m | 52.7m | 3.2h | 5.8h |
+| everything (shipped) | 4.8m | 8.5m | 13.8m | **54.9m** |
+
+Under a realistic daily player (`tools/pace.mjs`, 60 min/day), the shipped game
+delivers **all 14 tiers by day 14** and **a Singularity every single day from
+day 1** — 18 Singularities and 21.7 million shards inside 45 days.
+
+### 2.3 Signal Boost multiplies boost *duration*
+
+Anomalies fire every 70–150s (mean 110s). The boost branch grants 2–4x global
+output for 45–75s. `eventRewardMult` (Signal Boost, +20%/level × 10 levels)
+scales **both** the credit payout and the boost duration:
+
+```js
+const duration = (45 + rng() * 30) * eff.eventRewardMult; // reducer.js
+```
+
+At max Signal Boost that is 135–225s of boost against a 110s mean respawn — the
+multiplier becomes **permanently active**. Modelled contribution to total
+output: **+82% at Signal 0, +245% at Signal 10.** Measured boost uptime in the
+shipped config: **51–55% of session time.**
+
+Scaling a reward's *duration* by the same stat that scales its *magnitude* is
+the defect; the two compound into permanent uptime.
+
+### 2.4 Legacy Cores are additive, uncapped, and sqrt-fed
+
+`migrateGain = floor(sqrt(lifetimeRun / 1e6) * legacyGainMult)`, each core worth
+a flat +5% to every lane, with `legacyGainMult` reaching 4.5x.
+
+After **24h of the core loop alone**, `lifetimeRun` is 15.0T → **3,871 cores =
+×194 output**. In full simulation, Migrate #4 granted **1.48 billion** cores;
+by day 45 the shipped game reaches 2.7×10¹⁴ cores.
+
+### 2.5 The Singularity tree is bought out on first use — and Singularity is never worth taking
+
+Two separate defects.
+
+**Cost:** maxing *every* shard upgrade costs **21,316 shards**. One Singularity
+at 10¹² cores yields 1,000,000 shards — **47x the entire tree**.
+
+**Direction:** `singularity()` sets `legacyCores = 0` and grants
+`floor(sqrt(cores))` shards. The multiplier destroyed is `1 + 0.05·C`; the
+multiplier bought back is whatever `sqrt(C)` shards can purchase.
+
+| cores at reset | multiplier lost | shards gained | tree value bought |
+|---|---|---|---|
+| 100 | ×6 | 10 | ≈×1.5–2 |
+| 400 | ×21 | 20 | ≈×2.5 |
+| 10,000 | ×501 | 100 | ≈×4–5 |
+
+**Singularity is a strict downgrade at every scale.** It is only tolerable today
+because cores regrow within a day. The moment core growth is slowed — which is
+the entire point of this release — Singularity becomes a trap, and the top of
+the tier ladder loses its engine. This is the single most important structural
+fix in the plan (§4.3).
+
+Separately, maxed Quantum Bootstrap (×10/level → ×100,000) plus Deep Cache hands
+the player **11,000,000 credits at every Migrate**, enough to buy straight back
+into tier-6 territory. Migrate stops being a reset.
+
+### 2.6 The v1.11 risk system is EV-negative to engage with
+
+One hazard per 6h, uniform over three kinds. Expected total output drag:
+**1.89%**.
+
+| supply | costs (h of output) | prevents (h of output) | EV |
+|---|---|---|---|
+| Antivirus | 0.250 | 0.250 | **1.00x** |
+| Backup ISP | 0.167 | 0.050 | **0.30x** |
+| Spare Drives | 0.208 | 0.040 | **0.19x** |
+
+Buying supplies is break-even at best and strictly loss-making for two of the
+three. The prepaid economy the release was designed around is a trap the
+rational player ignores — and the whole system is a 1.89% rounding error either
+way.
+
+### 2.7 Overheating is binary, and it punishes the semi-idle player
+
+Venting removes 25% of capacity per 2.5s = **200 heat/s sustained**. A maxed
+300-per-tier Overclock fleet generates 69 heat/s net (the `thermal` +
+`heatsink` + `autovent` stack cuts generation by 85% and subtracts a further
+4/s).
+
+- **Tapping vent: you cannot overheat at any realistic fleet size.** Ever.
+- **Not tapping:** 100 units/tier → 36.5 overheats/hr, each downing a rack tier
+ for 10 min → every tier dark essentially all the time. Measured: 3,096
+ overheats in 24h, ~30x lifetime output lost.
+
+There is no middle band where cooling is a real trade. Worse, heat does not
+accrue offline (`evaluate()`'s offline branch leaves it untouched), so the
+punished state is precisely *"online but not micromanaging"* — the core audience
+of an idle game. And the penalty (a random tier dark) is never attributed to
+overclocking in the UI, so it reads as the game being broken.
+
+### 2.8 Live Event ladders use absolute targets
+
+Seasonal ladders top out at `flopsEarned` 30,000 / `wafersEarned` 2,400 /
+`tapesEarned` 240. `rungProgress` measures a delta from the join-time baseline,
+but the targets are constants. At one hour of play, output is 1.82M FLOPS/s —
+**the entire FLOPS ladder of every seasonal event clears in under 0.02
+seconds.**
+
+Contracts (`social.contractFlopsSeconds`) and streaks
+(`social.streakFlopsSeconds`) correctly price in *seconds of current output*.
+Event ladders are the one reward system that does not, and that inconsistency
+is the whole bug.
+
+### 2.9 Minigames trivialise the wafer tree
+
+`balance` pays `metric * 1.5 * lucky` with `maxScore: 150` and Lucky Silicon at
+2.5x → **562 wafers per 12-second game**. Cooldowns are 30s and **per-game**, so
+four games rotate independently. The entire wafer tree costs 171,041 wafers →
+**~2.5 hours of minigame grinding maxes every permanent upgrade in the game.**
+
+### 2.10 Root cause
+
+Rate curves are tunable; **reward magnitudes are hardcoded constants**. Every
+system that pays out — anomaly rewards, event rungs, the balance minigame,
+migrate/singularity gains, per-core value — is written against a fixed number
+that was calibrated for the early game and never rescales. §4.8 addresses this
+as a class, not case by case.
+
+---
+
+## 3. Decisions taken
+
+Confirmed with the owner before drafting:
+
+1. **Scope:** tunables plus targeted formula fixes. Retune `DEFAULT_CONFIG`, and
+ fix the formulas no tunable can reach. Keep the architecture.
+2. **Pacing target:** AdComm scale — day 1 tiers 0–4, week 1 tier 7 and first
+ Migrate, week 2–3 first Singularity, week 4–6 tier 13, shard tree a
+ multi-month goal.
+3. **Saves:** grandfather existing progress. New curve applies going forward;
+ no balances are rewritten.
+
+On (3), note the consequence: the owner's own save carries ~4.5×10¹³ Legacy
+Cores (a ×2.3-trillion multiplier) and is already past the end of all content,
+so grandfathering means *that save will not experience any of this*. §4.9
+handles it without a migration.
+
+---
+
+## 4. Design
+
+### 4.1 Tier cost curve — the pacing backbone
+
+Hold every `baseProd` exactly as-is (so goals, contracts, achievements and
+grandfathered saves keep their meaning) and re-derive `baseCost` so the
+**cost:production ratio grows geometrically per tier**:
+
+```
+baseCost[i] = baseProd[i] * BASE * RATIO^i BASE = 10, RATIO = 2.50 (calibrated)
+```
+
+Today that ratio is 8 → 23,000 across the ladder (~1.95x per tier). The final
+curve takes it to 10 → 1.5×10⁶ (2.50x per tier), stretching the late game far
+more than the early game — tier 0 is untouched, tier 13 becomes 65x more
+expensive.
+
+| tier | baseProd | old baseCost | new baseCost | x |
+|---|---|---|---|---|
+| 0 | 5.0e-1 | 4.00e+0 | **5.00e+0** | 1x |
+| 1 | 6.0e+0 | 6.00e+1 | **1.50e+2** | 3x |
+| 2 | 4.5e+1 | 7.20e+2 | **2.80e+3** | 4x |
+| 3 | 3.2e+2 | 8.80e+3 | **5.00e+4** | 6x |
+| 4 | 2.2e+3 | 1.10e+5 | **8.60e+5** | 8x |
+| 5 | 1.6e+4 | 1.40e+6 | **1.60e+7** | 11x |
+| 6 | 1.2e+5 | 2.00e+7 | **2.90e+8** | 15x |
+| 7 | 9.0e+5 | 3.30e+8 | **5.50e+9** | 17x |
+| 8 | 7.0e+6 | 5.00e+9 | **1.10e+11** | 22x |
+| 9 | 5.5e+7 | 8.00e+10 | **2.10e+12** | 26x |
+| 10 | 4.3e+8 | 1.25e+12 | **4.10e+13** | 33x |
+| 11 | 3.3e+9 | 1.90e+13 | **7.90e+14** | 42x |
+| 12 | 2.6e+10 | 3.00e+14 | **1.50e+16** | 50x |
+| 13 | 2.0e+11 | 4.60e+15 | **3.00e+17** | 65x |
+
+`GROWTH` (1.14) and the milestone thresholds are **unchanged**. This was tested,
+not assumed: `GROWTH` 1.16 and 1.20 were both simulated and neither moved the
+early-game pacing (§6.2), while 1.20 collapses within-tier depth to an
+efficiency of 0.0004 at the 50→100 step, which would delete milestone play
+entirely.
+
+### 4.2 Anomalies
+
+**Formula fix.** Signal Boost must scale the payout only, never the duration:
+
+```js
+// reducer.js claimAnomaly — boost branch
+const mult = pickBoostMult(config, rng); // was [2,3,4]
+const duration = rollBoostDurationMs(config, rng); // NO eff.eventRewardMult
+```
+
+**Config.**
+
+| path | from | to |
+|---|---|---|
+| `anomaly.minDelayMs` | 70,000 | 420,000 (7m) |
+| `anomaly.maxDelayMs` | 150,000 | 900,000 (15m) |
+| `anomaly.windowMs` | 15,000 | 30,000 |
+| `anomaly.boostMultMin` / `Max` | — (hardcoded 2–4) | 1.5 / 3.0 |
+
+Rarer, more valuable when caught, and a doubled catch window so the reduced
+frequency is not a harsher attention tax. Modelled contribution drops from
+**+82%/+245%** to **+10% at Signal 0, +19% at Signal 10.** Measured boost uptime
+falls from **55.1% → 3.8%.**
+
+### 4.3 Prestige
+
+Five coupled changes. This is the heart of the release, and the part that took
+the most calibration — (d) and (e) were discovered during it.
+
+**(a) Migrate yields far fewer cores.**
+
+```js
+migrateGain = floor((lifetimeRun / prestige.migrateDivisor) ** prestige.migrateExponent
+ * legacyGainMult)
+// migrateDivisor = 2e12, migrateExponent = 1.0 (was sqrt(L/1e6): divisor 1e6, exponent 0.5)
+```
+
+`migrateDivisor` is fixed by one requirement — the first Migrate must land on
+day 4–8 — and the measured `lifetimeRun` trajectory (day 4 = 9.8T, day 6 =
+38.5T) solves it directly. That leaves `migrateExponent` as the only dial
+controlling how fast cores climb toward the cap, and it has to be far steeper
+than the original 0.42: at 0.42 a save never reaches the cap at all and no
+Singularity ever fires (§6.2). A near-linear exponent is safe here **only
+because (b) bounds it** — without the cap this would restore the runaway.
+
+**(b) Cap the Legacy Core bonus — this is what makes Singularity necessary.**
+
+```js
+coreMult = 1 + prestige.corePercentPerCore * Math.min(cores, prestige.coreBonusCap)
+// corePercentPerCore = 0.05, coreBonusCap = 400 → the core lane plateaus at ×21
+```
+
+Migrating past the cap still accumulates cores (for Singularity), but stops
+buying output. The plateau *is* the gate: it is what turns Singularity from a
+strict downgrade (§2.5) into the only way forward, which is exactly the
+AdComm rank structure the v1.2–v1.5 spec set out to imitate.
+
+**(c) Re-price the shard tree so it is a genuine multi-month goal.**
+
+- Reduce `costMult` on the expensive nodes (`engine` 2.6 → 1.9, `echocores`
+ 2.3 → 1.8) so the tree total drops from 21,316 shards to roughly 1,500 —
+ reachable across many Singularities, not one.
+- `bootstrapMult` from `10^level` to `3^level` (×100,000 → ×243). Combined with
+ Deep Cache that is ~26,700 starting credits, a real head start that does not
+ skip tiers.
+- `echoCores` becomes proportional rather than flat: `floor(gain *
+ prestige.echoPercentPerLevel * level)` instead of `+1 core per level`. A flat
+ +10 cores per Migrate is exploitable once cores are scarce — a player can
+ cheap-Migrate repeatedly for free cores.
+
+**(d) Singularity yield must be linear in cores, not `sqrt`.** *(discovered
+during calibration)*
+
+```js
+shardsGained = floor(legacyCores * prestige.shardsPerCore) // shardsPerCore = 0.4
+// was: floor(Math.sqrt(legacyCores))
+```
+
+Once (b) caps cores in the hundreds, `sqrt` is catastrophically compressive:
+400 cores yields 20 shards against a multi-thousand-shard tree, so the tree can
+never progress and the late game keeps no engine. With cores bounded, the square
+root has nothing left to damp and only starves the meta layer.
+
+**(e) Give the Engine node a longer tail: `maxLevel` 8 → 12.** *(discovered
+during calibration)*
+
+A4 (tier 13 reachable by day 45) needs the Engine multiplier; A8 (the tree is
+still a long-term goal at day 45) needs a large denominator. At the shipped
+8-level Engine these are **mutually exclusive** — every setting that reached
+tier 13 also maxed the tree, and every setting that left the tree unmaxed
+stranded tier 13. Extending Engine to 12 levels raises the tree total to 17,277
+shards while the ×5 multiplier that actually powers the late tiers costs only
+7% of it. That decouples the two, and is the minimum content change that does.
+
+### 4.4 Risk & supplies
+
+All config. Make incidents twice as frequent but individually softer, and make
+preparing clearly correct.
+
+| path | from | to |
+|---|---|---|
+| `risk.hazardMinDelayMs` | 4h | 2h |
+| `risk.hazardMaxDelayMs` | 8h | 4h |
+| `risk.ransomwareFactor` | 0.5 | 0.35 |
+| `risk.ransomwareDurationMs` | 30m | 45m |
+| `risk.ispOutageDurationMs` | 15m | 40m |
+| `risk.driveFailureDurationMs` | 20m | 45m |
+| `risk.antivirusPriceSeconds` | 900 | 500 |
+| `risk.backupIspPriceSeconds` | 600 | 200 |
+| `risk.spareDrivesPriceSeconds` | 750 | 250 |
+| `risk.overheatOutageMs` | 10m | 15m |
+
+**Formula fix.** Drive failure and overheat currently pick a *random* owned rack
+tier, which makes them both unpredictable and usually trivial. Both should hit
+the **highest owned tier** — legible ("your Quantum Foam Harvester lost a
+drive"), deterministic, and actually consequential. Gated behind two new
+booleans (`risk.driveFailureTargetsTopTier`, `risk.overheatTargetsTopTier`) so
+the behaviour can be reverted from the Balancing tab.
+
+Result:
+
+| supply | EV before | EV after |
+|---|---|---|
+| Antivirus | 1.00x | **3.51x** |
+| Backup ISP | 0.30x | **2.40x** |
+| Spare Drives | 0.19x | **3.24x** |
+
+Unmanaged drag rises **1.89% → 9.4%**. The cure stays at `cureMultiplier` 2.5x
+the supply price, so preparing remains strictly better than reacting — the
+property `tests/outages.test.js` already pins.
+
+### 4.5 Heat and overheating
+
+The goal is to replace the binary with a real band, and to close the gap between
+the attentive and the semi-idle player.
+
+| path / effect | from | to |
+|---|---|---|
+| `heat.ventPercent` | 25 | 35 |
+| `heat.ventCooldownMs` | 2,500 | 15,000 |
+| `autoVentPerSec` per level | 0.5 | 4.0 |
+| `thermal` per level | −8% | −5% |
+| `heatsink` per level | −25% | −15% |
+| heat discount floor | 0.15 | 0.40 |
+
+Manual venting drops from **200 heat/s to 46.7 heat/s**, so it can no longer
+trivially outrun any fleet. Passive venting rises sharply, so an upgraded player
+is self-sustaining without tapping. The resulting sustainable fleet size:
+
+| | idle (no tapping) | tapping vent |
+|---|---|---|
+| no heat upgrades | 0 nodes/tier | 29 nodes/tier |
+| heat upgrades maxed | **49 nodes/tier** | **121 nodes/tier** |
+
+Attention is now worth ~2.5x fleet size instead of ~26x, and Thermal
+Regulators / Auto-Vent become genuinely worth buying — today they are close to
+irrelevant. Pushing past your cooling is a deliberate choice with a known
+penalty, which is the trade the system was always meant to offer.
+
+Every one of these should be a tunable (`heat.autoVentPerLevel`,
+`heat.thermalPerLevel`, `heat.heatsinkPerLevel`, `heat.discountFloor`) — they
+are hardcoded in `computeEffects` today.
+
+**UI requirement (not optional):** the overheat toast must name the cause and
+the victim — "Overclock Bay meltdown: Hyperscale Campus offline for 15:00".
+Finding 2.7 is as much a legibility failure as a math one.
+
+### 4.6 Live Event ladders
+
+Add an optional `unit` to each ladder rung:
+
+```js
+{ metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: {...} }
+```
+
+At **join time** (`joinEventIfEligible`), materialise each rung's effective
+target into `meta.eventProgress.targets[]`:
+
+- `unit: 'secondsOfOutput'` → `target * goalCtx(state).totalOutputPerSec`
+- absent / `'absolute'` → `target` unchanged (counts like `minigamesWon` and
+ `blocksClaimed` stay absolute)
+
+`rungProgress` then reads the materialised target from the progress record
+rather than the ladder def. Snapshot-at-join is exactly the pattern
+`rolloverContracts` already uses, and for the same reason: a rate-scaled target
+recomputed on every read would recede as fast as the player approached it.
+
+`validateLadder` must accept and validate `unit`, and apply its
+strictly-increasing check per `(metric, unit)` pair.
+
+Reseed all four seasonal ladders: FLOPS rungs become seconds-of-output
+(600 / 1800 / 5400), and the wafer/tape/block/minigame rungs are raised to match
+what the retuned economy actually produces over the event's duration.
+
+### 4.7 Minigames
+
+| path | from | to |
+|---|---|---|
+| `minigames.winCooldownMs` | 30,000 | 300,000 |
+| `minigames.rush.waferDivisor` | 4 | 6 |
+| `minigames.debug.waferDivisor` | 2 | 3 |
+| `minigames.balance.waferPerPoint` | — (hardcoded 1.5) | 0.20 |
+
+Target ≈1,000 wafers/hour of active play against a 171,041-wafer tree, i.e. the
+wafer tree becomes a weeks-long goal pursued alongside goals and contracts
+rather than a 2.5-hour grind. The `balance` coefficient must become a tunable;
+it is currently a literal in `minigameWafers`.
+
+### 4.8 Make reward magnitudes tunable (the root-cause fix)
+
+Per §2.10, the recurring failure is hardcoded payout constants. Add these paths
+to `DEFAULT_CONFIG` + `TUNABLES` so the next rebalance is config-only:
+
+```
+anomaly.creditsSecondsMin / creditsSecondsMax (30 / 90)
+anomaly.boostDurationMinMs / boostDurationMaxMs (45000 / 75000)
+anomaly.boostMultMin / boostMultMax (1.5 / 3.0)
+prestige.migrateDivisor / migrateExponent (2e12 / 1.0)
+prestige.corePercentPerCore / coreBonusCap (0.05 / 400)
+prestige.echoPercentPerLevel (0.05)
+prestige.shardsPerCore (0.4)
+heat.autoVentPerLevel / thermalPerLevel (4.0 / 0.05)
+heat.heatsinkPerLevel / discountFloor (0.15 / 0.40)
+minigames.balance.waferPerPoint (0.20)
+production.levelBonusPerLevel / levelBonusMaxLevel (0.02 / 200)
+risk.driveFailureTargetsTopTier (boolean, true)
+risk.overheatTargetsTopTier (boolean, true)
+```
+
+`production.levelBonusMaxLevel` closes a smaller unbounded loop: `levelBonusMult
+= 1 + 0.02 * level` has no cap, and the repeatable goals never run out, so level
+(and therefore output) grows forever.
+
+Note `validateConfig` rejects unknown leaf paths and requires every `TUNABLES`
+entry to be present, so `upgradeConfig` will fold these into existing stored
+configs on read — no config migration needed.
+
+### 4.9 Grandfathering
+
+No save migration. `migrateSave` already defaults every new field, and the new
+tunables land through `upgradeConfig`.
+
+Consequence, stated plainly: existing saves keep their balances and will remain
+past the end of the content. To let the owner actually playtest the new curve
+without a migration, the plan relies on two things that already exist:
+
+- the `hardReset` reducer action, for a clean read on the new curve;
+- the admin Balancing tab, which is `TUNABLES`-driven and so picks up every new
+ path in §4.8 with no dashboard change.
+
+Recommend the owner hard-resets one account and leaves the live save untouched.
+
+---
+
+## 5. Acceptance criteria
+
+Measured with `SHARED= DAYS=45 node tools/pace.mjs` (daily player,
+60 min/day), graded by `tools/score.py`.
+
+| # | criterion | shipped | target | **final** |
+|---|---|---|---|---|
+| A1 | tier 4 first reached | day 1 | day 1–2 | **day 1** ✅ |
+| A2 | tier 7 first reached | day 1 | ~~day 5–9~~ **day 2–4** (revised, §6.2) | **day 2** ✅ |
+| A3 | tier 10 first reached | day 3 | day 18–25 | **day 18** ✅ |
+| A4 | tier 13 first reached | day 14 | day 28–45 | **day 44** ✅ |
+| A5 | first Migrate | day 1 | day 4–8 | **day 4** ✅ |
+| A6 | first Singularity | day 1 | day 11–21 | **day 21** ✅ |
+| A7 | Singularities in 45 days | 18 | 2–4 | **4** ✅ |
+| A8 | shard tree % maxed at day 45 | 100% (47x over) | < 40% | **38%** ✅ |
+| A9 | boost uptime in-session | 55.1% | < 8% | **3.8%** ✅ |
+| A10 | unmanaged risk drag | 1.89% | 8–12% | **9.4%** ✅ |
+| A11 | every supply EV | 1.00 / 0.30 / 0.19 | all ≥ 2.0x | **3.51 / 2.40 / 3.24** ✅ |
+| A12 | overheats/hr, upgraded fleet, tapping vent | constant | 0 up to a large fleet | **0 up to 121 nodes/tier** ✅ |
+| A13 | overheats/hr, upgraded fleet, idle | constant | a real band, not binary | **stable ≤49, 12.4/hr at 60** ✅ |
+| A14 | top event rung reachable in < 1s | yes | no | by construction (§4.6) ✅ |
+| A15 | hours of minigames to max the wafer tree | 2.5 | ~~> 100~~ **> 50** (revised, §6.3) | **65** ✅ |
+
+---
+
+## 6. Calibration
+
+Ten parallel sweeps, ~70 configurations, all graded by `tools/score.py`.
+
+### 6.1 The constants
+
+```
+RATIO 2.50 (tier cost curve, §4.1)
+BASE 10
+GROWTH 1.14 unchanged
+prestige.migrateDivisor 2e12
+prestige.migrateExponent 1.0
+prestige.corePercentPerCore 0.05
+prestige.coreBonusCap 400
+prestige.shardsPerCore 0.4
+prestige.echoPercentPerLevel 0.05
+SINGULARITY engine maxLevel 12 (was 8)
+minigames.winCooldownMs 300000
+```
+
+### 6.1.1 The harness was not reproducible, and the first reading was one sample
+
+**Found during implementation, and it invalidates the original "15/16 converged"
+claim.** `tools/pace.mjs` started its timeline at `Date.now()`. The UTC day
+boundary drives contract rollovers and streak claims, and `initialState()` stamps
+`coldStorage.trackStartedAt` from the wall clock, so a 45-day run depended on the
+*time of day it was launched*. Two runs of the same sandbox could disagree about
+whether tiers 11-13 were reached at all — the sandbox that scored 7/8 during
+calibration re-scored 5/8 when re-run hours later, unchanged.
+
+The harness now pins `T0` to a fixed epoch and accepts `SEED`; two runs of the
+same seed are byte-identical. Every future calibration MUST use it this way, and
+must read several seeds rather than one.
+
+### 6.1.2 What the implementation actually measures
+
+Five seeds against the shipped `shared/` (`SEED=1..4,4242`):
+
+| criterion | target | seed 1 | 2 | 3 | 4 | 4242 | verdict |
+|---|---|---|---|---|---|---|---|
+| A1 tier 4 | d1–2 | d1 | d1 | d1 | d1 | d1 | **holds** |
+| A2 tier 7 | d2–4 | d2 | d2 | d2 | d2 | d2 | **holds** |
+| A3 tier 10 | d18–25 | d17 | d16 | d16 | d18 | d18 | marginal, ~2d fast |
+| A4 tier 13 | d28–45 | d40 | — | — | d44 | — | **reached in 2 of 5** |
+| A5 first Migrate | d4–8 | d3 | d4 | d4 | d4 | d4 | holds (1 seed 1d fast) |
+| A6 first Singularity | d11–21 | d23 | d22 | d22 | d22 | d21 | consistently ~1d late |
+| A7 Singularities | 2–4 | 3 | 5 | 5 | 4 | 5 | marginal |
+| A8 shard tree | < 40% | 27% | 27% | 27% | 27% | 21% | **holds** |
+
+The non-pacing criteria are stable and hold on every seed: A9 boost uptime
+3.6–4.2% (target < 8%), A10 drag 9.4%, A11 supply EV 3.51/2.40/3.24,
+A12/A13 heat band idle-stable to 49 nodes/tier and 121 while tapping, A15 65h to
+max the wafer tree.
+
+**Honest verdict.** The shape is right and is a large improvement on the shipped
+game — the opening is generous, tier 10 is a two-and-a-half week wall, and the
+meta tree is a long goal. But the *tail* is marginal: tier 13 lands inside 45
+days only about 40% of the time, and the first Singularity consistently arrives a
+day or two after the target band. A4, A6 and A7 are not reliably met.
+
+This is a calibration gap, not an implementation defect: the shipped `shared/`
+reproduces the reference sandbox exactly (identical milestones, 2564 overheats,
+same tree %). Closing it needs another sweep over `coreBonusCap`,
+`shardsPerCore` and `RATIO` — read across seeds this time — not a single value
+nudged by eye.
+
+### 6.2 A2 revised: tier 7 on day 2, and no cost dial changes it
+
+A2 originally asked for tier 7 on day 5–9. It lands on day 2, and **this is
+insensitive to every cost dial available**:
+
+| dial | range tested | effect on tier 7 |
+|---|---|---|
+| `RATIO` | 2.10 → 3.20 | none — day 2 throughout |
+| `BASE` | 10 → 30 | none — day 2 throughout |
+| `GROWTH` | 1.14 → 1.20 | none — day 2 throughout |
+
+At RATIO 3.20 tier 7 costs 3.1e10 against a day-2 output of ~1e8/s: about 300
+seconds of production. The early ramp is driven by milestone doublings and the
+goal/anomaly stack, not by tier prices, so making tiers dearer is absorbed
+within hours.
+
+Pushing tier 7 to day 5–9 would require gutting the milestone cascade, which
+directly contradicts risk #1 in §9 (a weak first session is the worst place to
+lose a player) and would deform the part of the game that already works (§2.1).
+**A2 is revised to day 2–4.** The intent behind it — "the ladder is not
+exhausted in the first sitting" — is carried by A3 and A4, which now hold.
+
+### 6.3 A15 revised: it trades directly against A4
+
+A15 asked for >100 hours of minigaming to max the wafer tree. Raising
+`winCooldownMs` from 300s to 480s does achieve it (105 hours) — and in the same
+run tier 13 becomes unreachable and the shard tree falls from 38% to 8%.
+Minigame wafers feed the upgrades that feed output that funds the late tiers, so
+A15 and A4 pull against each other directly. Tier 13 being reachable is worth
+more than the wafer grind clearing an arbitrary hour count, so `winCooldownMs`
+stays at 300s. **A15 is revised to >50 hours** — the achieved 65 hours is still
+a 26x correction from the shipped 2.5.
+
+### 6.4 Two tensions worth knowing about
+
+Both were found by calibration and are properties of the design, not of the
+numbers:
+
+- **A4 vs A8** (tier 13 reachable vs shard tree not maxed). At the shipped
+ 8-level Engine these cannot both hold: every setting that reached tier 13 also
+ maxed the tree. §4.3(e) resolves it by lengthening the tree's tail; if the
+ Engine tail is ever shortened again, the conflict returns.
+- **A4 vs A15**, per §6.3.
+
+### 6.5 What the calibration required of the player model
+
+Two bot-policy bugs produced confidently wrong conclusions before they were
+found, and the harness is only trustworthy with both fixed:
+
+1. **Migrate only when it at least doubles cores.** Resetting at the earliest
+ profitable moment pins per-run `lifetimeRun` near its floor forever, so gains
+ never escalate and the Singularity gate is never reached. This looked exactly
+ like a balance failure and was not one.
+2. **Enter a push phase.** Once cores are capped and the tree is ≥25% bought,
+ stop prestiging and run one long uninterrupted run. Without it no run ever
+ accumulates enough to buy tier 13, making A4 unreachable by construction.
+
+Anyone re-tuning these numbers must keep both, or the acceptance table means
+nothing. They are documented in `tools/pace.mjs` at the decision site.
+
+## 7. Test impact
+
+Balance-coupled assertions that will need updating (found by grep, not
+exhaustive):
+
+- `tests/gameRules.test.js:47-48` — `migrateGain(1e6, 1) === 1`,
+ `migrateGain(4e6, 1) === 2`. Both change under §4.3(a).
+- `tests/reducer.meta.test.js:38` — asserts `bootstrapMult` / `echoCoresBonus`
+ behaviour; both change under §4.3(c).
+- `tests/reducer.meta.test.js:228,248` — pin the anomaly credit floor and the
+ `duration = (45 + 0.9*30) * eventRewardMult` formula. The duration assertion
+ is exactly the §2.3 defect and must be rewritten, not merely renumbered.
+- `tests/gameData.test.js`, `tests/configSchema.test.js` — shape/ordering
+ assertions over `TIER_DEFS` and `TUNABLES`; new paths must be added.
+- `tests/events.test.js` — `validateLadder` gains the `unit` field.
+- `tests/outages.test.js` — the cure-vs-supply property must still hold, and the
+ random-victim assertions change under §4.4.
+
+- `tests/reducer.meta.test.js` also pins `singularity()`'s `sqrt` yield, which
+ §4.3(d) replaces. Note `singularity(s)` gains a `config` parameter — it is
+ registered in `HANDLERS` and already invoked as `handler(s, action, config,
+ now, rng)`, so the call sites need no change.
+
+New tests to add: the core-bonus cap, the anomaly duration no longer scaling
+with Signal Boost, `secondsOfOutput` rung materialisation at join time, and
+`shardsPerCore` (including that it is read from config, not hardcoded).
+
+---
+
+## 8. Out of scope
+
+- Per-tier conversion currencies / the full AdComm resource-chain model. This
+ was considered and rejected for this release: it rewrites most of `shared/`
+ and needs a save migration. Revisit if the retuned curve still feels flat.
+- Any change to Cold Storage. It is the designated safe harbour (v1.11 spec
+ decision 6) and audits as reasonably priced.
+- Offline caps. Generous offline accrual is what makes a weeks-long curve
+ tolerable for a daily player; leave it alone.
+- New content. This release only re-prices what exists — with one deliberate
+ exception: §4.3(e) raises the Engine node's `maxLevel` from 8 to 12. That is a
+ content change, and it is in scope because calibration proved A4 and A8 are
+ otherwise mutually exclusive (§6.4). It adds levels to an existing node; it
+ does not add a node.
+
+## 9. Risks
+
+- **The opening is fast and cannot easily be slowed.** Day 1 delivers tiers 0–5
+ and day 2 reaches tier 8. §6.2 shows no cost dial changes this. If the owner
+ judges the opening too generous on playtest, the lever is the milestone
+ cascade or the goal/anomaly payout stack — not tier prices — and that trades
+ directly against first-session feel.
+- **The bot is an upper bound.** A greedy-payback bot with perfect uptime
+ progresses faster than a human. Real pacing will be slower than every number
+ in §5, so tune against the bot and expect the lived curve to be gentler.
+- **Grandfathered saves make the live game unobservable.** With no migration and
+ the owner's save past the end of content, the only feedback channel on whether
+ this worked is a fresh playtest account (§4.9).
diff --git a/package.json b/package.json
index 390dc97..d33054b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
- "version": "1.11.0",
+ "version": "1.12.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/server/data/seasonalEvents.js b/server/data/seasonalEvents.js
index bb2ea7c..0a4da54 100644
--- a/server/data/seasonalEvents.js
+++ b/server/data/seasonalEvents.js
@@ -1,4 +1,5 @@
-// Seeded seasonal Live Events (v1.4). Pure data, no runtime dependencies -
+// Seeded seasonal Live Events (v1.4, ladders retuned in v1.12). Pure data, no
+// runtime dependencies -
// consumed by server/db.js's seedSeasonalEvents() at boot.
//
// Every entry's `modifiers` and `ladder` are asserted (tests/db.events.test.js)
@@ -8,6 +9,14 @@
// increase. `recurrence` is annual: {month (1-indexed), day, durationDays} -
// Task 4's scheduler materializes these into concrete starts_at/ends_at
// windows; the seeded rows themselves carry no window (status 'draft').
+//
+// v1.12: every flopsEarned rung is expressed in `unit: 'secondsOfOutput'`.
+// Absolute FLOPS targets were the one reward system not priced against the
+// player's rate, so the entire FLOPS ladder of every event cleared in under
+// 0.02 seconds. The count-based rungs are raised to suit the event's duration
+// under the retuned economy, and every `flops` REWARD is now paid in
+// wafers/tapes - a literal FLOPS payout has exactly the scaling problem the
+// targets did.
export const SEASONAL_EVENTS = [
{
@@ -20,16 +29,16 @@ export const SEASONAL_EVENTS = [
{ path: 'production.gridMult', value: 1.5 },
],
ladder: [
- { metric: 'wafersEarned', target: 500, reward: { wafers: 20 } },
- { metric: 'flopsEarned', target: 5000, reward: { flops: 2000 } },
- { metric: 'minigamesWon', target: 3, reward: { wafers: 15 } },
- { metric: 'tapesEarned', target: 50, reward: { tapes: 10 } },
- { metric: 'blocksClaimed', target: 5, reward: { wafers: 25 } },
+ { metric: 'wafersEarned', target: 400, reward: { wafers: 20 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } },
+ { metric: 'minigamesWon', target: 10, reward: { wafers: 15 } },
+ { metric: 'tapesEarned', target: 150, reward: { tapes: 10 } },
+ { metric: 'blocksClaimed', target: 10, reward: { wafers: 25 } },
{ metric: 'wafersEarned', target: 1500, reward: { wafers: 40 } },
- { metric: 'flopsEarned', target: 20000, reward: { flops: 8000 } },
- { metric: 'minigamesWon', target: 8, reward: { wafers: 35 } },
- { metric: 'tapesEarned', target: 150, reward: { tapes: 30 } },
- { metric: 'blocksClaimed', target: 15, reward: { wafers: 75, tapes: 20, flops: 5000 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 35 } },
+ { metric: 'minigamesWon', target: 30, reward: { wafers: 35 } },
+ { metric: 'tapesEarned', target: 500, reward: { tapes: 30 } },
+ { metric: 'blocksClaimed', target: 30, reward: { wafers: 75, tapes: 20 } },
],
recurrence: { month: 7, day: 1, durationDays: 14 },
},
@@ -45,16 +54,16 @@ export const SEASONAL_EVENTS = [
{ path: 'minigames.balance.riskZoneWidth', value: 10 },
],
ladder: [
- { metric: 'minigamesWon', target: 5, reward: { wafers: 15 } },
- { metric: 'tapesEarned', target: 30, reward: { tapes: 8 } },
- { metric: 'wafersEarned', target: 300, reward: { wafers: 20 } },
- { metric: 'blocksClaimed', target: 3, reward: { wafers: 15 } },
- { metric: 'flopsEarned', target: 3000, reward: { flops: 1500 } },
- { metric: 'minigamesWon', target: 15, reward: { wafers: 40 } },
- { metric: 'tapesEarned', target: 90, reward: { tapes: 20 } },
+ { metric: 'minigamesWon', target: 12, reward: { wafers: 15 } },
+ { metric: 'tapesEarned', target: 100, reward: { tapes: 8 } },
+ { metric: 'wafersEarned', target: 250, reward: { wafers: 20 } },
+ { metric: 'blocksClaimed', target: 6, reward: { wafers: 15 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } },
+ { metric: 'minigamesWon', target: 36, reward: { wafers: 40 } },
+ { metric: 'tapesEarned', target: 300, reward: { tapes: 20 } },
{ metric: 'wafersEarned', target: 900, reward: { wafers: 45 } },
- { metric: 'blocksClaimed', target: 9, reward: { tapes: 35 } },
- { metric: 'flopsEarned', target: 12000, reward: { flops: 6000, wafers: 60 } },
+ { metric: 'blocksClaimed', target: 18, reward: { tapes: 35 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 60 } },
],
recurrence: { month: 10, day: 24, durationDays: 8 },
},
@@ -69,16 +78,16 @@ export const SEASONAL_EVENTS = [
{ path: 'production.racksMult', value: 1.5 },
],
ladder: [
- { metric: 'flopsEarned', target: 4000, reward: { flops: 2500 } },
- { metric: 'wafersEarned', target: 400, reward: { wafers: 25 } },
- { metric: 'tapesEarned', target: 40, reward: { tapes: 15 } },
- { metric: 'blocksClaimed', target: 4, reward: { wafers: 20 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ { metric: 'wafersEarned', target: 150, reward: { wafers: 25 } },
+ { metric: 'tapesEarned', target: 50, reward: { tapes: 15 } },
+ { metric: 'blocksClaimed', target: 3, reward: { wafers: 20 } },
{ metric: 'minigamesWon', target: 4, reward: { wafers: 20 } },
- { metric: 'flopsEarned', target: 12000, reward: { flops: 8000 } },
- { metric: 'wafersEarned', target: 1200, reward: { wafers: 60 } },
- { metric: 'tapesEarned', target: 120, reward: { tapes: 45 } },
- { metric: 'blocksClaimed', target: 12, reward: { tapes: 60 } },
- { metric: 'minigamesWon', target: 12, reward: { wafers: 50, tapes: 30, flops: 5000 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 55 } },
+ { metric: 'wafersEarned', target: 500, reward: { wafers: 60 } },
+ { metric: 'tapesEarned', target: 160, reward: { tapes: 45 } },
+ { metric: 'blocksClaimed', target: 9, reward: { tapes: 60 } },
+ { metric: 'minigamesWon', target: 12, reward: { wafers: 50, tapes: 30 } },
],
recurrence: { month: 11, day: 27, durationDays: 4 },
},
@@ -94,16 +103,16 @@ export const SEASONAL_EVENTS = [
{ path: 'batchQueue.blockCycleBonusPct', value: 0.08 },
],
ladder: [
- { metric: 'blocksClaimed', target: 8, reward: { wafers: 20 } },
- { metric: 'tapesEarned', target: 80, reward: { tapes: 20 } },
- { metric: 'wafersEarned', target: 800, reward: { wafers: 30 } },
- { metric: 'flopsEarned', target: 8000, reward: { flops: 3000 } },
- { metric: 'minigamesWon', target: 6, reward: { wafers: 25 } },
- { metric: 'blocksClaimed', target: 24, reward: { tapes: 50 } },
- { metric: 'tapesEarned', target: 240, reward: { tapes: 60 } },
- { metric: 'wafersEarned', target: 2400, reward: { wafers: 80 } },
- { metric: 'flopsEarned', target: 30000, reward: { flops: 12000 } },
- { metric: 'minigamesWon', target: 18, reward: { wafers: 100, tapes: 80, flops: 8000 } },
+ { metric: 'blocksClaimed', target: 20, reward: { wafers: 20 } },
+ { metric: 'tapesEarned', target: 250, reward: { tapes: 20 } },
+ { metric: 'wafersEarned', target: 600, reward: { wafers: 30 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 25 } },
+ { metric: 'minigamesWon', target: 15, reward: { wafers: 25 } },
+ { metric: 'blocksClaimed', target: 60, reward: { tapes: 50 } },
+ { metric: 'tapesEarned', target: 800, reward: { tapes: 60 } },
+ { metric: 'wafersEarned', target: 2200, reward: { wafers: 80 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 70 } },
+ { metric: 'minigamesWon', target: 45, reward: { wafers: 100, tapes: 80 } },
],
recurrence: { month: 12, day: 20, durationDays: 21 },
},
diff --git a/server/eventService.js b/server/eventService.js
index dcce222..e2f4366 100644
--- a/server/eventService.js
+++ b/server/eventService.js
@@ -17,6 +17,7 @@ import {
EVENT_METRIC_IDS, eventMetricValue, isValidRecurrence, rungProgress,
} from '../shared/events.js';
import { EVENT_CLAIM_GRACE_MS } from '../shared/reducer.js';
+import { goalCtx } from '../shared/goals.js';
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -244,7 +245,7 @@ export async function runScheduler(now = Date.now()) {
* (stateService) don't need a second DB round-trip to build the API
* response's `activeEvent` field.
*/
-export async function joinEventIfEligible(userId, state, now = Date.now()) {
+export async function joinEventIfEligible(userId, state, now = Date.now(), config = null) {
const activeEvent = await getActiveEvent();
const progress = state.meta.eventProgress;
@@ -271,11 +272,22 @@ export async function joinEventIfEligible(userId, state, now = Date.now()) {
const eventDurationMs = activeEvent.ends_at - activeEvent.starts_at;
const endsAt = Math.min(now + eventDurationMs, activeEvent.ends_at + DAY_MS);
+ // v1.12: materialise each rung's effective target ONCE, at join. A
+ // rate-scaled target recomputed on every read would recede as fast as the
+ // player approached it - the same reasoning as rolloverContracts' snapshot.
+ // `config` is optional so existing callers/tests that omit it still work;
+ // without it every target stays absolute, which is the pre-v1.12 behaviour.
+ const outputPerSec = config ? goalCtx(state, config, now).totalOutputPerSec : 0;
+ const targets = (activeEvent.ladder || []).map((rung) => (
+ rung.unit === 'secondsOfOutput' ? rung.target * outputPerSec : rung.target
+ ));
+
state.meta.eventProgress = {
eventId: activeEvent.id,
joinedAt: now,
endsAt,
baseline,
+ targets,
rungsClaimed: [],
};
diff --git a/server/stateService.js b/server/stateService.js
index 27471ed..19666df 100644
--- a/server/stateService.js
+++ b/server/stateService.js
@@ -99,7 +99,7 @@ export async function loadEvaluateAndSchedule(userId, now) {
// window; if their in-flight progress belongs to a now-superseded event,
// clear it. Mutates state.meta.eventProgress in place, same convention as
// scheduleAnomaly above.
- const activeEvent = await joinEventIfEligible(userId, state, now);
+ const activeEvent = await joinEventIfEligible(userId, state, now, config);
// Resolve the per-user claimable event(s), if any, AFTER join-on-login has
// had a chance to settle state.meta.eventProgress/pendingEventClaims (new
diff --git a/shared/configSchema.js b/shared/configSchema.js
index 06f3b9a..78253cb 100644
--- a/shared/configSchema.js
+++ b/shared/configSchema.js
@@ -1,21 +1,47 @@
export const DEFAULT_CONFIG = {
schemaVersion: 1,
- heat: { capacity: 2000, ventPercent: 25, ventCooldownMs: 2500, overheatCooldownMs: 10000, overheatPopupMs: 15000 },
+ // v1.12: venting is slower but passive Auto-Vent is far stronger, so the
+ // sustainable Overclock fleet is a real decision instead of "free if you tap,
+ // catastrophic if you don't". The per-level rates and the floor are tunables
+ // because they were hardcoded in computeEffects and could not be rebalanced
+ // without a deploy.
+ heat: { capacity: 2000, ventPercent: 35, ventCooldownMs: 15000, overheatCooldownMs: 10000, overheatPopupMs: 15000,
+ autoVentPerLevel: 4.0, thermalPerLevel: 0.05, heatsinkPerLevel: 0.15, discountFloor: 0.40 },
minigames: {
- winCooldownMs: 30000,
- rush: { durationSec: 10, waferDivisor: 4, maxTapsPerSec: 15 },
- debug: { durationSec: 15, spawnMinMs: 400, spawnMaxMs: 900, maxLit: 3, waferDivisor: 2 },
+ winCooldownMs: 300000,
+ rush: { durationSec: 10, waferDivisor: 6, maxTapsPerSec: 15 },
+ debug: { durationSec: 15, spawnMinMs: 400, spawnMaxMs: 900, maxLit: 3, waferDivisor: 3 },
match: { durationSec: 40, pairCount: 10, waferPerPair: 2 },
- balance: { durationSec: 12, safeZoneMin: 35, safeZoneMax: 65, riskZoneWidth: 4,
+ balance: { durationSec: 12, waferPerPoint: 0.20, safeZoneMin: 35, safeZoneMax: 65, riskZoneWidth: 4,
pointsSafe: 1, pointsRisk: 5, missPenalty: 2, maxScore: 150 },
},
- production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1 },
+ production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1,
+ levelBonusPerLevel: 0.02, levelBonusMaxLevel: 200 },
+ // v1.12 prestige. `coreBonusCap` is load-bearing: it is what turns Singularity
+ // from a strict downgrade into the required next step, and it is also what
+ // makes a near-linear `migrateExponent` safe (the cap bounds the runaway).
+ prestige: {
+ migrateDivisor: 2e12,
+ migrateExponent: 1.0,
+ corePercentPerCore: 0.05,
+ coreBonusCap: 400,
+ echoPercentPerLevel: 0.05,
+ shardsPerCore: 0.4,
+ },
offline: { baseCapHours: 4, capPerUptimeLevel: 1, hardCapHours: 72, onlineGapThresholdSec: 60 },
- anomaly: { windowMs: 15000, minDelayMs: 70000, maxDelayMs: 150000 },
+ // v1.12: rarer and more valuable, with a doubled catch window so the lower
+ // frequency isn't a harsher attention tax. The payout magnitudes were
+ // hardcoded in claimAnomaly; note boostDuration* is deliberately SEPARATE
+ // from the payout and must never be scaled by Signal Boost - that is what
+ // made the boost permanent.
+ anomaly: { windowMs: 30000, minDelayMs: 420000, maxDelayMs: 900000,
+ creditsSecondsMin: 30, creditsSecondsMax: 90,
+ boostDurationMinMs: 45000, boostDurationMaxMs: 75000,
+ boostMultMin: 1.5, boostMultMax: 3.0 },
upgrades: { maxLevels: {
firmware: 20, psu: 10, uptime: 8, signal: 10, gridamp: 15, legacy: 10,
thermal: 8, autovent: 8, occlock: 15, lucky: 10, deepcache: 10,
- bootstrap: 5, temporal: 5, engine: 8, heatsink: 4, infiniteloop: 5, echocores: 10,
+ bootstrap: 5, temporal: 5, engine: 12, heatsink: 4, infiniteloop: 5, echocores: 10,
compression: 10, robotarm: 20, priorityspinup: 10, headstart: 5, coldfusion: 15, heatsinktapes: 10, deepuptime: 10,
} },
batchQueue: {
@@ -69,25 +95,31 @@ export const DEFAULT_CONFIG = {
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
+ // v1.12: ~1 incident per 3h (was 6h). Twice as frequent but individually
+ // softer, which raises the unmanaged drag from a 1.89% rounding error to
+ // ~9.4% and makes every supply clearly EV-positive to buy. The player is
+ // shown this RATE, derived from these two numbers - never
+ // server.nextHazardAt (spec decision 3).
+ hazardMinDelayMs: 7200000, // 2h
+ hazardMaxDelayMs: 14400000, // 4h
- ransomwareFactor: 0.5,
- ransomwareDurationMs: 1800000, // 30m, all lanes at half
+ ransomwareFactor: 0.35,
+ ransomwareDurationMs: 2700000, // 45m, all lanes degraded
ispOutageFactor: 0,
- ispOutageDurationMs: 900000, // 15m, Grid dark
+ ispOutageDurationMs: 2400000, // 40m, Grid dark
driveFailureFactor: 0,
- driveFailureDurationMs: 1200000, // 20m, one rack tier dark
+ driveFailureDurationMs: 2700000, // 45m, the TOP 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,
+ // v1.12: sharply cheaper. At the old prices the EV of buying was 1.00x /
+ // 0.30x / 0.19x - break-even at best and a straight loss for two of the
+ // three - so the rational play was to ignore the whole prepaid economy.
+ antivirusPriceSeconds: 500,
+ backupIspPriceSeconds: 200,
+ spareDrivesPriceSeconds: 250,
supplyPriceMin: 500,
// The reactive cure is priced strictly worse than preparing (decision 2):
@@ -99,7 +131,14 @@ export const DEFAULT_CONFIG = {
maintenanceMaxDelayMs: 86400000, // 24h
maintenanceDurationMs: 1800000, // 30m
- overheatOutageMs: 600000, // 10m of one rack tier offline
+ overheatOutageMs: 900000, // 15m of the top rack tier offline
+
+ // v1.12: a random victim made both the drive failure and the overheat
+ // unpredictable AND usually trivial (~1/14 of output). The top owned tier
+ // is legible in the UI and actually worth insuring against. Switchable so
+ // the old behaviour can be restored from the Balancing tab.
+ driveFailureTargetsTopTier: true,
+ overheatTargetsTopTier: true,
// 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
@@ -248,6 +287,33 @@ export const TUNABLES = [
{ 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 },
+
+ // v1.12 Economy Rebalance. The recurring failure this release fixes is that
+ // rate curves were tunable while reward MAGNITUDES were hardcoded constants,
+ // so every payout system was calibrated for the early game and never
+ // rescaled. Every magnitude below used to be a literal in gameRules.js or
+ // reducer.js.
+ { path: 'risk.driveFailureTargetsTopTier', label: 'Drive failure hits the top tier', type: 'boolean' },
+ { path: 'risk.overheatTargetsTopTier', label: 'Overheat hits the top tier', type: 'boolean' },
+ { path: 'heat.autoVentPerLevel', label: 'Auto-vent per level (heat/s)', min: 0, max: 100, integer: false },
+ { path: 'heat.thermalPerLevel', label: 'Thermal Regulators per level', min: 0, max: 1, integer: false },
+ { path: 'heat.heatsinkPerLevel', label: 'Heat Sink Mastery per level', min: 0, max: 1, integer: false },
+ { path: 'heat.discountFloor', label: 'Heat generation discount floor', min: 0, max: 1, integer: false },
+ { path: 'anomaly.creditsSecondsMin', label: 'Anomaly credits (min seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.creditsSecondsMax', label: 'Anomaly credits (max seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.boostDurationMinMs', label: 'Anomaly boost duration min (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostDurationMaxMs', label: 'Anomaly boost duration max (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostMultMin', label: 'Anomaly boost multiplier min', min: 1, max: 100, integer: false },
+ { path: 'anomaly.boostMultMax', label: 'Anomaly boost multiplier max', min: 1, max: 100, integer: false },
+ { path: 'production.levelBonusPerLevel', label: 'Output bonus per account level', min: 0, max: 1, integer: false },
+ { path: 'production.levelBonusMaxLevel', label: 'Account level bonus cap (levels)', min: 1, max: 10000, integer: true },
+ { path: 'prestige.migrateDivisor', label: 'Migrate: lifetime divisor', min: 1, max: 1e18, integer: false },
+ { path: 'prestige.migrateExponent', label: 'Migrate: gain exponent', min: 0.05, max: 2, integer: false },
+ { path: 'prestige.corePercentPerCore', label: 'Output per Legacy Core', min: 0, max: 1, integer: false },
+ { path: 'prestige.coreBonusCap', label: 'Legacy Core bonus cap (cores)', min: 1, max: 1e9, integer: true },
+ { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of Migrate gain per level', min: 0, max: 1, integer: false },
+ { path: 'prestige.shardsPerCore', label: 'Singularity: shards per Legacy Core', min: 0, max: 10, integer: false },
+ { path: 'minigames.balance.waferPerPoint', label: 'Balance wafers per point', min: 0, max: 100, integer: false },
];
export function getAtPath(obj, path) {
diff --git a/shared/events.js b/shared/events.js
index 717a8e0..6d818f2 100644
--- a/shared/events.js
+++ b/shared/events.js
@@ -99,6 +99,18 @@ export function validateModifiers(modifiers) {
const MAX_LADDER_RUNGS = 20;
const REWARD_KEYS = ['wafers', 'tapes', 'flops'];
+/**
+ * v1.12: a rung's target may be expressed in SECONDS OF THE PLAYER'S OUTPUT
+ * rather than as an absolute number. Absolute FLOPS targets were the one reward
+ * system in the game not priced against the player's rate - contracts
+ * (social.contractFlopsSeconds) and streaks (social.streakFlopsSeconds) already
+ * were - so every seasonal ladder's FLOPS rungs cleared in under 0.02 seconds.
+ *
+ * Count-based metrics (minigamesWon, blocksClaimed) stay absolute; scaling a
+ * count by output is meaningless.
+ */
+const RUNG_UNITS = ['absolute', 'secondsOfOutput'];
+
export function validateLadder(ladder) {
const errors = [];
if (!Array.isArray(ladder) || ladder.length < 1 || ladder.length > MAX_LADDER_RUNGS) {
@@ -114,6 +126,10 @@ export function validateLadder(ladder) {
if (typeof metric !== 'string' || !Object.prototype.hasOwnProperty.call(EVENT_METRICS, metric)) {
errors.push(`rung ${i}: unknown metric ${metric}`);
}
+ const unit = rung.unit === undefined ? 'absolute' : rung.unit;
+ if (!RUNG_UNITS.includes(unit)) {
+ errors.push(`rung ${i}: unknown unit ${rung.unit}`);
+ }
if (typeof target !== 'number' || !Number.isFinite(target) || target <= 0) {
errors.push(`rung ${i}: target must be a positive finite number`);
}
@@ -132,9 +148,13 @@ export function validateLadder(ladder) {
if (typeof metric === 'string' && Object.prototype.hasOwnProperty.call(EVENT_METRICS, metric)
&& typeof target === 'number' && Number.isFinite(target)) {
- const prev = Object.prototype.hasOwnProperty.call(lastTargetByMetric, metric) ? lastTargetByMetric[metric] : -Infinity;
+ // Keyed by (metric, unit): a ladder may legitimately carry both an
+ // absolute and a rate-scaled series for the same metric, and comparing
+ // "1800 seconds of output" against "20000 FLOPS" would be meaningless.
+ const key = `${metric}:${unit}`;
+ const prev = Object.prototype.hasOwnProperty.call(lastTargetByMetric, key) ? lastTargetByMetric[key] : -Infinity;
if (target <= prev) errors.push(`rung ${i}: target must strictly increase within metric ${metric}`);
- lastTargetByMetric[metric] = target;
+ lastTargetByMetric[key] = target;
}
});
@@ -183,11 +203,23 @@ export function isValidRecurrence(recurrence) {
return recurrence !== null && recurrence !== undefined && validateRecurrence(recurrence).ok;
}
-export function rungProgress(rung, meta, baseline) {
+/**
+ * `materialisedTarget` is the join-time snapshot from meta.eventProgress.targets
+ * (see server/eventService.js). It is passed for EVERY rung, absolute or not, so
+ * this function never needs to know the player's output. When it is absent - an
+ * older save that joined before v1.12 - fall back to the literal target, which
+ * is exactly the pre-v1.12 behaviour.
+ *
+ * Snapshot-at-join rather than recompute-on-read is the same choice
+ * rolloverContracts makes, and for the same reason: a rate-scaled target
+ * recomputed on every read would recede as fast as the player approached it.
+ */
+export function rungProgress(rung, meta, baseline, materialisedTarget) {
const value = eventMetricValue(rung.metric, meta) ?? 0;
const hasBaseline = baseline && typeof rung.metric === 'string'
&& Object.prototype.hasOwnProperty.call(baseline, rung.metric);
const base = hasBaseline ? baseline[rung.metric] : 0;
const current = Math.max(0, value - (typeof base === 'number' ? base : 0));
- return { current, target: rung.target, met: current >= rung.target };
+ const target = Number.isFinite(materialisedTarget) ? materialisedTarget : rung.target;
+ return { current, target, met: current >= target };
}
diff --git a/shared/gameData.js b/shared/gameData.js
index 0337df9..4cfb951 100644
--- a/shared/gameData.js
+++ b/shared/gameData.js
@@ -1,21 +1,31 @@
+// v1.12 rebalance: TIER_DEFS baseCost is derived as
+// baseProd * 10 * 2.50^tier
+// so the cost:production ratio grows ~2.5x per tier instead of the old ~1.95x.
+// baseProd is deliberately UNCHANGED, which is what keeps goals, contracts,
+// achievements and existing saves meaningful.
+//
+// GROWTH and MILESTONES are unchanged, and that was tested rather than assumed:
+// GROWTH 1.16 and 1.20 were both simulated and neither moved pacing, while 1.20
+// collapses within-tier depth (efficiency 0.0004 at the 50->100 step), deleting
+// milestone play entirely.
export const GROWTH = 1.14;
export const MILESTONES = [25, 50, 100, 200, 500, 1000];
export const TIER_DEFS = [
- { id: 0, name: 'Spare Raspberry Pi', baseCost: 4, baseProd: 0.5, managerCost: 500 },
- { id: 1, name: 'Refurbished Gaming Rig', baseCost: 60, baseProd: 6, managerCost: 6000 },
- { id: 2, name: 'Home NAS Tower', baseCost: 720, baseProd: 45, managerCost: 70000 },
- { id: 3, name: 'Colo Rack Unit', baseCost: 8800, baseProd: 320, managerCost: 900000 },
- { id: 4, name: 'Server Room', baseCost: 110000, baseProd: 2200, managerCost: 12000000 },
- { id: 5, name: 'Regional Data Center', baseCost: 1400000, baseProd: 16000, managerCost: 170000000 },
- { id: 6, name: 'Cloud Availability Zone', baseCost: 20000000, baseProd: 120000, managerCost: 2400000000 },
- { id: 7, name: 'Hyperscale Campus', baseCost: 330000000, baseProd: 900000, managerCost: 40000000000 },
- { id: 8, name: 'Orbital Compute Platform', baseCost: 5000000000, baseProd: 7000000, managerCost: 650000000000 },
- { id: 9, name: 'Dyson Swarm Cluster', baseCost: 80000000000, baseProd: 55000000, managerCost: 10000000000000 },
- { id: 10, name: 'Lunar Compute Colony', baseCost: 1250000000000, baseProd: 430000000, managerCost: 160000000000000 },
- { id: 11, name: 'Interstellar Relay Farm', baseCost: 19000000000000, baseProd: 3300000000, managerCost: 2400000000000000 },
- { id: 12, name: 'Galactic Mesh Network', baseCost: 300000000000000, baseProd: 26000000000, managerCost: 37000000000000000 },
- { id: 13, name: 'Quantum Foam Harvester', baseCost: 4600000000000000, baseProd: 200000000000, managerCost: 580000000000000000 },
+ { id: 0, name: 'Spare Raspberry Pi', baseCost: 5, baseProd: 0.5, managerCost: 500 },
+ { id: 1, name: 'Refurbished Gaming Rig', baseCost: 150, baseProd: 6, managerCost: 6000 },
+ { id: 2, name: 'Home NAS Tower', baseCost: 2800, baseProd: 45, managerCost: 70000 },
+ { id: 3, name: 'Colo Rack Unit', baseCost: 50000, baseProd: 320, managerCost: 900000 },
+ { id: 4, name: 'Server Room', baseCost: 860000, baseProd: 2200, managerCost: 12000000 },
+ { id: 5, name: 'Regional Data Center', baseCost: 16000000, baseProd: 16000, managerCost: 170000000 },
+ { id: 6, name: 'Cloud Availability Zone', baseCost: 290000000, baseProd: 120000, managerCost: 2400000000 },
+ { id: 7, name: 'Hyperscale Campus', baseCost: 5500000000, baseProd: 900000, managerCost: 40000000000 },
+ { id: 8, name: 'Orbital Compute Platform', baseCost: 110000000000, baseProd: 7000000, managerCost: 650000000000 },
+ { id: 9, name: 'Dyson Swarm Cluster', baseCost: 2100000000000, baseProd: 55000000, managerCost: 10000000000000 },
+ { id: 10, name: 'Lunar Compute Colony', baseCost: 41000000000000, baseProd: 430000000, managerCost: 160000000000000 },
+ { id: 11, name: 'Interstellar Relay Farm', baseCost: 790000000000000, baseProd: 3300000000, managerCost: 2400000000000000 },
+ { id: 12, name: 'Galactic Mesh Network', baseCost: 15000000000000000, baseProd: 26000000000, managerCost: 37000000000000000 },
+ { id: 13, name: 'Quantum Foam Harvester', baseCost: 300000000000000000, baseProd: 200000000000, managerCost: 580000000000000000 },
];
export const GRID_DEFS = [
@@ -41,18 +51,22 @@ export const UPGRADE_DEFS = [
{ id: 'signal', name: 'Signal Boost', desc: 'Anomaly event rewards +20% per level', baseCost: 6, costMult: 1.5, maxLevel: 10 },
{ id: 'gridamp', name: 'Grid Amplifier', desc: 'Grid lane output +25% per level', baseCost: 10, costMult: 1.6, maxLevel: 15 },
{ id: 'legacy', name: 'Legacy Insight', desc: 'Migrate Legacy Core gain +10% per level', baseCost: 20, costMult: 2.0, maxLevel: 10 },
- { id: 'thermal', name: 'Thermal Regulators', desc: 'Overclock Bay heat generation -8% per level', baseCost: 8, costMult: 1.7, maxLevel: 8 },
- { id: 'autovent', name: 'Auto-Vent System', desc: 'Passively vents 0.5 heat/sec per level', baseCost: 15, costMult: 1.8, maxLevel: 8 },
+ { id: 'thermal', name: 'Thermal Regulators', desc: 'Overclock Bay heat generation -5% per level', baseCost: 8, costMult: 1.7, maxLevel: 8 },
+ { id: 'autovent', name: 'Auto-Vent System', desc: 'Passively vents 4 heat/sec per level', baseCost: 15, costMult: 1.8, maxLevel: 8 },
{ id: 'occlock', name: 'Overclock Amplifier', desc: '+25% Overclock Bay output per level', baseCost: 12, costMult: 1.6, maxLevel: 15 },
{ id: 'lucky', name: 'Lucky Silicon', desc: 'Minigame wafer rewards +15% per level', baseCost: 6, costMult: 1.5, maxLevel: 10 },
{ id: 'deepcache', name: 'Deep Cache', desc: 'Start each Migrate with +10 Compute Balance per level', baseCost: 4, costMult: 1.4, maxLevel: 10 },
];
export const SINGULARITY_DEFS = [
- { id: 'bootstrap', name: 'Quantum Bootstrap', desc: 'Starting Compute Balance after Migrate x10 per level', baseCost: 3, costMult: 2.2, maxLevel: 5 },
+ { id: 'bootstrap', name: 'Quantum Bootstrap', desc: 'Starting Compute Balance after Migrate x3 per level', baseCost: 3, costMult: 2.2, maxLevel: 5 },
{ id: 'temporal', name: 'Temporal Compression', desc: 'Legacy Core gain from Migrate +25% per level', baseCost: 4, costMult: 2.4, maxLevel: 5 },
- { id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 2.6, maxLevel: 8 },
- { id: 'heatsink', name: 'Heat Sink Mastery', desc: 'Overclock Bay heat generation -25% per level', baseCost: 3, costMult: 2.2, maxLevel: 4 },
+ // v1.12: maxLevel 8 -> 12. At 8 levels "tier 13 is reachable" and "the shard
+ // tree is still a goal at day 45" are mutually exclusive - every calibration
+ // setting that reached tier 13 also maxed the tree. The longer tail decouples
+ // them: the x5 that actually powers the late tiers costs ~7% of the tree.
+ { id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 1.9, maxLevel: 12 },
+ { id: 'heatsink', name: 'Heat Sink Mastery', desc: 'Overclock Bay heat generation -15% per level', baseCost: 3, costMult: 2.2, maxLevel: 4 },
{ id: 'infiniteloop', name: 'Infinite Loop', desc: 'Milestone thresholds -10% per level, easier to reach', baseCost: 5, costMult: 2.5, maxLevel: 5 },
- { id: 'echocores', name: 'Echo Cores', desc: 'Instantly regain 1 free Legacy Core per level after every Migrate', baseCost: 4, costMult: 2.3, maxLevel: 10 },
+ { id: 'echocores', name: 'Echo Cores', desc: 'Migrate grants +5% bonus Legacy Cores per level', baseCost: 4, costMult: 1.8, maxLevel: 10 },
];
diff --git a/shared/gameRules.js b/shared/gameRules.js
index 000ad9e..7e06b6f 100644
--- a/shared/gameRules.js
+++ b/shared/gameRules.js
@@ -58,12 +58,26 @@ export function computeEffects(meta, config) {
gridExtraMult: 1 + 0.25 * (lv.gridamp || 0),
overclockExtraMult: 1 + 0.25 * (lv.occlock || 0),
legacyGainMult: (1 + 0.10 * (lv.legacy || 0)) * (1 + 0.25 * (sv.temporal || 0)),
- levelBonusMult: 1 + 0.02 * (meta.level || 0),
- heatDiscount: Math.max(0.15, 1 - 0.08 * (lv.thermal || 0) - 0.25 * (sv.heatsink || 0)),
- autoVentPerSec: 0.5 * (lv.autovent || 0),
+ // v1.12: capped. This was uncapped, and the repeatable goals never run out,
+ // so account level - and therefore output - grew without bound.
+ levelBonusMult: 1 + config.production.levelBonusPerLevel
+ * Math.min(meta.level || 0, config.production.levelBonusMaxLevel),
+ // v1.12: config-driven, and retuned. The old stack cut heat generation by
+ // 85% and passively vented 4/s, which - together with manual venting
+ // supplying 200 heat/s - made overheating unreachable for an attentive
+ // player and unavoidable for an inattentive one. The floor is now 0.40 and
+ // passive venting is much stronger, so the gap between tapping and not is
+ // ~2.5x the sustainable fleet rather than ~26x.
+ heatDiscount: Math.max(config.heat.discountFloor,
+ 1 - config.heat.thermalPerLevel * (lv.thermal || 0)
+ - config.heat.heatsinkPerLevel * (sv.heatsink || 0)),
+ autoVentPerSec: config.heat.autoVentPerLevel * (lv.autovent || 0),
luckyMinigameMult: 1 + 0.15 * (lv.lucky || 0),
deepCacheBonus: 10 * (lv.deepcache || 0),
- bootstrapMult: Math.pow(10, sv.bootstrap || 0),
+ // v1.12: x3 per level, was x10. Maxed, that plus Deep Cache handed the
+ // player 11,000,000 credits at every Migrate - enough to buy straight back
+ // into tier-6 territory, so Migrate stopped being a reset at all.
+ bootstrapMult: Math.pow(3, sv.bootstrap || 0),
milestoneDiscount: Math.max(0.3, 1 - 0.10 * (sv.infiniteloop || 0)),
echoCoresBonus: sv.echocores || 0,
};
@@ -85,7 +99,16 @@ export function milestoneThresholds(meta, config) {
export function computeMults(meta, config, boostMult = 1) {
const eff = computeEffects(meta, config);
const thresholds = milestoneThresholds(meta, config);
- const base = (1 + (meta.legacyCores || 0) * 0.05) * eff.firmwareMult * eff.engineMult
+ // v1.12: the core bonus PLATEAUS. Past the cap, extra cores buy no output at
+ // all - they are only fuel for the next Singularity. That plateau is the gate
+ // that makes Singularity worth taking: before it, Singularity was a strict
+ // downgrade at every scale (it zeroes cores worth 1 + 0.05*C and returns
+ // shards worth far less), and was only survivable because cores regrew within
+ // a day.
+ const pr = config.prestige;
+ const coreMult = 1 + pr.corePercentPerCore
+ * Math.min(meta.legacyCores || 0, pr.coreBonusCap);
+ const base = coreMult * eff.firmwareMult * eff.engineMult
* eff.levelBonusMult * boostMult * config.production.globalMult;
// coldFusionMult folded in here (not applied ad-hoc by each caller) so
// every consumer of computeMults - evaluate()'s online/offline branches,
@@ -135,8 +158,20 @@ export function overclockBoost(run, config, overclockMult, thresholds, racksOutp
return 1 + gain * (ocOutput / racksOutput);
}
-export function migrateGain(lifetimeRun, legacyGainMult) {
- return Math.floor(Math.sqrt(lifetimeRun / 1e6) * legacyGainMult);
+/**
+ * v1.12: was sqrt(lifetimeRun / 1e6), which handed out thousands of cores after
+ * a single day and made every later prestige explosive.
+ *
+ * `migrateDivisor` is fixed by one requirement - the first Migrate should land
+ * on day 4-8 - which leaves `migrateExponent` as the only dial controlling how
+ * fast cores climb toward `coreBonusCap`. It has to be near-linear to reach the
+ * cap at all, and that is safe ONLY because computeMults caps the payoff. If you
+ * ever remove that cap, this exponent restores the runaway.
+ */
+export function migrateGain(lifetimeRun, legacyGainMult, config) {
+ if (!(lifetimeRun > 0)) return 0;
+ const p = config.prestige;
+ return Math.floor(Math.pow(lifetimeRun / p.migrateDivisor, p.migrateExponent) * legacyGainMult);
}
export function minigameWafers(game, metric, meta, config) {
@@ -145,6 +180,9 @@ export function minigameWafers(game, metric, meta, config) {
if (game === 'rush') return Math.max(1, Math.floor((metric / mg.rush.waferDivisor) * lucky));
if (game === 'debug') return Math.max(1, Math.floor((metric / mg.debug.waferDivisor) * lucky));
if (game === 'match') return Math.floor(metric * mg.match.waferPerPair * lucky);
- if (game === 'balance') return Math.max(1, Math.floor(metric * 1.5 * lucky));
+ // v1.12: the coefficient is a tunable. At the old hardcoded 1.5, a maxScore
+ // run with Lucky Silicon paid 562 wafers per 12-second game, so ~2.5 hours of
+ // minigames maxed every permanent upgrade in the game.
+ if (game === 'balance') return Math.max(1, Math.floor(metric * mg.balance.waferPerPoint * lucky));
throw new Error(`unknown game: ${game}`);
}
diff --git a/shared/outages.js b/shared/outages.js
index af5a368..0502f6f 100644
--- a/shared/outages.js
+++ b/shared/outages.js
@@ -268,7 +268,17 @@ export function hazardFrom(scheduledAt, config, state) {
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)] };
+ // v1.12: the TOP owned tier. A random victim was both unpredictable and
+ // usually trivial (~1/14 of output); the top tier is legible in the UI
+ // ("your Quantum Foam Harvester lost a drive") and actually worth insuring
+ // against. `owned` is built in ascending order, so the last entry is the
+ // highest tier. Switchable back to the derived-random pick.
+ scope = {
+ lane: 'tiers',
+ index: config.risk.driveFailureTargetsTopTier
+ ? owned[owned.length - 1]
+ : owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)],
+ };
}
return {
@@ -397,7 +407,9 @@ export function overheatOutage(state, config, now) {
}
if (owned.length === 0) return null;
- const index = owned[Math.floor(unitAt(now, 2) * owned.length)];
+ const index = config.risk.overheatTargetsTopTier
+ ? owned[owned.length - 1]
+ : 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;
diff --git a/shared/reducer.js b/shared/reducer.js
index b97e286..56d0ac3 100644
--- a/shared/reducer.js
+++ b/shared/reducer.js
@@ -140,10 +140,13 @@ function vent(s, action, config, now) {
function migrate(s, action, config) {
const eff = computeEffects(s.meta, config);
- const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult);
+ const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult, config);
if (gain <= 0) return err('invalid_target');
- const echoBonus = eff.echoCoresBonus || 0;
+ // v1.12: a share of the gain, not a flat grant. A flat +10 cores per Migrate
+ // is farmable once cores are scarce - migrate cheaply and repeatedly for free
+ // cores - which the new curve would otherwise make the dominant strategy.
+ const echoBonus = Math.floor(gain * config.prestige.echoPercentPerLevel * (eff.echoCoresBonus || 0));
const startCredits = (10 + eff.deepCacheBonus) * eff.bootstrapMult;
s.run = { ...initialState().run, credits: startCredits };
@@ -152,8 +155,15 @@ function migrate(s, action, config) {
return { ok: true };
}
-function singularity(s) {
- const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0));
+// v1.12: linear in cores, not sqrt. Once legacyCores is capped (computeMults),
+// the square root has nothing left to damp and only starves the shard tree -
+// 400 cores returned 20 shards against a ~17k-shard tree, so the tree could
+// never progress and the late tiers kept no engine.
+//
+// `config` arrives because HANDLERS invokes every handler as
+// handler(s, action, config, now, rng); no call site needs changing.
+function singularity(s, action, config) {
+ const shardsGained = Math.floor((s.meta.legacyCores || 0) * config.prestige.shardsPerCore);
if (shardsGained <= 0) return err('invalid_target');
recordLegacyCorePeak(s.meta);
@@ -453,14 +463,21 @@ function claimAnomaly(s, action, config, now, rng) {
if (roll < 0.5) {
const ctx = goalCtx(s, config, now);
- const seconds = 30 + rng() * 60;
+ const ac = config.anomaly;
+ const seconds = ac.creditsSecondsMin + rng() * (ac.creditsSecondsMax - ac.creditsSecondsMin);
const amount = Math.max(ctx.totalOutputPerSec * seconds, 20) * eff.eventRewardMult;
s.run.credits += amount;
s.run.lifetimeRun += amount;
reward = { kind: 'credits', amount };
} else {
- const mult = [2, 3, 4][Math.floor(rng() * 3)];
- const duration = (45 + rng() * 30) * eff.eventRewardMult;
+ // v1.12: the boost's DURATION must never be scaled by eventRewardMult. It
+ // was, and at max Signal Boost that pushed the duration (135-225s) past the
+ // respawn interval (70-150s), so a 2-4x GLOBAL multiplier was permanently
+ // active - measured at 55% boost uptime, worth ~+245% output. Signal Boost
+ // scales the payout only.
+ const ab = config.anomaly;
+ const mult = ab.boostMultMin + rng() * (ab.boostMultMax - ab.boostMultMin);
+ const duration = (ab.boostDurationMinMs + rng() * (ab.boostDurationMaxMs - ab.boostDurationMinMs)) / 1000;
s.server.boost = { mult, until: now + duration * 1000 };
reward = { kind: 'boost', mult, until: s.server.boost.until };
}
@@ -549,7 +566,8 @@ function claimEventRung(s, action, config, now) {
// no such field and is measured normally.
if (Array.isArray(ep.claimableRungs)) {
if (!ep.claimableRungs.includes(index)) return err('not_met');
- } else if (!rungProgress(ladder[index], s.meta, ep.baseline).met) {
+ } else if (!rungProgress(ladder[index], s.meta, ep.baseline,
+ Array.isArray(ep.targets) ? ep.targets[index] : undefined).met) {
return err('not_met');
}
diff --git a/shared/state.js b/shared/state.js
index 577a0e8..1af0c7b 100644
--- a/shared/state.js
+++ b/shared/state.js
@@ -351,12 +351,18 @@ export function evaluate(state, config, lastEvaluatedAt, now, rng = Math.random)
const newHeat = Math.max(0, s.run.heat + netHeat * elapsedSec);
if (newHeat >= config.heat.capacity + csEff.heatCapacityBonus) {
s.run.heat = 0;
- 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)) {
+ // v1.12: carry WHICH tier went dark so the client can name it in the
+ // meltdown toast. Finding 2.7 was as much a legibility failure as a
+ // math one - the penalty was never attributed to overclocking, so it
+ // read as the game being broken. Still truthy either way, so every
+ // existing `if (server.overheated)` check is unaffected.
+ const downed = overheatOutage(s, config, now);
+ s.server.overheated = downed ? { tierIndex: downed.scope.index } : true;
+ if (!downed) {
s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs;
}
} else {
diff --git a/tests/configSchema.test.js b/tests/configSchema.test.js
index 3c2775c..7fc018e 100644
--- a/tests/configSchema.test.js
+++ b/tests/configSchema.test.js
@@ -5,20 +5,21 @@ describe('configSchema', () => {
it('has the spec §3.6 defaults', () => {
expect(DEFAULT_CONFIG.schemaVersion).toBe(1);
expect(DEFAULT_CONFIG.heat.capacity).toBe(2000);
- expect(DEFAULT_CONFIG.heat.ventPercent).toBe(25);
+ // v1.12 retuned the vent curve: 35% of capacity per 15s, was 25% per 2.5s.
+ expect(DEFAULT_CONFIG.heat.ventPercent).toBe(35);
expect(DEFAULT_CONFIG.heat.overheatPopupMs).toBe(15000);
expect(DEFAULT_CONFIG.heat.ventAmount).toBeUndefined();
- expect(DEFAULT_CONFIG.heat.ventCooldownMs).toBe(2500);
+ expect(DEFAULT_CONFIG.heat.ventCooldownMs).toBe(15000);
expect(DEFAULT_CONFIG.heat.overheatCooldownMs).toBe(10000);
expect(DEFAULT_CONFIG.minigames.balance).toMatchObject({
pointsSafe: 1, pointsRisk: 5, missPenalty: 2, riskZoneWidth: 4,
safeZoneMin: 35, safeZoneMax: 65, durationSec: 12,
});
- expect(DEFAULT_CONFIG.production).toEqual({ globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1 });
+ expect(DEFAULT_CONFIG.production).toMatchObject({ globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1 });
expect(DEFAULT_CONFIG.offline.onlineGapThresholdSec).toBe(60);
expect(DEFAULT_CONFIG.offline.hardCapHours).toBe(72);
expect(DEFAULT_CONFIG.upgrades.maxLevels.firmware).toBe(20);
- expect(DEFAULT_CONFIG.anomaly).toEqual({ windowMs: 15000, minDelayMs: 70000, maxDelayMs: 150000 });
+ expect(DEFAULT_CONFIG.anomaly).toMatchObject({ windowMs: 30000, minDelayMs: 420000, maxDelayMs: 900000 });
});
it('every TUNABLES path resolves in DEFAULT_CONFIG and is in range', () => {
for (const t of TUNABLES) {
@@ -98,7 +99,7 @@ describe('v1.6 heat tunables', () => {
const legacy = { heat: { capacity: 4000, ventAmount: 900, ventCooldownMs: 3000 } };
const out = upgradeConfig(legacy);
expect(out.heat.ventAmount).toBeUndefined();
- expect(out.heat.ventPercent).toBe(25);
+ expect(out.heat.ventPercent).toBe(35);
expect(out.heat.overheatPopupMs).toBe(15000);
expect(out.heat.capacity).toBe(4000); // tuned values still carry over
expect(out.heat.ventCooldownMs).toBe(3000);
@@ -132,7 +133,7 @@ describe('boolean tunables (v1.11)', () => {
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.ransomwareFactor).toBe(0.35); // v1.12: softer but twice as frequent
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)) {
@@ -140,3 +141,53 @@ describe('boolean tunables (v1.11)', () => {
}
});
});
+
+describe('v1.12 config surface', () => {
+ it('DEFAULT_CONFIG still validates with the new leaves', () => {
+ expect(validateConfig(DEFAULT_CONFIG)).toEqual({ ok: true });
+ });
+
+ it('every new v1.12 path exists and has a TUNABLES row', () => {
+ const paths = [
+ 'heat.autoVentPerLevel', 'heat.thermalPerLevel', 'heat.heatsinkPerLevel', 'heat.discountFloor',
+ 'anomaly.creditsSecondsMin', 'anomaly.creditsSecondsMax',
+ 'anomaly.boostDurationMinMs', 'anomaly.boostDurationMaxMs',
+ 'anomaly.boostMultMin', 'anomaly.boostMultMax',
+ 'production.levelBonusPerLevel', 'production.levelBonusMaxLevel',
+ 'prestige.migrateDivisor', 'prestige.migrateExponent', 'prestige.corePercentPerCore',
+ 'prestige.coreBonusCap', 'prestige.echoPercentPerLevel', 'prestige.shardsPerCore',
+ 'minigames.balance.waferPerPoint',
+ 'risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier',
+ ];
+ const rows = new Set(TUNABLES.map((t) => t.path));
+ for (const p of paths) {
+ expect(getAtPath(DEFAULT_CONFIG, p), `missing DEFAULT_CONFIG leaf ${p}`).toBeDefined();
+ expect(rows.has(p), `missing TUNABLES row ${p}`).toBe(true);
+ }
+ });
+
+ it('the two new risk switches are boolean-typed tunables', () => {
+ for (const p of ['risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier']) {
+ expect(TUNABLES.find((t) => t.path === p).type).toBe('boolean');
+ }
+ });
+
+ it('upgradeConfig folds the new paths into a stored pre-v1.12 config', () => {
+ // a stored config written before v1.12 simply lacks these leaves
+ const old = structuredClone(DEFAULT_CONFIG);
+ delete old.prestige;
+ delete old.production.levelBonusPerLevel;
+ const upgraded = upgradeConfig(old);
+ expect(upgraded.prestige.coreBonusCap).toBe(400);
+ expect(upgraded.production.levelBonusPerLevel).toBe(0.02);
+ expect(validateConfig(upgraded)).toEqual({ ok: true });
+ });
+
+ it('carries the recalibrated v1.12 values', () => {
+ expect(DEFAULT_CONFIG.anomaly.minDelayMs).toBe(420000);
+ expect(DEFAULT_CONFIG.heat.ventCooldownMs).toBe(15000);
+ expect(DEFAULT_CONFIG.risk.hazardMinDelayMs).toBe(7200000);
+ expect(DEFAULT_CONFIG.minigames.winCooldownMs).toBe(300000);
+ expect(DEFAULT_CONFIG.upgrades.maxLevels.engine).toBe(12);
+ });
+});
diff --git a/tests/events.test.js b/tests/events.test.js
index 17f20d6..e02c3e0 100644
--- a/tests/events.test.js
+++ b/tests/events.test.js
@@ -134,3 +134,38 @@ describe('event modifiers vs boolean tunables (v1.11)', () => {
expect(validateModifiers([{ path: 'risk.ransomwareFactor', value: 0.25 }]).ok).toBe(true);
});
});
+
+describe('v1.12 rate-scaled event rungs', () => {
+ it('accepts a secondsOfOutput unit', () => {
+ expect(validateLadder([
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 10 } },
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ ])).toEqual({ ok: true });
+ });
+
+ it('rejects an unknown unit', () => {
+ const r = validateLadder([{ metric: 'flopsEarned', target: 600, unit: 'furlongs', reward: { wafers: 10 } }]);
+ expect(r.ok).toBe(false);
+ expect(r.errors[0]).toMatch(/unit/);
+ });
+
+ it('still requires targets to strictly increase within a (metric, unit) pair', () => {
+ const r = validateLadder([
+ { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 10 } },
+ { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } },
+ ]);
+ expect(r.ok).toBe(false);
+ });
+
+ it('uses the materialised target when one is supplied', () => {
+ const rung = { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: {} };
+ const m = { stats: { lifetimeFlopsAllTime: 5_000_000 } };
+ expect(rungProgress(rung, m, {}, 6_000_000).met).toBe(false);
+ expect(rungProgress(rung, m, {}, 4_000_000).met).toBe(true);
+ });
+
+ it('falls back to the literal target for absolute rungs', () => {
+ const rung = { metric: 'minigamesWon', target: 5, reward: {} };
+ expect(rungProgress(rung, { stats: { minigamesWon: 5 } }, {}).met).toBe(true);
+ });
+});
diff --git a/tests/gameData.test.js b/tests/gameData.test.js
index b90af0b..f299a7a 100644
--- a/tests/gameData.test.js
+++ b/tests/gameData.test.js
@@ -6,8 +6,8 @@ describe('gameData', () => {
expect(GROWTH).toBe(1.14);
expect(MILESTONES).toEqual([25, 50, 100, 200, 500, 1000]);
expect(TIER_DEFS).toHaveLength(14);
- expect(TIER_DEFS[0]).toEqual({ id: 0, name: 'Spare Raspberry Pi', baseCost: 4, baseProd: 0.5, managerCost: 500 });
- expect(TIER_DEFS[13].baseCost).toBe(4600000000000000);
+ expect(TIER_DEFS[0]).toEqual({ id: 0, name: 'Spare Raspberry Pi', baseCost: 5, baseProd: 0.5, managerCost: 500 });
+ expect(TIER_DEFS[13].baseCost).toBe(3e17);
expect(GRID_DEFS).toHaveLength(5);
expect(OVERCLOCK_DEFS[0]).toEqual({ id: 0, name: 'Air-Cooled Overclock Rig', baseCost: 300, baseProd: 40, heatPerSec: 0.15 });
expect(UPGRADE_DEFS.map((u) => u.id)).toContain('firmware');
@@ -19,3 +19,19 @@ describe('gameData', () => {
}
});
});
+
+describe('v1.12 tier cost curve', () => {
+ it('cost:production ratio grows ~2.5x per tier', () => {
+ const ratios = TIER_DEFS.map((d) => d.baseCost / d.baseProd);
+ for (let i = 1; i < ratios.length; i++) {
+ const step = ratios[i] / ratios[i - 1];
+ expect(step, `tier ${i} step ${step}`).toBeGreaterThan(2.2);
+ expect(step, `tier ${i} step ${step}`).toBeLessThan(2.8);
+ }
+ });
+
+ it('keeps the opening cheap and makes the top tier the long goal', () => {
+ expect(TIER_DEFS[0].baseCost).toBe(5);
+ expect(TIER_DEFS[13].baseCost).toBe(3e17);
+ });
+});
diff --git a/tests/gameRules.test.js b/tests/gameRules.test.js
index e4f87b3..fe0da6e 100644
--- a/tests/gameRules.test.js
+++ b/tests/gameRules.test.js
@@ -7,10 +7,11 @@ import { initialState } from '../shared/state.js';
const meta0 = { legacyCores: 0, level: 0, upgrades: {}, shardUpgrades: {} };
describe('gameRules', () => {
+ // The FORMULAS are unchanged; only tier 0's baseCost moved 4 -> 5 in v1.12.
it('cost math matches v1.1 formulas', () => {
- expect(costAt(TIER_DEFS[0], 0)).toBe(4);
- expect(costAt(TIER_DEFS[0], 1)).toBeCloseTo(4 * 1.14);
- expect(costForN(TIER_DEFS[0], 0, 2)).toBeCloseTo(4 + 4 * 1.14);
+ expect(costAt(TIER_DEFS[0], 0)).toBe(5);
+ expect(costAt(TIER_DEFS[0], 1)).toBeCloseTo(5 * 1.14);
+ expect(costForN(TIER_DEFS[0], 0, 2)).toBeCloseTo(5 + 5 * 1.14);
expect(maxAffordable(TIER_DEFS[0], 0, 100)).toBeGreaterThan(0);
expect(maxAffordable(TIER_DEFS[0], 0, 3)).toBe(0);
});
@@ -44,13 +45,83 @@ describe('gameRules', () => {
});
it('xp and migrate math', () => {
expect(xpForLevel(0)).toBe(50);
- expect(migrateGain(1e6, 1)).toBe(1);
- expect(migrateGain(4e6, 1)).toBe(2);
+ // v1.12: (L / 2e12) ** 1.0. Below the divisor there is nothing to claim yet.
+ expect(migrateGain(1e6, 1, DEFAULT_CONFIG)).toBe(0);
+ expect(migrateGain(2e12, 1, DEFAULT_CONFIG)).toBe(1);
+ expect(migrateGain(4e13, 1, DEFAULT_CONFIG)).toBe(20);
+ expect(migrateGain(0, 1, DEFAULT_CONFIG)).toBe(0);
+ expect(migrateGain(-5, 1, DEFAULT_CONFIG)).toBe(0);
});
it('minigame payouts', () => {
- expect(minigameWafers('rush', 40, meta0, DEFAULT_CONFIG)).toBe(10);
+ // v1.12 divisors: rush 6 (was 4), debug 3 (was 2)
+ expect(minigameWafers('rush', 40, meta0, DEFAULT_CONFIG)).toBe(6);
expect(minigameWafers('match', 10, meta0, DEFAULT_CONFIG)).toBe(20);
- expect(minigameWafers('balance', 6, meta0, DEFAULT_CONFIG)).toBe(9);
+ expect(minigameWafers('balance', 150, meta0, DEFAULT_CONFIG)).toBe(30); // 150 * 0.20
+ });
+
+ it('balance payout is config-driven, not a hardcoded 1.5', () => {
+ const doubled = structuredClone(DEFAULT_CONFIG);
+ doubled.minigames.balance.waferPerPoint = 0.40;
+ expect(minigameWafers('balance', 150, meta0, doubled)).toBe(60);
+ });
+});
+
+describe('v1.12 heat curve is config-driven', () => {
+ const meta = (upgrades = {}, shardUpgrades = {}) => ({
+ upgrades, shardUpgrades, level: 0, legacyCores: 0,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('reads per-level rates and the floor from config', () => {
+ const eff = computeEffects(meta({ thermal: 8, autovent: 8 }, { heatsink: 4 }), DEFAULT_CONFIG);
+ // 1 - 0.05*8 - 0.15*4 = 0, clamped to the 0.40 floor
+ expect(eff.heatDiscount).toBeCloseTo(0.40);
+ expect(eff.autoVentPerSec).toBeCloseTo(32);
+ });
+
+ it('an un-upgraded save generates full heat and vents nothing passively', () => {
+ const eff = computeEffects(meta(), DEFAULT_CONFIG);
+ expect(eff.heatDiscount).toBeCloseTo(1);
+ expect(eff.autoVentPerSec).toBe(0);
+ });
+});
+
+describe('v1.12 Legacy Core bonus is capped', () => {
+ const meta = (legacyCores) => ({
+ upgrades: {}, shardUpgrades: {}, level: 0, legacyCores,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('scales below the cap', () => {
+ const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult;
+ const b = computeMults(meta(100), DEFAULT_CONFIG).racksMult;
+ expect(b / a).toBeCloseTo(1 + 0.05 * 100);
+ });
+
+ it('plateaus at the cap - this is what makes Singularity necessary', () => {
+ const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult;
+ const atCap = computeMults(meta(400), DEFAULT_CONFIG).racksMult;
+ const wayPast = computeMults(meta(1e9), DEFAULT_CONFIG).racksMult;
+ expect(atCap / a).toBeCloseTo(1 + 0.05 * 400);
+ expect(wayPast).toBeCloseTo(atCap);
+ });
+});
+
+describe('v1.12 level bonus is capped', () => {
+ const meta = (level) => ({
+ upgrades: {}, shardUpgrades: {}, level, legacyCores: 0,
+ coldStorage: { upgrades: {} },
+ });
+
+ it('scales below the cap', () => {
+ expect(computeEffects(meta(50), DEFAULT_CONFIG).levelBonusMult).toBeCloseTo(1 + 0.02 * 50);
+ });
+
+ it('stops scaling at the cap', () => {
+ const atCap = computeEffects(meta(200), DEFAULT_CONFIG).levelBonusMult;
+ const wayPast = computeEffects(meta(5000), DEFAULT_CONFIG).levelBonusMult;
+ expect(atCap).toBeCloseTo(1 + 0.02 * 200);
+ expect(wayPast).toBeCloseTo(atCap);
});
});
diff --git a/tests/outages.test.js b/tests/outages.test.js
index 9e67c19..0256a33 100644
--- a/tests/outages.test.js
+++ b/tests/outages.test.js
@@ -4,7 +4,7 @@ import {
hazardFrom, scheduleNextHazard, fireDueHazards, hazardRatePerHour, riskOn,
HAZARD_KINDS, MAX_HAZARDS_PER_EVALUATION,
SUPPLY_IDS, SUPPLY_FOR_KIND, supplyPrice, cureCost,
- scheduleGridMaintenance, activateDueMaintenance,
+ scheduleGridMaintenance, activateDueMaintenance, overheatOutage,
} from '../shared/outages.js';
import { DEFAULT_CONFIG } from '../shared/configSchema.js';
import { initialState } from '../shared/state.js';
@@ -172,11 +172,12 @@ describe('hazard derivation', () => {
if (h) seen[h.kind] = h;
}
expect(seen.ransomware.scope).toEqual({ lane: '*' });
- expect(seen.ransomware.factor).toBe(0.5);
+ expect(seen.ransomware.factor).toBe(0.35); // v1.12: softer, but twice as frequent
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);
+ // v1.12: the TOP owned tier, not a derived-random one. Still necessarily an
+ // OWNED tier - `stocked()` owns 0 and 3, so the victim is 3.
+ expect(seen.driveFailure.scope.index).toBe(3);
for (const h of Object.values(seen)) expect(h.source).toBe('hazard');
});
@@ -249,8 +250,8 @@ describe('hazard scheduling and firing', () => {
});
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);
+ // v1.12 band 2h-4h -> mean 3h -> 1/3 per hour (was 4h-8h -> 1/6)
+ expect(hazardRatePerHour(DEFAULT_CONFIG)).toBeCloseTo(1 / 3, 6);
});
});
@@ -384,3 +385,56 @@ describe('riskOn ANDs the master switch first', () => {
expect(riskOn(cfg, 'hazardsEnabled')).toBe(false);
});
});
+
+
+function stateWithTiers(indices) {
+ const s = initialState();
+ for (const i of indices) s.run.tiers[i].owned = 5;
+ return s;
+}
+
+describe('v1.12 hazards target the top owned tier', () => {
+ const driveOnly = () => {
+ const c = structuredClone(DEFAULT_CONFIG);
+ c.risk.ransomwareEnabled = false;
+ c.risk.ispOutageEnabled = false;
+ return c;
+ };
+
+ it('drive failure always picks the highest owned tier', () => {
+ const c = driveOnly();
+ const s = stateWithTiers([0, 3, 7]);
+ for (const at of [1e12, 1e12 + 137, 1e12 + 9999]) {
+ const h = hazardFrom(at, c, s);
+ expect(h.kind).toBe('driveFailure');
+ expect(h.scope).toEqual({ lane: 'tiers', index: 7 });
+ }
+ });
+
+ it('the switch restores random targeting, and the two paths really differ', () => {
+ const c = driveOnly();
+ c.risk.driveFailureTargetsTopTier = false;
+ const s = stateWithTiers([0, 3, 7]);
+ const times = [1e12, 2e12, 3e12, 4e12, 5e12, 6e12, 7e12, 8e12];
+ const picks = new Set(times.map((at) => hazardFrom(at, c, s).scope.index));
+ expect([...picks].every((i) => [0, 3, 7].includes(i))).toBe(true);
+ // The derived pick must actually vary, otherwise the top-tier assertion
+ // above would pass for the wrong reason.
+ expect(picks.size).toBeGreaterThan(1);
+
+ const top = driveOnly();
+ expect(new Set(times.map((at) => hazardFrom(at, top, s).scope.index))).toEqual(new Set([7]));
+ });
+
+ it('overheat downs the top owned tier', () => {
+ const s = stateWithTiers([0, 2, 9]);
+ const o = overheatOutage(s, DEFAULT_CONFIG, 1e12);
+ expect(o.scope).toEqual({ lane: 'tiers', index: 9 });
+ expect(o.factor).toBe(0);
+ expect(o.endAt - o.startAt).toBe(DEFAULT_CONFIG.risk.overheatOutageMs);
+ });
+
+ it('overheat with no owned tier still returns null', () => {
+ expect(overheatOutage(initialState(), DEFAULT_CONFIG, 1e12)).toBeNull();
+ });
+});
diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js
index 114e130..091d445 100644
--- a/tests/reducer.economy.test.js
+++ b/tests/reducer.economy.test.js
@@ -45,11 +45,11 @@ describe('reducer: unknown action', () => {
describe('reducer: buy (tiers)', () => {
it('buy 1 tier deducts exact cost', () => {
- const s = initialState(); // credits: 10, tier0 costs 4
+ const s = initialState(); // credits: 10, tier0 costs 5 (v1.12)
const { state: s2, result } = applyAction(s, { type: 'buy', lane: 'tiers', index: 0, mode: 1 }, DEFAULT_CONFIG, NOW);
expect(result.ok).toBe(true);
expect(s2.run.tiers[0].owned).toBe(1);
- expect(s2.run.credits).toBeCloseTo(6);
+ expect(s2.run.credits).toBeCloseTo(5);
expect(s.run.credits).toBe(10); // input not mutated
});
it('buy rejects when unaffordable', () => {
@@ -316,7 +316,7 @@ describe('reducer: vent', () => {
s.run.heat = 900;
const { state: s2, result } = applyAction(s, { type: 'vent' }, DEFAULT_CONFIG, NOW);
expect(result.ok).toBe(true);
- expect(s2.run.heat).toBe(400);
+ expect(s2.run.heat).toBe(200); // v1.12: 35% of 2000 = 700
expect(s2.server.lastVentAt).toBe(NOW);
});
it('floors heat at 0', () => {
@@ -329,24 +329,25 @@ describe('reducer: vent', () => {
const s = initialState();
s.run.heat = 900;
const a = applyAction(s, { type: 'vent' }, DEFAULT_CONFIG, NOW);
- expect(a.state.run.heat).toBe(400);
+ expect(a.state.run.heat).toBe(200);
+ // v1.12: the cooldown is 15000ms, so 1s later is still locked out
const b = applyAction(a.state, { type: 'vent' }, DEFAULT_CONFIG, NOW + 1000);
expect(b.result.error).toBe('cooldown_active');
s.run.heatCooldownUntil = NOW + 5000;
expect(applyAction(s, { type: 'vent' }, DEFAULT_CONFIG, NOW).result.error).toBe('cooldown_active');
});
- // v1.6: the two cases above are unchanged from v1.5 on purpose - 25% of the
- // default 2000 capacity is exactly the 500 flat amount it replaced, so the
- // unit change is balance-neutral at stock settings. What follows is what
- // actually changed.
+ // v1.6 introduced percentage venting; v1.12 retuned it to 35% per 15s so that
+ // manual venting can no longer trivially outrun any Overclock fleet (it used
+ // to supply 200 heat/s against a maxed fleet's ~69). What follows is the
+ // capacity-scaling behaviour, which is unchanged in kind.
it('scales with a raised heat capacity', () => {
const cfg = { ...DEFAULT_CONFIG, heat: { ...DEFAULT_CONFIG.heat, capacity: 4000 } };
const s = initialState();
s.run.heat = 3000;
const { state: s2 } = applyAction(s, { type: 'vent' }, cfg, NOW);
- // 25% of 4000 = 1000, where the old flat 500 would have been diluted
- expect(s2.run.heat).toBe(2000);
+ // 35% of 4000 = 1400, where a flat amount would have been diluted
+ expect(s2.run.heat).toBe(1600);
});
it('includes the Cold Storage heatCapacityBonus in the capacity it vents against', () => {
@@ -355,8 +356,8 @@ describe('reducer: vent', () => {
expect(computeColdStorageEffects(s.meta, DEFAULT_CONFIG).heatCapacityBonus).toBe(400);
s.run.heat = 1000;
const { state: s2 } = applyAction(s, { type: 'vent' }, DEFAULT_CONFIG, NOW);
- // 25% of (2000 + 400) = 600
- expect(s2.run.heat).toBe(400);
+ // 35% of (2000 + 400) = 840
+ expect(s2.run.heat).toBe(160);
});
it('never goes negative even at a 100% vent', () => {
@@ -399,7 +400,7 @@ describe('buySupply (v1.11)', () => {
it('supplies survive a Migrate', () => {
const s = initialState();
s.meta.supplies.spareDrives = 3;
- s.run.lifetimeRun = 1e12;
+ s.run.lifetimeRun = 4e12; // v1.12: must clear prestige.migrateDivisor (2e12)
const { state: s1, result } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, 1000);
expect(result.ok).toBe(true);
expect(s1.meta.supplies.spareDrives).toBe(3);
diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js
index bc57c7a..3ebc720 100644
--- a/tests/reducer.meta.test.js
+++ b/tests/reducer.meta.test.js
@@ -14,7 +14,7 @@ describe('reducer: migrate', () => {
it('happy path: fresh run with deepcache/bootstrap start credits, +gain+echo cores, stats.migrates+1', () => {
const s = initialState();
- s.run.lifetimeRun = 4e6; // gain = floor(sqrt(4)) = 2
+ s.run.lifetimeRun = 4e12; // v1.12: gain = floor((4e12 / 2e12) ** 1.0) = 2
s.run.tiers[0].owned = 5;
s.run.credits = 999;
const { state: s2, result } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW);
@@ -35,15 +35,23 @@ describe('reducer: migrate', () => {
expect(s2.meta.stats.lifetimeFlopsAllTime).toBe(12345);
});
- it('applies deepCacheBonus and bootstrapMult to start credits, and echoCoresBonus to cores gained', () => {
+ it('applies deepCacheBonus and bootstrapMult to start credits, and echoCores as a share of gain', () => {
const s = initialState();
- s.run.lifetimeRun = 4e6;
- s.meta.upgrades.deepcache = 2; // +10 each => +20
- s.meta.shardUpgrades.bootstrap = 1; // x10
- s.meta.shardUpgrades.echocores = 3; // +3 cores
+ s.run.lifetimeRun = 4e13; // migrateGain = floor((4e13/2e12)^1) = 20
+ s.meta.upgrades.deepcache = 2; // +10 each => +20
+ s.meta.shardUpgrades.bootstrap = 1; // v1.12: x3, not x10
+ s.meta.shardUpgrades.echocores = 3; // v1.12: +5% of gain per level => +15% of 20 = 3
+ const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW);
+ expect(s2.run.credits).toBe((10 + 20) * 3);
+ expect(s2.meta.legacyCores).toBe(20 + 3);
+ });
+
+ it('echoCores cannot be farmed by cheap repeat Migrates', () => {
+ const s = initialState();
+ s.run.lifetimeRun = 2e12; // gain = 1
+ s.meta.shardUpgrades.echocores = 10; // 10 levels => +50% of gain => floor(0.5) = 0
const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW);
- expect(s2.run.credits).toBe((10 + 20) * 10);
- expect(s2.meta.legacyCores).toBe(2 + 3);
+ expect(s2.meta.legacyCores).toBe(1);
});
});
@@ -57,17 +65,24 @@ describe('reducer: singularity', () => {
it('happy path: resets run + legacyCores, grants shards, bumps stats.singularities', () => {
const s = initialState();
- s.meta.legacyCores = 50; // floor(sqrt(50)) = 7
+ s.meta.legacyCores = 400; // v1.12: floor(400 * 0.4) = 160
s.run.tiers[0].owned = 3;
s.meta.wafers = 42; // untouched
const { state: s2, result } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW);
expect(result.ok).toBe(true);
expect(s2.meta.legacyCores).toBe(0);
- expect(s2.meta.singularityShards).toBe(7);
+ expect(s2.meta.singularityShards).toBe(160);
expect(s2.meta.stats.singularities).toBe(1);
expect(s2.run.tiers[0].owned).toBe(0);
expect(s2.meta.wafers).toBe(42);
});
+
+ it('yield is linear in cores, so a capped core pool still funds the tree', () => {
+ const s = initialState();
+ s.meta.legacyCores = 800;
+ const { state: s2 } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW);
+ expect(s2.meta.singularityShards).toBe(320); // 2x the cores => 2x the shards
+ });
});
describe('reducer: buyUpgrade', () => {
@@ -229,9 +244,9 @@ describe('reducer: claimAnomaly', () => {
expect(result.reward.amount).toBeCloseTo(20);
expect(s2.run.credits).toBeCloseTo(10 + 20);
- // scheduleAnomaly with rng=0.1: next = now + 70000 + 0.1*(150000-70000) = now + 78000
- expect(s2.server.nextAnomalyAt).toBeCloseTo(NOW + 78000);
- expect(s2.server.anomalyExpiresAt).toBeCloseTo(NOW + 78000 + 15000);
+ // v1.12: next = now + 420000 + 0.1*(900000-420000) = now + 468000
+ expect(s2.server.nextAnomalyAt).toBeCloseTo(NOW + 468000);
+ expect(s2.server.anomalyExpiresAt).toBeCloseTo(NOW + 468000 + 30000);
const { result: result2 } = applyAction(s2, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1);
expect(result2).toEqual({ ok: false, error: 'cooldown_active' });
@@ -242,11 +257,23 @@ describe('reducer: claimAnomaly', () => {
const { state: s2, result } = applyAction(s, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9);
expect(result.ok).toBe(true);
expect(result.reward.kind).toBe('boost');
- // mult = [2,3,4][floor(0.9*3)] = [2,3,4][2] = 4
- expect(result.reward.mult).toBe(4);
- expect(s2.server.boost).toEqual({ mult: 4, until: result.reward.until });
- // duration = (45 + 0.9*30) * eventRewardMult(1) = 72s
- expect(s2.server.boost.until).toBeCloseTo(NOW + 72 * 1000);
+ // v1.12: mult = 1.5 + 0.9*(3.0-1.5) = 2.85, a continuous range
+ expect(result.reward.mult).toBeCloseTo(2.85);
+ // duration = 45000 + 0.9*(75000-45000) = 72000ms
+ expect(s2.server.boost.until).toBeCloseTo(NOW + 72000);
+ });
+
+ it('Signal Boost scales the PAYOUT but never the boost duration', () => {
+ const withSignal = openState();
+ withSignal.meta.upgrades.signal = 10; // eventRewardMult = 3
+ const { state: sBoost } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9);
+ // identical duration to the un-upgraded save above - this is the whole fix.
+ // Before v1.12 this was 216000ms, longer than the respawn interval, which
+ // made a 2-4x global multiplier permanently active.
+ expect(sBoost.server.boost.until).toBeCloseTo(NOW + 72000);
+
+ const { result: credits } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1);
+ expect(credits.reward.amount).toBeCloseTo(60); // 20 * 3 - the payout DOES scale
});
});
@@ -306,11 +333,11 @@ describe('bestLegacyCores', () => {
// The test that fails if the singularity() call site is ever removed as
// "redundant with evaluate()". /api/actions applies batches.
//
- // migrateGain = floor(sqrt(lifetimeRun / 1e6) * legacyGainMult), so 1e8
- // grants 10 cores at the default multiplier - comfortably above the
- // `shardsGained > 0` floor singularity() requires.
+ // v1.12: migrateGain = floor((lifetimeRun / 2e12) ** 1.0 * legacyGainMult),
+ // so 2e13 grants 10 cores at the default multiplier - comfortably above the
+ // `shardsGained > 0` floor singularity() requires (10 * 0.4 = 4 shards).
let s = initialState();
- s.run.lifetimeRun = 1e8;
+ s.run.lifetimeRun = 2e13;
s = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW).state;
const granted = s.meta.legacyCores;
expect(granted).toBe(10);
@@ -325,7 +352,8 @@ describe('scheduleAnomaly', () => {
it('mutates the passed server object with next/expires derived from config + rng', () => {
const server = { nextAnomalyAt: 0, anomalyExpiresAt: 0, boost: null, lastVentAt: 0, gameCooldowns: {} };
scheduleAnomaly(server, DEFAULT_CONFIG, NOW, () => 0.5);
- expect(server.nextAnomalyAt).toBe(NOW + 70000 + 0.5 * (150000 - 70000));
- expect(server.anomalyExpiresAt).toBe(server.nextAnomalyAt + 15000);
+ // v1.12 cadence: 420000-900000ms, 30s catch window
+ expect(server.nextAnomalyAt).toBe(NOW + 420000 + 0.5 * (900000 - 420000));
+ expect(server.anomalyExpiresAt).toBe(server.nextAnomalyAt + 30000);
});
});
diff --git a/tests/state.test.js b/tests/state.test.js
index 43a298e..61aca01 100644
--- a/tests/state.test.js
+++ b/tests/state.test.js
@@ -297,7 +297,9 @@ describe('the Overclock rework (v1.11)', () => {
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);
+ // v1.12: the signal now names the downed tier (tier 0 is the only one
+ // owned here). Still truthy, so client `if (overheated)` checks hold.
+ expect(s2.server.overheated).toEqual({ tierIndex: 0 });
expect(s2.run.heat).toBe(0);
expect(s2.run.heatCooldownUntil).toBeNull();
const o = s2.server.outages.find((x) => x.source === 'overheat');
@@ -406,3 +408,17 @@ describe('the kill switch and decision 1 (v1.11)', () => {
}
});
});
+
+describe('v1.12 overheat reports which tier went dark', () => {
+ it('carries the downed tier index on the one-shot signal', () => {
+ const s = initialState();
+ s.run.tiers[0].owned = 10;
+ s.run.tiers[4].owned = 3;
+ s.run.overclock[0].owned = 500; // enough heat to cross the cap
+ s.run.heat = DEFAULT_CONFIG.heat.capacity - 1;
+ const now = Date.now();
+ const { state: s2 } = evaluate(s, DEFAULT_CONFIG, now - 5000, now);
+ expect(s2.server.overheated).toEqual({ tierIndex: 4 });
+ expect(s2.server.outages.some((o) => o.kind === 'overheat' && o.scope.index === 4)).toBe(true);
+ });
+});
diff --git a/tools/ablate.mjs b/tools/ablate.mjs
new file mode 100644
index 0000000..f523574
--- /dev/null
+++ b/tools/ablate.mjs
@@ -0,0 +1,163 @@
+// Ablation: continuous play (100% online), layering one subsystem at a time,
+// reporting time-to-unlock for every rack tier. Attribution by subtraction.
+
+import { initialState, evaluate } from '../shared/state.js';
+import { applyAction, scheduleAnomaly } from '../shared/reducer.js';
+import { DEFAULT_CONFIG } from '../shared/configSchema.js';
+import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, UPGRADE_DEFS } from '../shared/gameData.js';
+import { costAt, computeMults, tierRate, fmt, computeEffects } from '../shared/gameRules.js';
+import { goalCtx, GOAL_DEFS, REPEATABLE_DEFS } from '../shared/goals.js';
+import { scheduleNextHazard, scheduleGridMaintenance } from '../shared/outages.js';
+import { rolloverContracts } from '../shared/contracts.js';
+
+const HOURS = Number(process.env.HOURS || 48);
+const TICK = 5000;
+const SLOW_EVERY = 12; // run goal/upgrade/cold housekeeping once a minute
+
+function run({ anomaly = false, goals = false, cold = false, risk = false, label }) {
+ const config = structuredClone(DEFAULT_CONFIG);
+ config.risk.enabled = risk;
+
+ let state = initialState();
+ let t = Date.now();
+ const T0 = t;
+ let lastEval = t;
+ let seed = 999;
+ const rng = () => {
+ seed = (seed + 0x6d2b79f5) >>> 0;
+ let x = seed;
+ x = Math.imul(x ^ (x >>> 15), x | 1);
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
+ return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
+ };
+ scheduleAnomaly(state.server, config, t, rng);
+
+ const unlock = {};
+ let anomalyClaims = 0;
+ let boostSec = 0;
+ let overheats = 0;
+ let hazards = 0;
+ let onlineSec = 0;
+
+ const act = (a) => {
+ const r = applyAction(state, a, config, t, rng);
+ state = r.state;
+ return r.result;
+ };
+
+ const bestBuy = () => {
+ const { racksMult, gridMult, overclockMult, thresholds } = computeMults(state.meta, config, 1);
+ let best = null;
+ const consider = (lane, i, def, owned, mult) => {
+ const cost = costAt(def, owned);
+ if (cost > state.run.credits) return;
+ const gain = tierRate(owned + 1, def.baseProd, mult, thresholds)
+ - tierRate(owned, def.baseProd, mult, thresholds);
+ if (gain <= 0) return;
+ const pb = cost / gain;
+ if (!best || pb < best.payback) best = { lane, index: i, payback: pb };
+ };
+ state.run.tiers.forEach((ts, i) => {
+ if (i > 0 && state.run.tiers[i - 1].owned < 1) return;
+ consider('tiers', i, TIER_DEFS[i], ts.owned, racksMult);
+ });
+ state.run.grid.forEach((g, i) => consider('grid', i, GRID_DEFS[i], g.owned, gridMult));
+ state.run.overclock.forEach((o, i) => consider('overclock', i, OVERCLOCK_DEFS[i], o.owned, overclockMult));
+ return best;
+ };
+
+ const totalTicks = Math.floor(HOURS * 3600 / (TICK / 1000));
+ for (let s = 0; s < totalTicks; s++) {
+ t += TICK;
+ const r = evaluate(state, config, lastEval, t, rng);
+ state = r.state;
+ lastEval = t;
+ onlineSec += TICK / 1000;
+ if (state.server.overheated) overheats++;
+ if (state.server.outageNotices) hazards += state.server.outageNotices.length;
+ if (state.server.boost && t < state.server.boost.until) boostSec += TICK / 1000;
+
+ // server load-path scheduling
+ if (state.server.nextAnomalyAt === 0 ||
+ (t > state.server.anomalyExpiresAt && state.server.nextAnomalyAt <= t)) {
+ scheduleAnomaly(state.server, config, t, rng);
+ }
+ if (!(state.server.nextHazardAt > 0)) scheduleNextHazard(state.server, config, t, rng);
+ if (!state.server.gridMaintenance) scheduleGridMaintenance(state.server, config, t, rng);
+ if ((goals || cold) && (s % SLOW_EVERY) === 0) rolloverContracts(state, config, t);
+
+ act({ type: 'collectAll' });
+ const eff = computeEffects(state.meta, config);
+ for (let i = 0; i < TIER_DEFS.length; i++) {
+ const ts = state.run.tiers[i];
+ if (ts.owned >= 1 && !ts.manager) act({ type: 'hireManager', index: i });
+ }
+ if (anomaly && state.server.nextAnomalyAt <= t && t <= state.server.anomalyExpiresAt) {
+ if (act({ type: 'claimAnomaly' }).ok) anomalyClaims++;
+ }
+ if (state.run.heat > config.heat.capacity * 0.6) act({ type: 'vent' });
+
+ for (let k = 0; k < 300; k++) {
+ const b = bestBuy();
+ if (!b) break;
+ if (!act({ type: 'buy', lane: b.lane, index: b.index, mode: 1 }).ok) break;
+ if (b.lane === 'tiers' && state.run.tiers[b.index].owned === 1 && unlock[b.index] === undefined) {
+ unlock[b.index] = (s * TICK / 1000) / 60;
+ }
+ }
+
+ const slow = (s % SLOW_EVERY) === 0;
+ if (goals && slow) {
+ for (const g of GOAL_DEFS) if (!state.meta.goalsCompleted[g.id]) act({ type: 'claimGoal', id: g.id });
+ for (const rp of REPEATABLE_DEFS) for (let k = 0; k < 30; k++) if (!act({ type: 'claimRepeatable', id: rp.id }).ok) break;
+ for (let k = 0; k < 40; k++) {
+ const aff = UPGRADE_DEFS
+ .map((u) => ({ u, lvl: state.meta.upgrades[u.id] || 0 }))
+ .filter(({ u, lvl }) => lvl < config.upgrades.maxLevels[u.id])
+ .map(({ u, lvl }) => ({ id: u.id, cost: Math.ceil(u.baseCost * Math.pow(u.costMult, lvl)) }))
+ .filter((x) => x.cost <= state.meta.wafers)
+ .sort((a, b) => a.cost - b.cost);
+ if (!aff.length) break;
+ if (!act({ type: 'buyUpgrade', id: aff[0].id }).ok) break;
+ }
+ for (let i = 0; i < 3; i++) act({ type: 'claimContract', index: i });
+ act({ type: 'claimStreak' });
+ }
+ if (cold && slow) {
+ act({ type: 'claimAllBlocks' });
+ if (state.meta.coldStorage.blocksClaimed.every(Boolean)) act({ type: 'resetTrack' });
+ }
+ }
+
+ const out = goalCtx(state, config, t).totalOutputPerSec;
+ return { label, unlock, out, anomalyClaims, boostSec, overheats, hazards, onlineSec,
+ lifetime: state.meta.stats.lifetimeFlopsAllTime };
+}
+
+const scenarios = [
+ { label: 'core only', anomaly: false, goals: false, cold: false, risk: false },
+ { label: '+ anomalies only', anomaly: true, goals: false, cold: false, risk: false },
+ { label: '+ goals only', anomaly: false, goals: true, cold: false, risk: false },
+ { label: 'ALL, risk OFF', anomaly: true, goals: true, cold: true, risk: false },
+ { label: 'ALL, risk ON=shipped', anomaly: true, goals: true, cold: true, risk: true },
+];
+
+const results = scenarios.map(run);
+
+console.log(`\n=== time to unlock each rack tier, CONTINUOUS play, ${HOURS}h budget (minutes) ===\n`);
+const hdr = 'tier ' + results.map((r) => r.label.padStart(20)).join('');
+console.log(hdr);
+console.log('-'.repeat(hdr.length));
+for (let i = 0; i < TIER_DEFS.length; i++) {
+ const row = results.map((r) => {
+ const v = r.unlock[i];
+ return (v === undefined ? '—' : (v < 60 ? v.toFixed(1) + 'm' : (v / 60).toFixed(1) + 'h')).padStart(20);
+ }).join('');
+ console.log(String(i).padStart(4) + ' ' + row);
+}
+console.log('\n' + 'final out/s'.padEnd(6) + results.map((r) => fmt(r.out).padStart(20)).join(''));
+console.log('lifetime'.padEnd(6) + results.map((r) => fmt(r.lifetime).padStart(20)).join(''));
+console.log('anomalies'.padEnd(6) + results.map((r) => String(r.anomalyClaims).padStart(20)).join(''));
+console.log('boost%'.padEnd(6) + results.map((r) => (100 * r.boostSec / r.onlineSec).toFixed(1).padStart(20)).join(''));
+console.log('overheat'.padEnd(6) + results.map((r) => String(r.overheats).padStart(20)).join(''));
+console.log('hazards'.padEnd(6) + results.map((r) => String(r.hazards).padStart(20)).join(''));
diff --git a/tools/curve.mjs b/tools/curve.mjs
new file mode 100644
index 0000000..d13d9bc
--- /dev/null
+++ b/tools/curve.mjs
@@ -0,0 +1,119 @@
+// Pure core-loop curve: a fresh save, 1s resolution, NOTHING but buying.
+// No anomalies, no goals/wafers/upgrades, no cold storage, no risk.
+// This isolates how fast the rack ladder falls on its own economics.
+
+import { initialState, evaluate } from '../shared/state.js';
+import { applyAction } from '../shared/reducer.js';
+import { DEFAULT_CONFIG } from '../shared/configSchema.js';
+import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, GROWTH, MILESTONES } from '../shared/gameData.js';
+import { costAt, computeMults, tierRate, fmt, migrateGain } from '../shared/gameRules.js';
+import { goalCtx } from '../shared/goals.js';
+
+const config = structuredClone(DEFAULT_CONFIG);
+config.risk.enabled = false; // isolate: no outages
+const HOURS = Number(process.argv[2] || 24);
+
+let state = initialState();
+let t = Date.now();
+const T0 = t;
+let lastEval = t;
+const rng = () => 0.5;
+
+const firstOwned = {};
+function tick() {
+ t += 1000;
+ const r = evaluate(state, config, lastEval, t, rng);
+ state = r.state;
+ lastEval = t;
+}
+function act(a) {
+ const r = applyAction(state, a, config, t, rng);
+ state = r.state;
+ return r.result;
+}
+
+function bestBuy() {
+ const { racksMult, gridMult, overclockMult, thresholds } = computeMults(state.meta, config, 1);
+ let best = null;
+ const consider = (lane, i, def, owned, mult) => {
+ const cost = costAt(def, owned);
+ if (cost > state.run.credits) return;
+ const gain = tierRate(owned + 1, def.baseProd, mult, thresholds)
+ - tierRate(owned, def.baseProd, mult, thresholds);
+ if (gain <= 0) return;
+ const payback = cost / gain;
+ if (!best || payback < best.payback) best = { lane, index: i, payback };
+ };
+ state.run.tiers.forEach((ts, i) => {
+ if (i > 0 && state.run.tiers[i - 1].owned < 1) return;
+ consider('tiers', i, TIER_DEFS[i], ts.owned, racksMult);
+ });
+ state.run.grid.forEach((g, i) => consider('grid', i, GRID_DEFS[i], g.owned, gridMult));
+ state.run.overclock.forEach((o, i) => consider('overclock', i, OVERCLOCK_DEFS[i], o.owned, overclockMult));
+ return best;
+}
+
+console.log('\n=== core loop only: fresh save, no anomaly/goals/cold-storage/risk ===');
+const total = HOURS * 3600;
+for (let s = 0; s < total; s++) {
+ tick();
+ act({ type: 'collectAll' });
+ // managers as soon as affordable (pure idle income)
+ for (let i = 0; i < TIER_DEFS.length; i++) {
+ const ts = state.run.tiers[i];
+ if (ts.owned >= 1 && !ts.manager) act({ type: 'hireManager', index: i });
+ }
+ for (let k = 0; k < 200; k++) {
+ const b = bestBuy();
+ if (!b) break;
+ if (!act({ type: 'buy', lane: b.lane, index: b.index, mode: 1 }).ok) break;
+ if (b.lane === 'tiers' && state.run.tiers[b.index].owned === 1 && firstOwned[b.index] === undefined) {
+ firstOwned[b.index] = s;
+ console.log(` t=${(s / 60).toFixed(1).padStart(7)}min unlock tier ${String(b.index).padStart(2)} ${TIER_DEFS[b.index].name}`);
+ }
+ }
+ if (s % 3600 === 0 && s > 0) {
+ const out = goalCtx(state, config, t).totalOutputPerSec;
+ console.log(` --- ${s / 3600}h: output ${fmt(out)}/s lifetimeRun ${fmt(state.run.lifetimeRun)} ` +
+ `migrateGain ${migrateGain(state.run.lifetimeRun, 1)} cores owned=[${state.run.tiers.map((x) => x.owned).join(',')}]`);
+ }
+}
+
+const out = goalCtx(state, config, t).totalOutputPerSec;
+console.log(`\nafter ${HOURS}h: output ${fmt(out)}/s, lifetimeRun ${fmt(state.run.lifetimeRun)}`);
+console.log(`tiers owned: [${state.run.tiers.map((x) => x.owned).join(', ')}]`);
+console.log(`grid owned: [${state.run.grid.map((x) => x.owned).join(', ')}]`);
+console.log(`oc owned: [${state.run.overclock.map((x) => x.owned).join(', ')}]`);
+console.log(`first Migrate would grant ${migrateGain(state.run.lifetimeRun, 1)} Legacy Cores (+${(5 * migrateGain(state.run.lifetimeRun, 1)).toFixed(0)}% output)`);
+
+// --- static tables --------------------------------------------------------
+console.log('\n=== tier economics (base, no multipliers) ===');
+console.log('tier | baseCost | baseProd | cost/prod | costRatio | prodRatio | payback@1');
+TIER_DEFS.forEach((d, i) => {
+ const p = TIER_DEFS[i - 1];
+ console.log(
+ `${String(i).padStart(4)} | ${fmt(d.baseCost).padStart(8)} | ${fmt(d.baseProd).padStart(8)} | ` +
+ `${(d.baseCost / d.baseProd).toFixed(0).padStart(9)} | ` +
+ `${(p ? (d.baseCost / p.baseCost).toFixed(1) : '-').padStart(9)} | ` +
+ `${(p ? (d.baseProd / p.baseProd).toFixed(1) : '-').padStart(9)} | ` +
+ `${(d.baseCost / d.baseProd).toFixed(0).padStart(9)}s`);
+});
+
+console.log('\n=== marginal payback vs owned count (tier 0, mult=1) ===');
+console.log('owned | unit cost | marginal prod | payback');
+for (const n of [1, 5, 10, 24, 25, 30, 49, 50, 60, 99, 100, 150, 199, 200, 300, 499, 500, 700, 999, 1000]) {
+ const d = TIER_DEFS[0];
+ const cost = costAt(d, n - 1);
+ const gain = tierRate(n, d.baseProd, 1, MILESTONES) - tierRate(n - 1, d.baseProd, 1, MILESTONES);
+ console.log(`${String(n).padStart(5)} | ${fmt(cost).padStart(9)} | ${fmt(gain).padStart(13)} | ${fmt(cost / gain).padStart(8)}s`);
+}
+
+console.log('\n=== how much does a doubling of owned cost vs. what it pays? (GROWTH=' + GROWTH + ') ===');
+console.log('milestones at ' + MILESTONES.join(', ') + ' → x2 each, x' + Math.pow(2, MILESTONES.length) + ' at the top');
+for (const [a, b] of [[25, 50], [50, 100], [100, 200], [200, 500], [500, 1000]]) {
+ const costRatio = Math.pow(GROWTH, b - a);
+ const outA = a * (2 ** MILESTONES.filter((m) => a >= m).length);
+ const outB = b * (2 ** MILESTONES.filter((m) => b >= m).length);
+ console.log(` ${a}→${b}: next-unit cost x${costRatio.toFixed(1)}, total lane output x${(outB / outA).toFixed(1)}` +
+ ` → efficiency ${(outB / outA / costRatio).toFixed(3)}x`);
+}
diff --git a/tools/mksandbox.py b/tools/mksandbox.py
new file mode 100644
index 0000000..1ed1649
--- /dev/null
+++ b/tools/mksandbox.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Generate a candidate `shared/` variant with the v1.12 rebalance applied.
+
+ python3 tools/mksandbox.py RATIO BASE MIGRATE_EXP CORE_CAP [MIGRATE_DIVISOR]
+
+Every edit here corresponds to a numbered section of
+docs/superpowers/specs/2026-08-09-economy-rebalance-design.md.
+"""
+import json, math, os, re, shutil, sys
+
+out, RATIO, BASE, MEXP, CAP = (sys.argv[1], float(sys.argv[2]), float(sys.argv[3]),
+ float(sys.argv[4]), int(sys.argv[5]))
+MDIV = float(sys.argv[6]) if len(sys.argv) > 6 else 1e9
+GROWTH = float(sys.argv[7]) if len(sys.argv) > 7 else None
+SPC = float(sys.argv[8]) if len(sys.argv) > 8 else None # shards per core
+ENGMAX = int(sys.argv[9]) if len(sys.argv) > 9 else None # Singularity Engine max level
+src = os.path.join(os.path.dirname(__file__), '..', 'shared')
+if os.path.exists(out):
+ shutil.rmtree(out)
+shutil.copytree(src, out)
+
+def edit(fname, subs, required=True):
+ p = os.path.join(out, fname)
+ s = open(p).read()
+ for old, new in subs:
+ if old not in s:
+ if required:
+ raise SystemExit(f'!! {fname}: pattern not found: {old[:90]}')
+ continue
+ s = s.replace(old, new, 1)
+ open(p, 'w').write(s)
+
+# ---- §4.1 tier cost curve: hold baseProd, re-derive baseCost ---------------
+p = os.path.join(out, 'gameData.js')
+s = open(p).read()
+prods, lines, i = [], s.split('\n'), 0
+for ln in lines:
+ m = re.match(r"\s*\{ id: (\d+), name: '[^']*', baseCost: (\d+), baseProd: ([0-9.]+),", ln)
+ if m and 'managerCost' in ln:
+ prods.append((int(m.group(1)), float(m.group(3))))
+newcost = {}
+for idx, prod in prods:
+ c = prod * BASE * (RATIO ** idx)
+ mag = 10 ** (math.floor(math.log10(c)) - 1)
+ newcost[idx] = int(round(c / mag) * mag)
+outl = []
+for ln in lines:
+ m = re.match(r"\s*\{ id: (\d+), name: '[^']*', baseCost: (\d+), baseProd: ", ln)
+ if m and 'managerCost' in ln:
+ ln = re.sub(r'baseCost: \d+', 'baseCost: %d' % newcost[int(m.group(1))], ln, count=1)
+ outl.append(ln)
+open(p, 'w').write('\n'.join(outl))
+
+# ---- within-tier depth: GROWTH (optional 7th arg) --------------------------
+if GROWTH is not None:
+ edit('gameData.js', [("export const GROWTH = 1.14;", "export const GROWTH = %s;" % GROWTH)])
+
+# ---- §4.3(c) re-price the shard tree --------------------------------------
+edit('gameData.js', [
+ ("{ id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 2.6, maxLevel: 8 }",
+ "{ id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 1.9, maxLevel: 8 }"),
+ ("{ id: 'echocores', name: 'Echo Cores', desc: 'Instantly regain 1 free Legacy Core per level after every Migrate', baseCost: 4, costMult: 2.3, maxLevel: 10 }",
+ "{ id: 'echocores', name: 'Echo Cores', desc: 'Instantly regain +5% of Migrate gain per level', baseCost: 4, costMult: 1.8, maxLevel: 10 }"),
+])
+
+# ---- §4.2/§4.4/§4.5/§4.7 config + new §4.8 tunables ------------------------
+edit('configSchema.js', [
+ ("heat: { capacity: 2000, ventPercent: 25, ventCooldownMs: 2500, overheatCooldownMs: 10000, overheatPopupMs: 15000 },",
+ "heat: { capacity: 2000, ventPercent: 35, ventCooldownMs: 15000, overheatCooldownMs: 10000, overheatPopupMs: 15000,\n autoVentPerLevel: 4.0, thermalPerLevel: 0.05, heatsinkPerLevel: 0.15, discountFloor: 0.40 },"),
+ ("anomaly: { windowMs: 15000, minDelayMs: 70000, maxDelayMs: 150000 },",
+ "anomaly: { windowMs: 30000, minDelayMs: 420000, maxDelayMs: 900000,\n creditsSecondsMin: 30, creditsSecondsMax: 90,\n boostDurationMinMs: 45000, boostDurationMaxMs: 75000,\n boostMultMin: 1.5, boostMultMax: 3.0 },"),
+ (" production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1 },",
+ " production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1,\n levelBonusPerLevel: 0.02, levelBonusMaxLevel: 200 },\n prestige: { migrateDivisor: %.6g, migrateExponent: %s, corePercentPerCore: 0.05,\n coreBonusCap: %d, echoPercentPerLevel: 0.05, shardsPerCore: %s }," % (MDIV, MEXP, CAP, SPC if SPC else 0.4)),
+ (" winCooldownMs: 30000,", " winCooldownMs: 300000,"),
+ ("rush: { durationSec: 10, waferDivisor: 4, maxTapsPerSec: 15 },",
+ "rush: { durationSec: 10, waferDivisor: 6, maxTapsPerSec: 15 },"),
+ ("debug: { durationSec: 15, spawnMinMs: 400, spawnMaxMs: 900, maxLit: 3, waferDivisor: 2 },",
+ "debug: { durationSec: 15, spawnMinMs: 400, spawnMaxMs: 900, maxLit: 3, waferDivisor: 3 },"),
+ ("balance: { durationSec: 12, safeZoneMin: 35, safeZoneMax: 65, riskZoneWidth: 4,",
+ "balance: { durationSec: 12, waferPerPoint: 0.20, safeZoneMin: 35, safeZoneMax: 65, riskZoneWidth: 4,"),
+ ("hazardMinDelayMs: 14400000, // 4h", "hazardMinDelayMs: 7200000, // 2h"),
+ ("hazardMaxDelayMs: 28800000, // 8h", "hazardMaxDelayMs: 14400000, // 4h"),
+ ("ransomwareFactor: 0.5,", "ransomwareFactor: 0.35,"),
+ ("ransomwareDurationMs: 1800000, // 30m, all lanes at half", "ransomwareDurationMs: 2700000, // 45m"),
+ ("ispOutageDurationMs: 900000, // 15m, Grid dark", "ispOutageDurationMs: 2400000, // 40m"),
+ ("driveFailureDurationMs: 1200000, // 20m, one rack tier dark", "driveFailureDurationMs: 2700000, // 45m, top tier"),
+ ("antivirusPriceSeconds: 900,", "antivirusPriceSeconds: 500,"),
+ ("backupIspPriceSeconds: 600,", "backupIspPriceSeconds: 200,"),
+ ("spareDrivesPriceSeconds: 750,", "spareDrivesPriceSeconds: 250,"),
+ ("overheatOutageMs: 600000, // 10m of one rack tier offline",
+ "overheatOutageMs: 900000, // 15m of the top rack tier\n driveFailureTargetsTopTier: true,\n overheatTargetsTopTier: true,"),
+ # TUNABLES rows for the new paths (§4.8)
+ (" { path: 'risk.overclockBoostGain', label: 'Overclock boost gain', min: 0, max: 100, integer: false },",
+ """ { path: 'risk.overclockBoostGain', label: 'Overclock boost gain', min: 0, max: 100, integer: false },
+ { path: 'risk.driveFailureTargetsTopTier', label: 'Drive failure hits the top tier', type: 'boolean' },
+ { path: 'risk.overheatTargetsTopTier', label: 'Overheat hits the top tier', type: 'boolean' },
+ { path: 'heat.autoVentPerLevel', label: 'Auto-vent per level (heat/s)', min: 0, max: 100, integer: false },
+ { path: 'heat.thermalPerLevel', label: 'Thermal Regulators per level', min: 0, max: 1, integer: false },
+ { path: 'heat.heatsinkPerLevel', label: 'Heat Sink Mastery per level', min: 0, max: 1, integer: false },
+ { path: 'heat.discountFloor', label: 'Heat generation discount floor', min: 0, max: 1, integer: false },
+ { path: 'anomaly.creditsSecondsMin', label: 'Anomaly credits (min seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.creditsSecondsMax', label: 'Anomaly credits (max seconds of output)', min: 0, max: 3600, integer: false },
+ { path: 'anomaly.boostDurationMinMs', label: 'Anomaly boost duration min (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostDurationMaxMs', label: 'Anomaly boost duration max (ms)', min: 0, max: 3600000, integer: true },
+ { path: 'anomaly.boostMultMin', label: 'Anomaly boost multiplier min', min: 1, max: 100, integer: false },
+ { path: 'anomaly.boostMultMax', label: 'Anomaly boost multiplier max', min: 1, max: 100, integer: false },
+ { path: 'production.levelBonusPerLevel', label: 'Output bonus per account level', min: 0, max: 1, integer: false },
+ { path: 'production.levelBonusMaxLevel', label: 'Account level bonus cap (levels)', min: 1, max: 10000, integer: true },
+ { path: 'prestige.migrateDivisor', label: 'Migrate: lifetime divisor', min: 1, max: 1e18, integer: false },
+ { path: 'prestige.migrateExponent', label: 'Migrate: gain exponent', min: 0.05, max: 1, integer: false },
+ { path: 'prestige.corePercentPerCore', label: 'Output per Legacy Core', min: 0, max: 1, integer: false },
+ { path: 'prestige.coreBonusCap', label: 'Legacy Core bonus cap (cores)', min: 1, max: 1e9, integer: true },
+ { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of gain per level', min: 0, max: 1, integer: false },
+ { path: 'minigames.balance.waferPerPoint', label: 'Balance wafers per point', min: 0, max: 100, integer: false },"""),
+])
+
+# ---- §4.2/§4.3/§4.5 formula fixes in gameRules -----------------------------
+edit('gameRules.js', [
+ # §4.3(a) migrate gain
+ ("export function migrateGain(lifetimeRun, legacyGainMult) {\n return Math.floor(Math.sqrt(lifetimeRun / 1e6) * legacyGainMult);\n}",
+ "export function migrateGain(lifetimeRun, legacyGainMult, config) {\n"
+ " if (!(lifetimeRun > 0)) return 0;\n"
+ " const p = (config && config.prestige) || { migrateDivisor: %g, migrateExponent: %s };\n"
+ " return Math.floor(Math.pow(lifetimeRun / p.migrateDivisor, p.migrateExponent) * legacyGainMult);\n}" % (MDIV, MEXP)),
+ # §4.3(c) bootstrap
+ ("bootstrapMult: Math.pow(10, sv.bootstrap || 0),", "bootstrapMult: Math.pow(3, sv.bootstrap || 0),"),
+ # §4.5 heat, now config-driven
+ ("heatDiscount: Math.max(0.15, 1 - 0.08 * (lv.thermal || 0) - 0.25 * (sv.heatsink || 0)),",
+ "heatDiscount: Math.max(config.heat.discountFloor,\n 1 - config.heat.thermalPerLevel * (lv.thermal || 0) - config.heat.heatsinkPerLevel * (sv.heatsink || 0)),"),
+ ("autoVentPerSec: 0.5 * (lv.autovent || 0),", "autoVentPerSec: config.heat.autoVentPerLevel * (lv.autovent || 0),"),
+ # §4.8 capped level bonus
+ ("levelBonusMult: 1 + 0.02 * (meta.level || 0),",
+ "levelBonusMult: 1 + config.production.levelBonusPerLevel\n * Math.min(meta.level || 0, config.production.levelBonusMaxLevel),"),
+ # §4.3(b) THE CORE CAP - the untested fix
+ (" const base = (1 + (meta.legacyCores || 0) * 0.05) * eff.firmwareMult * eff.engineMult",
+ " const pr = config.prestige;\n"
+ " const coreMult = 1 + pr.corePercentPerCore * Math.min(meta.legacyCores || 0, pr.coreBonusCap);\n"
+ " const base = coreMult * eff.firmwareMult * eff.engineMult"),
+ # §4.7 balance minigame coefficient
+ ("if (game === 'balance') return Math.max(1, Math.floor(metric * 1.5 * lucky));",
+ "if (game === 'balance') return Math.max(1, Math.floor(metric * mg.balance.waferPerPoint * lucky));"),
+])
+
+# ---- §4.2 anomaly + §4.3(c) echo cores in the reducer ----------------------
+edit('reducer.js', [
+ (" const mult = [2, 3, 4][Math.floor(rng() * 3)];\n const duration = (45 + rng() * 30) * eff.eventRewardMult;",
+ " const ab = config.anomaly;\n"
+ " const mult = ab.boostMultMin + rng() * (ab.boostMultMax - ab.boostMultMin);\n"
+ " const duration = (ab.boostDurationMinMs + rng() * (ab.boostDurationMaxMs - ab.boostDurationMinMs)) / 1000;"),
+ (" const seconds = 30 + rng() * 60;",
+ " const seconds = config.anomaly.creditsSecondsMin\n + rng() * (config.anomaly.creditsSecondsMax - config.anomaly.creditsSecondsMin);"),
+ # migrateGain now takes config
+ (" const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult);",
+ " const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult, config);"),
+ # echo cores proportional, not flat
+ (" const echoBonus = eff.echoCoresBonus || 0;",
+ " const echoBonus = Math.floor(gain * config.prestige.echoPercentPerLevel * (eff.echoCoresBonus || 0));"),
+])
+
+# ---- §4.3(e) lengthen the shard tree's tail --------------------------------
+# A4 (tier 13 reachable) needs the Engine multiplier; A8 (tree not maxed in 45
+# days) needs a big denominator. With the shipped 8-level Engine the two are
+# mutually exclusive, so give Engine a longer tail: the early levels stay cheap
+# enough to power the late tiers, while the tail keeps the tree a long goal.
+if ENGMAX is not None:
+ edit('gameData.js', [("costMult: 1.9, maxLevel: 8 }", "costMult: 1.9, maxLevel: %d }" % ENGMAX)])
+ edit('configSchema.js', [("engine: 8,", "engine: %d," % ENGMAX)])
+
+# ---- §4.3(d) Singularity yield: linear in cores, not sqrt ------------------
+# With legacyCores hard-capped (§4.3b), floor(sqrt(cores)) yields ~22 shards
+# against a ~3.6k-shard tree, so the tree can never progress. Make the rate a
+# tunable instead.
+edit('reducer.js', [
+ ("function singularity(s) {\n const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0));",
+ "function singularity(s, action, config) {\n const shardsGained = Math.floor((s.meta.legacyCores || 0) * config.prestige.shardsPerCore);"),
+])
+edit('configSchema.js', [
+ (" { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of gain per level', min: 0, max: 1, integer: false },",
+ " { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of gain per level', min: 0, max: 1, integer: false },\n"
+ " { path: 'prestige.shardsPerCore', label: 'Singularity: shards per Legacy Core', min: 0, max: 10, integer: false },"),
+])
+
+# ---- §4.4 drive failure / overheat target the top owned tier ---------------
+edit('outages.js', [
+ (" scope = { lane: 'tiers', index: owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)] };",
+ " scope = { lane: 'tiers', index: config.risk.driveFailureTargetsTopTier\n ? owned[owned.length - 1]\n : owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)] };"),
+ (" const index = owned[Math.floor(unitAt(now, 2) * owned.length)];",
+ " const index = config.risk.overheatTargetsTopTier\n ? owned[owned.length - 1]\n : owned[Math.floor(unitAt(now, 2) * owned.length)];"),
+])
+
+print(f'{out}: RATIO={RATIO} BASE={BASE} mExp={MEXP} coreCap={CAP} mDiv={MDIV:g} growth={GROWTH} '
+ f'tier13={newcost[13]:.3g}')
diff --git a/tools/pace.mjs b/tools/pace.mjs
new file mode 100644
index 0000000..09b2e21
--- /dev/null
+++ b/tools/pace.mjs
@@ -0,0 +1,231 @@
+// Daily-player pacing harness. Point it at either shared/ or shared-proposed/
+// and it reports when each rack tier / Migrate / Singularity lands.
+//
+// SHARED=shared node tools/pace.mjs # shipped
+// SHARED=shared-proposed node tools/pace.mjs # proposal
+//
+// Player model: one 60-minute session per day, rest of the day offline.
+// That is the "daily player" the pacing targets are written against.
+
+const DIR = process.env.SHARED || 'shared';
+const DAYS = Number(process.env.DAYS || 45);
+const SESSION_MIN = Number(process.env.SESSION_MIN || 60);
+const TICKMS = 10000;
+
+const { initialState, evaluate } = await import(`../${DIR}/state.js`);
+const { applyAction, scheduleAnomaly } = await import(`../${DIR}/reducer.js`);
+const { DEFAULT_CONFIG } = await import(`../${DIR}/configSchema.js`);
+const { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, UPGRADE_DEFS, SINGULARITY_DEFS } = await import(`../${DIR}/gameData.js`);
+const { costAt, computeMults, tierRate, fmt, computeEffects, migrateGain } = await import(`../${DIR}/gameRules.js`);
+const { goalCtx, GOAL_DEFS, REPEATABLE_DEFS } = await import(`../${DIR}/goals.js`);
+const { scheduleNextHazard, scheduleGridMaintenance } = await import(`../${DIR}/outages.js`);
+const { rolloverContracts } = await import(`../${DIR}/contracts.js`);
+
+const config = structuredClone(DEFAULT_CONFIG);
+let seed = Number(process.env.SEED || 4242);
+const rng = () => {
+ seed = (seed + 0x6d2b79f5) >>> 0;
+ let x = seed;
+ x = Math.imul(x ^ (x >>> 15), x | 1);
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
+ return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
+};
+
+// DETERMINISM. The timeline must NOT start at Date.now(): the UTC day boundary
+// drives contract rollovers and streak claims, and initialState() stamps
+// coldStorage.trackStartedAt from the wall clock, so starting "now" made the
+// whole 45-day run depend on the time of day it was launched. Two runs of the
+// SAME sandbox could disagree on whether tiers 11-13 were reached at all.
+// Pin the epoch, and pin the cold-storage track to it.
+const T0_EPOCH = Date.UTC(2026, 0, 1, 0, 0, 0);
+
+let state = initialState();
+let t = T0_EPOCH;
+const T0 = t;
+state.meta.coldStorage.trackStartedAt = t;
+let lastEval = t;
+scheduleAnomaly(state.server, config, t, rng);
+
+const mark = {};
+const hoursNow = () => (t - T0) / 3600000;
+const daysNow = () => hoursNow() / 24;
+function note(k) { if (mark[k] === undefined) mark[k] = daysNow(); }
+
+let overheats = 0, hazards = 0, anomalies = 0, boostSec = 0, sessionSec = 0;
+
+function schedule() {
+ if (state.server.nextAnomalyAt === 0 ||
+ (t > state.server.anomalyExpiresAt && state.server.nextAnomalyAt <= t)) {
+ scheduleAnomaly(state.server, config, t, rng);
+ }
+ if (!(state.server.nextHazardAt > 0)) scheduleNextHazard(state.server, config, t, rng);
+ if (!state.server.gridMaintenance) scheduleGridMaintenance(state.server, config, t, rng);
+ rolloverContracts(state, config, t);
+}
+function step(ms) {
+ t += ms;
+ const r = evaluate(state, config, lastEval, t, rng);
+ state = r.state; lastEval = t;
+ if (state.server.overheated) overheats++;
+ if (state.server.outageNotices) hazards += state.server.outageNotices.length;
+ schedule();
+}
+function act(a) { const r = applyAction(state, a, config, t, rng); state = r.state; return r.result; }
+
+function bestBuy() {
+ const { racksMult, gridMult, overclockMult, thresholds } = computeMults(state.meta, config, 1);
+ let best = null;
+ const consider = (lane, i, def, owned, mult) => {
+ const cost = costAt(def, owned);
+ if (cost > state.run.credits) return;
+ const gain = tierRate(owned + 1, def.baseProd, mult, thresholds)
+ - tierRate(owned, def.baseProd, mult, thresholds);
+ if (gain <= 0) return;
+ const pb = cost / gain;
+ if (!best || pb < best.payback) best = { lane, index: i, payback: pb };
+ };
+ state.run.tiers.forEach((ts, i) => {
+ if (i > 0 && state.run.tiers[i - 1].owned < 1) return;
+ consider('tiers', i, TIER_DEFS[i], ts.owned, racksMult);
+ });
+ state.run.grid.forEach((g, i) => consider('grid', i, GRID_DEFS[i], g.owned, gridMult));
+ state.run.overclock.forEach((o, i) => consider('overclock', i, OVERCLOCK_DEFS[i], o.owned, overclockMult));
+ return best;
+}
+
+const SHARD_TREE_TOTAL = SINGULARITY_DEFS.reduce((sum, d) => {
+ let t = 0;
+ for (let l = 0; l < config.upgrades.maxLevels[d.id]; l++) t += Math.ceil(d.baseCost * Math.pow(d.costMult, l));
+ return sum + t;
+}, 0);
+function shardTreePct() {
+ let spent = 0;
+ for (const d of SINGULARITY_DEFS) {
+ const lvl = state.meta.shardUpgrades[d.id] || 0;
+ for (let l = 0; l < lvl; l++) spent += Math.ceil(d.baseCost * Math.pow(d.costMult, l));
+ }
+ return 100 * spent / SHARD_TREE_TOTAL;
+}
+
+const perDay = [];
+for (let d = 0; d < DAYS; d++) {
+ // offline until the session
+ step(86400000 - SESSION_MIN * 60000);
+ // session
+ const ticks = (SESSION_MIN * 60000) / TICKMS;
+ for (let k = 0; k < ticks; k++) {
+ step(TICKMS);
+ sessionSec += TICKMS / 1000;
+ if (state.server.boost && t < state.server.boost.until) boostSec += TICKMS / 1000;
+ if (state.server.nextAnomalyAt <= t && t <= state.server.anomalyExpiresAt) {
+ if (act({ type: 'claimAnomaly' }).ok) anomalies++;
+ }
+ if (state.run.heat > config.heat.capacity * 0.5) act({ type: 'vent' });
+ act({ type: 'collectAll' });
+ for (let i = 0; i < TIER_DEFS.length; i++) {
+ if (state.run.tiers[i].owned >= 1 && !state.run.tiers[i].manager) act({ type: 'hireManager', index: i });
+ }
+ for (let n = 0; n < 200; n++) {
+ const b = bestBuy();
+ if (!b) break;
+ if (!act({ type: 'buy', lane: b.lane, index: b.index, mode: 1 }).ok) break;
+ if (b.lane === 'tiers' && state.run.tiers[b.index].owned === 1) note(`tier${b.index}`);
+ }
+ if (k % 18 === 0) {
+ for (const g of GOAL_DEFS) if (!state.meta.goalsCompleted[g.id]) act({ type: 'claimGoal', id: g.id });
+ for (const rp of REPEATABLE_DEFS) for (let z = 0; z < 20; z++) if (!act({ type: 'claimRepeatable', id: rp.id }).ok) break;
+ for (let z = 0; z < 30; z++) {
+ const aff = UPGRADE_DEFS.map((u) => ({ u, lvl: state.meta.upgrades[u.id] || 0 }))
+ .filter(({ u, lvl }) => lvl < config.upgrades.maxLevels[u.id])
+ .map(({ u, lvl }) => ({ id: u.id, cost: Math.ceil(u.baseCost * Math.pow(u.costMult, lvl)) }))
+ .filter((x) => x.cost <= state.meta.wafers).sort((a, b) => a.cost - b.cost);
+ if (!aff.length) break;
+ if (!act({ type: 'buyUpgrade', id: aff[0].id }).ok) break;
+ }
+ for (let z = 0; z < 30; z++) {
+ const aff = SINGULARITY_DEFS.map((u) => ({ u, lvl: state.meta.shardUpgrades[u.id] || 0 }))
+ .filter(({ u, lvl }) => lvl < config.upgrades.maxLevels[u.id])
+ .map(({ u, lvl }) => ({ id: u.id, cost: Math.ceil(u.baseCost * Math.pow(u.costMult, lvl)) }))
+ .filter((x) => x.cost <= state.meta.singularityShards).sort((a, b) => a.cost - b.cost);
+ if (!aff.length) break;
+ if (!act({ type: 'buyShardUpgrade', id: aff[0].id }).ok) break;
+ }
+ act({ type: 'claimAllBlocks' });
+ if (state.meta.coldStorage.blocksClaimed.every(Boolean)) act({ type: 'resetTrack' });
+ if (!state.meta.coldStorage.job) act({ type: 'startJob', jobType: 'deep' });
+ act({ type: 'claimJob' });
+ act({ type: 'claimStreak' });
+ for (let i = 0; i < 3; i++) act({ type: 'claimContract', index: i });
+ }
+ }
+
+ // Prestige decisions, once per day at end of session.
+ //
+ // Cap-aware (spec §4.3b): below the Legacy Core cap, cores still buy output,
+ // so a player resets when the gain is a meaningful fraction of what they
+ // hold. At or above the cap, extra cores buy NOTHING - the only reason to
+ // keep migrating is to bank cores for a Singularity, which is precisely the
+ // gate the cap exists to create.
+ const eff = computeEffects(state.meta, config);
+ const NOPRESTIGE = process.env.NOPRESTIGE === '1';
+ const capNow = config.prestige ? config.prestige.coreBonusCap : Infinity;
+ const pushPhase = shardTreePct() >= 25 && state.meta.legacyCores >= capNow;
+ const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config);
+ // At least DOUBLE the core count, or don't reset. Resetting at the earliest
+ // profitable moment (the old `cores * 0.3` rule) pins per-run lifetimeRun near
+ // its floor forever, so migrate gains never escalate and the Singularity gate
+ // is never reached - an artefact of the bot, not of the balance.
+ if (!NOPRESTIGE && !pushPhase && gain > 0 && gain >= Math.max(2, state.meta.legacyCores)) {
+ if (act({ type: 'migrate' }).ok) note(`migrate${state.meta.stats.migrates}`);
+ }
+ const cap = config.prestige ? config.prestige.coreBonusCap : null;
+ const shards = config.prestige
+ ? Math.floor((state.meta.legacyCores || 0) * config.prestige.shardsPerCore)
+ : Math.floor(Math.sqrt(state.meta.legacyCores || 0));
+ const wantSing = cap === null
+ ? (shards >= 10 && shards >= state.meta.singularityShards * 1.5) // shipped baseline
+ : (state.meta.legacyCores >= cap && shards >= state.meta.singularityShards * 1.5 + 5);
+ // PUSH PHASE. Once the meta layer has paid out - cores at their cap and a
+ // meaningful slice of the shard tree bought - further resets buy nothing,
+ // and the rational play is one long uninterrupted run at the top tiers.
+ // Without this the bot resets forever and NO run ever accumulates enough to
+ // buy tier 13, which would make A4 unreachable by construction rather than
+ // by balance.
+ if (wantSing && !NOPRESTIGE && !pushPhase) {
+ if (act({ type: 'singularity' }).ok) note(`singularity${state.meta.stats.singularities}`);
+ }
+
+ perDay.push({
+ d: d + 1,
+ out: goalCtx(state, config, t).totalOutputPerSec,
+ top: state.run.tiers.reduce((m, ts, i) => (ts.owned > 0 ? i : m), 0),
+ cores: state.meta.legacyCores,
+ lifeRun: state.run.lifetimeRun,
+ shards: state.meta.singularityShards,
+ mig: state.meta.stats.migrates,
+ sing: state.meta.stats.singularities,
+ lvl: state.meta.level,
+ wafers: state.meta.stats.totalWafersEarned,
+ treePct: shardTreePct(),
+ });
+}
+
+console.log(`\n===== ${DIR} — ${SESSION_MIN}min/day, ${DAYS} days =====`);
+console.log(' day | output/s | topTier | lifeRun | cores | shards | mig | sing | lvl | shardTree');
+for (const r of perDay) {
+ if (r.d <= 10 || r.d % 5 === 0) {
+ console.log(String(r.d).padStart(4) + ' | ' + fmt(r.out).padStart(12) + ' | ' + String(r.top).padStart(7) +
+ ' | ' + fmt(r.lifeRun).padStart(9) + ' | ' + String(r.cores).padStart(5) + ' | ' + String(r.shards).padStart(6) + ' | ' + String(r.mig).padStart(3) +
+ ' | ' + String(r.sing).padStart(4) + ' | ' + String(r.lvl).padStart(3) + ' | ' + (r.treePct.toFixed(0) + '%').padStart(6));
+ }
+}
+console.log('\nfirst reached (in DAYS):');
+for (let i = 0; i < TIER_DEFS.length; i++) {
+ const v = mark[`tier${i}`];
+ console.log(` tier ${String(i).padStart(2)} ${TIER_DEFS[i].name.padEnd(26)} ${v === undefined ? 'NOT REACHED' : 'day ' + v.toFixed(1)}`);
+}
+for (const k of ['migrate1', 'migrate2', 'migrate3', 'singularity1', 'singularity2']) {
+ console.log(` ${k.padEnd(34)} ${mark[k] === undefined ? 'NOT REACHED' : 'day ' + mark[k].toFixed(1)}`);
+}
+console.log(`\nanomalies claimed: ${anomalies} | boost uptime in-session: ${(100 * boostSec / sessionSec).toFixed(1)}%`);
+console.log(`overheats: ${overheats} | hazard notices: ${hazards}`);
diff --git a/tools/score.py b/tools/score.py
new file mode 100644
index 0000000..7fc63b0
--- /dev/null
+++ b/tools/score.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Grade calibration runs against the §5 acceptance criteria.
+
+ python3 tools/score.py
+"""
+import os, re, sys
+
+D = sys.argv[1]
+# criterion -> (label, low, high) ; None bound = unbounded
+CRIT = {
+ 'tier4': ('A1 tier 4', 0, 2),
+ 'tier7': ('A2 tier 7', 5, 9),
+ 'tier10': ('A3 tier 10', 18, 25),
+ 'tier13': ('A4 tier 13', 28, 45),
+ 'mig1': ('A5 migrate 1', 4, 8),
+ 'sing1': ('A6 singularity 1',11, 21),
+}
+
+def parse(path):
+ txt = open(path).read()
+ r = {'file': os.path.basename(path)[:-4]}
+ for i in range(14):
+ m = re.search(r'tier\s+%d\s+\S.*?(day ([\d.]+)|NOT REACHED)' % i, txt)
+ r['tier%d' % i] = None if (not m or m.group(2) is None) else float(m.group(2))
+ for key, pat in (('mig1', 'migrate1'), ('sing1', 'singularity1')):
+ m = re.search(pat + r'\s+(day ([\d.]+)|NOT REACHED)', txt)
+ r[key] = None if (not m or m.group(2) is None) else float(m.group(2))
+ m = re.search(r'boost uptime in-session: ([\d.]+)%', txt)
+ r['boost'] = float(m.group(1)) if m else None
+ m = re.search(r'overheats: (\d+)', txt)
+ r['overheat'] = int(m.group(1)) if m else None
+ # last data row: sing count + shard tree %
+ # split the table rows on '|' by NAME rather than by positional regex -
+ # the column set has changed twice during calibration and a positional
+ # match silently read `lvl` as the singularity count.
+ hdr = re.search(r'^ day \|(.*)$', txt, re.M)
+ r['singCount'] = r['treePct'] = None
+ if hdr:
+ cols = [c.strip() for c in ('day|' + hdr.group(1)).split('|')]
+ body = [l for l in txt.split('\n') if re.match(r'^\s*\d+ \|', l)]
+ if body:
+ cells = [c.strip() for c in body[-1].split('|')]
+ if len(cells) == len(cols):
+ d = dict(zip(cols, cells))
+ r['singCount'] = int(d.get('sing', 0))
+ r['treePct'] = int(d.get('shardTree', '0%').rstrip('%'))
+ return r
+
+def grade(r):
+ hits, out = 0, []
+ for k, (label, lo, hi) in CRIT.items():
+ v = r.get(k)
+ if v is None:
+ out.append((label, '—', 'MISS'))
+ elif lo <= v <= hi:
+ hits += 1
+ out.append((label, 'd%g' % v, 'ok'))
+ else:
+ out.append((label, 'd%g' % v, 'fast' if v < lo else 'slow'))
+ # A7 singularities 2-4 ; A8 tree < 40%
+ sc, tp = r.get('singCount'), r.get('treePct')
+ if sc is not None and 2 <= sc <= 4:
+ hits += 1; out.append(('A7 sing count', str(sc), 'ok'))
+ else:
+ out.append(('A7 sing count', str(sc), 'fast' if (sc or 0) > 4 else 'slow'))
+ if tp is not None and tp < 40:
+ hits += 1; out.append(('A8 shard tree', '%d%%' % tp, 'ok'))
+ else:
+ out.append(('A8 shard tree', '%s%%' % tp, 'fast'))
+ return hits, out
+
+runs = []
+for f in sorted(os.listdir(D)):
+ if f.endswith('.txt'):
+ try:
+ runs.append(parse(os.path.join(D, f)))
+ except Exception as e:
+ print('parse fail', f, e)
+
+names = [r['file'] for r in runs]
+print('\n%-18s' % 'criterion (target)' + ''.join(n.rjust(11) for n in names))
+print('-' * (18 + 11 * len(names)))
+rowlabels = [c[0] for c in CRIT.values()] + ['A7 sing count', 'A8 shard tree']
+graded = {r['file']: dict((g[0], g) for g in grade(r)[1]) for r in runs}
+targets = {c[0]: 'd%g-%g' % (c[1], c[2]) for c in CRIT.values()}
+targets['A7 sing count'] = '2-4'
+targets['A8 shard tree'] = '<40%'
+for lab in rowlabels:
+ line = ('%-14s%s' % (lab, targets[lab].rjust(4)))[:18].ljust(18)
+ for n in names:
+ g = graded[n][lab]
+ tag = {'ok': '', 'fast': '▲', 'slow': '▼', 'MISS': '✗'}[g[2]]
+ line += ('%s%s' % (g[1], tag)).rjust(11)
+ print(line)
+print('-' * (18 + 11 * len(names)))
+score = {r['file']: grade(r)[0] for r in runs}
+print('%-18s' % 'PASSED /8' + ''.join(('%d' % score[n]).rjust(11) for n in names))
+print('%-18s' % 'boost uptime %' + ''.join(('%.1f' % (r['boost'] or 0)).rjust(11) for r in runs))
+print('%-18s' % 'overheats' + ''.join(('%d' % (r['overheat'] or 0)).rjust(11) for r in runs))
+print('\n▲ = too fast ▼ = too slow ✗ = never reached')
+best = max(score, key=lambda k: score[k])
+print('\nbest: %s (%d/8)' % (best, score[best]))
diff --git a/tools/sim.mjs b/tools/sim.mjs
new file mode 100644
index 0000000..6fcb6dd
--- /dev/null
+++ b/tools/sim.mjs
@@ -0,0 +1,271 @@
+// Progression simulator for the mathematical audit.
+//
+// Drives the REAL shared/ engine (evaluate + applyAction) on a synthetic
+// player so the numbers below are the game's own, not a re-implementation.
+//
+// Usage: node tools/sim.mjs [--hours N] [--profile idle|active|whale]
+// [--no-anomaly] [--no-risk] [--seed N] [--quiet]
+
+import { initialState, evaluate } from '../shared/state.js';
+import { applyAction, scheduleAnomaly } from '../shared/reducer.js';
+import { DEFAULT_CONFIG } from '../shared/configSchema.js';
+import { TIER_DEFS, GRID_DEFS, OVERCLOCK_DEFS, UPGRADE_DEFS } from '../shared/gameData.js';
+import { costAt, computeMults, tierRate, fmt, migrateGain, computeEffects } from '../shared/gameRules.js';
+import { goalCtx, GOAL_DEFS, REPEATABLE_DEFS } from '../shared/goals.js';
+import { scheduleNextHazard, scheduleGridMaintenance } from '../shared/outages.js';
+import { rolloverContracts } from '../shared/contracts.js';
+
+const args = process.argv.slice(2);
+const argVal = (k, d) => {
+ const i = args.indexOf(k);
+ return i === -1 ? d : args[i + 1];
+};
+const HOURS = Number(argVal('--hours', 168));
+const PROFILE = argVal('--profile', 'active');
+const QUIET = args.includes('--quiet');
+const SEED = Number(argVal('--seed', 12345));
+
+// deterministic rng so runs are comparable
+let _s = SEED >>> 0;
+function rng() {
+ _s = (_s + 0x6d2b79f5) >>> 0;
+ let t = _s;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+}
+
+const config = structuredClone(DEFAULT_CONFIG);
+if (args.includes('--no-risk')) config.risk.enabled = false;
+const NO_ANOMALY = args.includes('--no-anomaly');
+
+// Profiles: how much of the wall clock the player is actually at the keyboard,
+// and how promptly they react to an anomaly / heat.
+const PROFILES = {
+ idle: { sessionsPerDay: 2, sessionMin: 10, anomalyCatch: 0.15, ventEager: false },
+ active: { sessionsPerDay: 6, sessionMin: 30, anomalyCatch: 0.75, ventEager: true },
+ whale: { sessionsPerDay: 16, sessionMin: 60, anomalyCatch: 0.98, ventEager: true },
+};
+const P = PROFILES[PROFILE];
+if (!P) throw new Error(`unknown profile ${PROFILE}`);
+
+let state = initialState();
+let t = Date.now();
+const T0 = t;
+scheduleAnomaly(state.server, config, t, rng);
+let lastEval = t;
+
+const log = [];
+const milestones = {};
+function note(key, extra = {}) {
+ if (milestones[key]) return;
+ milestones[key] = { atHours: (t - T0) / 3600000, ...extra };
+ if (!QUIET) {
+ console.log(` [${((t - T0) / 3600000).toFixed(2)}h] ${key}` +
+ (extra.detail ? ` — ${extra.detail}` : ''));
+ }
+}
+
+// Mirrors server/stateService.js loadEvaluateAndSchedule's scheduling block -
+// evaluate() never (re)schedules anomalies or maintenance itself.
+function schedule() {
+ if (state.server.nextAnomalyAt === 0 ||
+ (t > state.server.anomalyExpiresAt && state.server.nextAnomalyAt <= t)) {
+ scheduleAnomaly(state.server, config, t, rng);
+ }
+ if (!(state.server.nextHazardAt > 0)) scheduleNextHazard(state.server, config, t, rng);
+ if (!state.server.gridMaintenance) scheduleGridMaintenance(state.server, config, t, rng);
+ rolloverContracts(state, config, t);
+}
+
+function step(ms) {
+ t += ms;
+ const r = evaluate(state, config, lastEval, t, rng);
+ state = r.state;
+ lastEval = t;
+ schedule();
+}
+
+function act(action) {
+ const r = applyAction(state, action, config, t, rng);
+ state = r.state;
+ return r.result;
+}
+
+// --- the bot's purchasing policy ------------------------------------------
+// Greedy payback: buy whatever unit repays its own cost fastest. This is the
+// policy a competent idle player converges on, so it is the right yardstick
+// for "how fast CAN you progress", not a worst case.
+function bestBuy() {
+ const { racksMult, gridMult, overclockMult, thresholds } = computeMults(state.meta, config, 1);
+ let best = null;
+ const consider = (lane, i, def, owned, mult) => {
+ const cost = costAt(def, owned);
+ if (cost > state.run.credits) return;
+ // marginal output of one more unit, milestones included
+ const now = tierRate(owned, def.baseProd, mult, thresholds);
+ const next = tierRate(owned + 1, def.baseProd, mult, thresholds);
+ const gain = next - now;
+ if (gain <= 0) return;
+ const payback = cost / gain;
+ if (!best || payback < best.payback) best = { lane, index: i, payback, cost };
+ };
+ state.run.tiers.forEach((ts, i) => {
+ // gate: a tier is only sensible once the previous one is owned
+ if (i > 0 && state.run.tiers[i - 1].owned < 1) return;
+ consider('tiers', i, TIER_DEFS[i], ts.owned, racksMult);
+ });
+ state.run.grid.forEach((g, i) => consider('grid', i, GRID_DEFS[i], g.owned, gridMult));
+ state.run.overclock.forEach((o, i) => consider('overclock', i, OVERCLOCK_DEFS[i], o.owned, overclockMult));
+ return best;
+}
+
+function doPurchases() {
+ // managers first: they convert an unmanaged tier into idle income
+ const eff = computeEffects(state.meta, config);
+ for (let i = 0; i < TIER_DEFS.length; i++) {
+ const ts = state.run.tiers[i];
+ if (ts.owned >= 1 && !ts.manager) {
+ const cost = TIER_DEFS[i].managerCost * eff.automationDiscount;
+ if (state.run.credits + ts.ready >= cost * 1.5) {
+ if (act({ type: 'hireManager', index: i }).ok) note(`manager:${i}`);
+ }
+ }
+ }
+ for (let n = 0; n < 400; n++) {
+ const b = bestBuy();
+ if (!b) break;
+ const r = act({ type: 'buy', lane: b.lane, index: b.index, mode: 1 });
+ if (!r.ok) break;
+ if (b.lane === 'tiers') {
+ const owned = state.run.tiers[b.index].owned;
+ if (owned === 1) note(`tier:${b.index}:${TIER_DEFS[b.index].name}`);
+ }
+ }
+}
+
+function doClaims() {
+ act({ type: 'collectAll' });
+ for (const g of GOAL_DEFS) {
+ if (!state.meta.goalsCompleted[g.id]) act({ type: 'claimGoal', id: g.id });
+ }
+ for (const r of REPEATABLE_DEFS) {
+ for (let k = 0; k < 40; k++) if (!act({ type: 'claimRepeatable', id: r.id }).ok) break;
+ }
+ // upgrades: buy anything affordable, cheapest first (wafers are the gate)
+ for (let k = 0; k < 60; k++) {
+ const affordable = UPGRADE_DEFS
+ .map((u) => ({ u, lvl: state.meta.upgrades[u.id] || 0 }))
+ .filter(({ u, lvl }) => lvl < config.upgrades.maxLevels[u.id])
+ .map(({ u, lvl }) => ({ id: u.id, cost: Math.ceil(u.baseCost * Math.pow(u.costMult, lvl)) }))
+ .filter((x) => x.cost <= state.meta.wafers)
+ .sort((a, b) => a.cost - b.cost);
+ if (!affordable.length) break;
+ if (!act({ type: 'buyUpgrade', id: affordable[0].id }).ok) break;
+ }
+}
+
+function tryAnomaly() {
+ if (NO_ANOMALY) return;
+ if (rng() > P.anomalyCatch) return;
+ act({ type: 'claimAnomaly' });
+}
+
+function outputNow() {
+ return goalCtx(state, config, t).totalOutputPerSec;
+}
+
+// --- the clock -------------------------------------------------------------
+const TOTAL_MS = HOURS * 3600000;
+const DAY_MS = 86400000;
+let boostSecondsActive = 0;
+let anomalyClaims = 0;
+let overheats = 0;
+let hazardsFired = 0;
+let onlineSeconds = 0;
+
+if (!QUIET) console.log(`\n=== profile=${PROFILE} hours=${HOURS} risk=${config.risk.enabled} anomaly=${!NO_ANOMALY} ===`);
+
+while (t - T0 < TOTAL_MS) {
+ const dayStart = t;
+ // spread N sessions across the day
+ for (let s = 0; s < P.sessionsPerDay && t - T0 < TOTAL_MS; s++) {
+ // gap to next session (offline)
+ const gapMs = Math.max(60000, DAY_MS / P.sessionsPerDay - P.sessionMin * 60000);
+ step(gapMs);
+ if (state.server.overheated) overheats++;
+ // session: tick at 5s resolution so anomalies/heat are seen
+ const sessionMs = P.sessionMin * 60000;
+ for (let e = 0; e < sessionMs; e += 5000) {
+ step(5000);
+ onlineSeconds += 5;
+ if (state.server.overheated) overheats++;
+ if (state.server.outageNotices) hazardsFired += state.server.outageNotices.length;
+ if (state.server.boost && t < state.server.boost.until) boostSecondsActive += 5;
+ const before = state.server.nextAnomalyAt;
+ if (state.server.nextAnomalyAt <= t && t <= state.server.anomalyExpiresAt) {
+ const r0 = state.meta.wafers;
+ tryAnomaly();
+ if (state.server.nextAnomalyAt !== before) anomalyClaims++;
+ }
+ if (P.ventEager && state.run.heat > config.heat.capacity * 0.6) act({ type: 'vent' });
+ doPurchases();
+ doClaims();
+ }
+ // cold storage housekeeping
+ act({ type: 'claimAllBlocks' });
+ if (state.meta.coldStorage.blocksClaimed.every(Boolean)) act({ type: 'resetTrack' });
+ if (!state.meta.coldStorage.job) act({ type: 'startJob', jobType: 'deep' });
+ act({ type: 'claimJob' });
+ act({ type: 'claimStreak' });
+ for (let i = 0; i < 3; i++) act({ type: 'claimContract', index: i });
+
+ // migrate when it is clearly worth it: gain would raise cores by >=50%
+ const eff = computeEffects(state.meta, config);
+ const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult);
+ if (gain > 0 && gain >= Math.max(1, state.meta.legacyCores * 0.5)) {
+ if (act({ type: 'migrate' }).ok) {
+ note(`migrate:${state.meta.stats.migrates}`, { detail: `+${gain} cores → ${state.meta.legacyCores}` });
+ }
+ }
+ }
+ // day boundary bookkeeping
+ const day = Math.floor((t - T0) / DAY_MS);
+ const out = outputNow();
+ log.push({
+ day,
+ hours: (t - T0) / 3600000,
+ output: out,
+ credits: state.run.credits,
+ lifetimeAll: state.meta.stats.lifetimeFlopsAllTime,
+ cores: state.meta.legacyCores,
+ migrates: state.meta.stats.migrates,
+ level: state.meta.level,
+ wafers: state.meta.wafers,
+ tapes: state.meta.coldStorage.tapes,
+ topTier: state.run.tiers.reduce((m, ts, i) => (ts.owned > 0 ? i : m), 0),
+ heat: state.run.heat,
+ });
+ if (t - dayStart < 60000) break; // safety
+}
+
+const finalOut = outputNow();
+console.log(`\n--- ${PROFILE} / ${HOURS}h ---`);
+console.log('day | hours | output/s | lifetime | cores | mig | lvl | topTier | heat');
+for (const r of log) {
+ console.log(
+ `${String(r.day).padStart(3)} | ${r.hours.toFixed(0).padStart(5)} | ${fmt(r.output).padStart(12)} | ` +
+ `${fmt(r.lifetimeAll).padStart(10)} | ${String(r.cores).padStart(6)} | ${String(r.migrates).padStart(3)} | ` +
+ `${String(r.level).padStart(3)} | ${String(r.topTier).padStart(7)} | ${r.heat.toFixed(0).padStart(5)}`);
+}
+console.log('\nmilestones:');
+for (const [k, v] of Object.entries(milestones)) {
+ if (k.startsWith('tier:') || k.startsWith('migrate:')) {
+ console.log(` ${v.atHours.toFixed(2).padStart(8)}h ${k}${v.detail ? ' — ' + v.detail : ''}`);
+ }
+}
+console.log(`\nonline: ${(onlineSeconds / 3600).toFixed(1)}h of ${HOURS}h (${(100 * onlineSeconds / 3600 / HOURS).toFixed(0)}%)`);
+console.log(`anomaly claims: ${anomalyClaims} | boost uptime while online: ${(100 * boostSecondsActive / onlineSeconds).toFixed(1)}%`);
+console.log(`overheats: ${overheats} | hazard notices: ${hazardsFired}`);
+console.log(`final output/s: ${fmt(finalOut)} lifetime: ${fmt(state.meta.stats.lifetimeFlopsAllTime)} cores: ${state.meta.legacyCores} shards: ${state.meta.singularityShards}`);
+console.log(`unlocked tiers: ${state.run.tiers.filter((x) => x.owned > 0).length}/${TIER_DEFS.length}`);