From 70bb96baaa6e81fa48d56bdc77ef7247a4787764 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 9 Aug 2026 14:49:33 -0400 Subject: [PATCH 01/14] Audit the economy and design the v1.12 rebalance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the real shared/ engine rather than re-deriving the math: four harnesses under tools/ measure the core loop in isolation, ablate each layered subsystem, and pace a daily player over 45 days. Headline findings: the core rack ladder is well-shaped, but the systems layered on it multiply progression ~21x (tier 9 in 55min vs 19.8h). Signal Boost scales anomaly boost DURATION, making a 2-4x global multiplier permanent. The v1.11 supply economy is EV-negative (1.00/0.30/0.19x) against a 1.89% total drag. Overheating is binary - unreachable if you tap vent, catastrophic if you don't. Event ladders use absolute FLOPS targets that clear in under 0.02s. And Singularity is a strict downgrade at every scale, which is masked today only because cores regrow within a day. The spec is a draft for owner review; §6 records what is verified and the calibration passes still outstanding. Co-Authored-By: Claude Opus 5 --- .../2026-08-09-economy-rebalance-design.md | 571 ++++++++++++++++++ tools/ablate.mjs | 163 +++++ tools/curve.mjs | 119 ++++ tools/pace.mjs | 182 ++++++ tools/sim.mjs | 271 +++++++++ 5 files changed, 1306 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-economy-rebalance-design.md create mode 100644 tools/ablate.mjs create mode 100644 tools/curve.mjs create mode 100644 tools/pace.mjs create mode 100644 tools/sim.mjs 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..b6a8804 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md @@ -0,0 +1,571 @@ +# v1.12 Economy Rebalance — design + +**Date:** 2026-08-09 +**Status:** Draft, awaiting owner review. +**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/pace.mjs` takes `SHARED=shared` or `SHARED=shared-proposed`, 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. + +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.35 +``` + +Today that ratio is 8 → 23,000 across the ladder (~1.95x per tier). The proposal +takes it to 10 → 5.1×10⁵ (2.35x per tier), stretching the late game far more +than the early game — tier 0 is untouched, tier 13 becomes ~28x more expensive. + +| tier | baseProd | old baseCost | new baseCost | +|---|---|---|---| +| 0 | 0.5 | 4 | 5 | +| 1 | 6 | 60 | 140 | +| 2 | 45 | 720 | 2,500 | +| 3 | 320 | 8,800 | 42,000 | +| 4 | 2,200 | 110,000 | 670,000 | +| 5 | 16,000 | 1.4e6 | 1.1e7 | +| 6 | 120,000 | 2.0e7 | 2.0e8 | +| 7 | 900,000 | 3.3e8 | 3.6e9 | +| 8 | 7.0e6 | 5.0e9 | 6.5e10 | +| 9 | 5.5e7 | 8.0e10 | 1.2e12 | +| 10 | 4.3e8 | 1.25e12 | 2.2e13 | +| 11 | 3.3e9 | 1.9e13 | 4.0e14 | +| 12 | 2.6e10 | 3.0e14 | 7.4e15 | +| 13 | 2.0e11 | 4.6e15 | 1.3e17 | + +`GROWTH` (1.14) and the milestone thresholds are **unchanged** — within-tier +depth is already well-shaped (going 25→50 costs ×27.4 for ×4 output, which +correctly pushes players to tier-hop). + +`RATIO` is the single dial for overall pacing and is the main thing §6 still +needs to converge. + +### 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 + +Three coupled changes. This is the heart of the release. + +**(a) Migrate yields far fewer cores.** + +```js +migrateGain = floor((lifetimeRun / prestige.migrateDivisor) ** prestige.migrateExponent + * legacyGainMult) +// migrateDivisor = 1e9, migrateExponent = 0.42 (was sqrt(L/1e6), i.e. exponent 0.5, divisor 1e6) +``` + +**(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 = 500 → the core lane plateaus at ×26 +``` + +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. + +### 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 (1e9 / 0.42) +prestige.corePercentPerCore / coreBonusCap (0.05 / 500) +prestige.echoPercentPerLevel (0.05) +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=shared-proposed DAYS=45 node tools/pace.mjs` (daily +player, 60 min/day). The release is done when: + +| # | criterion | shipped | target | +|---|---|---|---| +| A1 | tier 4 first reached | day 1 | day 1 | +| A2 | tier 7 first reached | day 1 | day 5–9 | +| A3 | tier 10 first reached | day 3 | day 18–25 | +| A4 | tier 13 first reached | day 14 | **day 28–45** | +| A5 | first Migrate | day 1 | day 4–8 | +| A6 | first Singularity | day 1 | day 11–21 | +| A7 | Singularities in 45 days | 18 | 2–4 | +| A8 | shard tree % maxed at day 45 | 100% (47x over) | < 40% | +| A9 | boost uptime in-session | 55.1% | < 8% | +| A10 | unmanaged risk drag | 1.89% | 8–12% | +| A11 | every supply EV | 1.00 / 0.30 / 0.19 | all ≥ 2.0x | +| A12 | overheats/24h, upgraded fleet, tapping vent | 3,096 | 0 | +| A13 | overheats/24h, upgraded fleet, idle | 3,096 | 5–20 | +| A14 | top event rung reachable in < 1s | yes | no | +| A15 | hours of minigames to max the wafer tree | 2.5 | > 100 | + +--- + +## 6. Verified so far, and what still needs calibration + +Already demonstrated against `shared-proposed` (three calibration passes): + +- **A9 met:** boost uptime 55.1% → **3.8%**. +- **A10/A11 met:** drag 1.89% → **9.4%**; supply EV **3.51 / 2.40 / 3.24**. +- **A1 met:** tier 4 on day 1. +- Heat band computed and sound (§4.5), pending in-sim confirmation of A12/A13. + +**Still short of target — the whole middle and top of the curve:** + +| criterion | target | current proposal | +|---|---|---| +| A2 tier 7 | day 5–9 | day 3 (too fast) | +| A3 tier 10 | day 18–25 | day 9 (too fast) | +| A4 tier 13 | day 28–45 | unreached at day 45 (too slow) | +| A5 first Migrate | day 4–8 | day 1 (too fast) | +| A6 first Singularity | day 11–21 | day 9 (close) | + +Note the shape of the miss: the curve is simultaneously **too fast up to tier 10 +and too slow after it**. That is not a `RATIO` problem — no single geometric +ratio fixes both ends. It is §2.5: cores never accumulate past ~95 because every +Singularity zeroes them and the shard tree buys back less than it destroys, so +the late game has no engine while the early game still has too much. + +**§4.3(b)'s core cap and §4.3(c)'s re-priced shard tree are the fix, and neither +has been simulated yet** — the Singularity defect was only identified on the +third calibration pass, after the numbers above were produced. Together they +should push the early game *down* (fewer cores early, capped benefit) and the +late game *up* (Singularity finally pays), which is precisely the two-ended +correction the table needs. + +Implementation should: + +1. apply §4.3(b) and (c) to `shared-proposed`; +2. re-run `tools/pace.mjs`; +3. tune `RATIO` (2.2–2.5), `migrateExponent` (0.38–0.45) and `coreBonusCap` + (300–800) until A1–A8 all hold. + +Expect 2–4 further passes; each run is ~8 minutes. If A2–A5 stay too fast after +the core cap lands, the next dial is `migrateExponent`, not `RATIO` — `RATIO` +should be reserved for the tier-10-to-13 span it uniquely controls. + +--- + +## 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. + +New tests to add: the core-bonus cap, the anomaly duration no longer scaling +with Signal Boost, and `secondsOfOutput` rung materialisation at join time. + +--- + +## 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. + +## 9. Risks + +- **The pacing target is aggressive.** Day 1 currently delivers tiers 0–6 in + simulation against a target of 0–4. Slowing the opening further risks a weak + first session, which is the worst place to lose a player. Prefer overshooting + slightly at the start and gating harder from tier 7 on. +- **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/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/pace.mjs b/tools/pace.mjs new file mode 100644 index 0000000..f68614b --- /dev/null +++ b/tools/pace.mjs @@ -0,0 +1,182 @@ +// 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 = 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; +}; + +let state = initialState(); +let t = Date.now(); +const T0 = 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 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 % 6 === 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 + const eff = computeEffects(state.meta, config); + const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult); + // a real player resets when it at least TRIPLES their permanent multiplier + if (gain > 0 && gain >= Math.max(3, state.meta.legacyCores * 2)) { + if (act({ type: 'migrate' }).ok) note(`migrate${state.meta.stats.migrates}`); + } + const shards = Math.floor(Math.sqrt(state.meta.legacyCores || 0)); + if (shards >= 10 && shards >= state.meta.singularityShards * 1.5) { + 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, + shards: state.meta.singularityShards, + mig: state.meta.stats.migrates, + sing: state.meta.stats.singularities, + lvl: state.meta.level, + wafers: state.meta.stats.totalWafersEarned, + }); +} + +console.log(`\n===== ${DIR} — ${SESSION_MIN}min/day, ${DAYS} days =====`); +console.log(' day | output/s | topTier | cores | shards | mig | sing | lvl | lifetime wafers'); +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) + + ' | ' + 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) + ' | ' + fmt(r.wafers).padStart(15)); + } +} +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/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}`); From 1aa1a7bfb3c709167381c28b8c3980937bd19ad9 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 9 Aug 2026 21:38:59 -0400 Subject: [PATCH 02/14] Converge the v1.12 tier curve: 15/16 acceptance criteria MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten parallel sweeps over ~70 configurations, generated by tools/mksandbox.py and graded by tools/score.py. Two fixes were discovered during calibration and are now part of the design. Singularity yield must be LINEAR in cores, not sqrt: once the core bonus is capped, sqrt(400) returns 20 shards against a multi-thousand-shard tree, so the meta layer can never progress and the late tiers keep no engine. And the Engine node needs a longer tail (maxLevel 8 -> 12), because at 8 levels "tier 13 is reachable" and "the shard tree is still a goal at day 45" are provably mutually exclusive. Also recorded: migrateExponent must be ~1.0 rather than 0.42 (the divisor is pinned by first-Migrate timing, leaving the exponent as the only dial that reaches the cap), and two player-model bugs in the harness that produced confidently wrong results until fixed - migrating at the earliest profitable moment, and never entering an endgame push phase. Resulting curve for a 60min/day player: tiers 0-9 in four days, tier 10 at day 18, first Singularity day 21, tier 13 day 44, shard tree 38% at day 45. Shipped, for comparison, delivers all 14 tiers by day 14 and a Singularity every day. A2 and A15 are revised against evidence rather than met; both revisions are argued in §6.2 and §6.3. Co-Authored-By: Claude Opus 5 --- .../2026-08-09-economy-rebalance-design.md | 318 ++++++++++++------ tools/mksandbox.py | 192 +++++++++++ tools/pace.mjs | 60 +++- tools/score.py | 102 ++++++ 4 files changed, 561 insertions(+), 111 deletions(-) create mode 100644 tools/mksandbox.py create mode 100644 tools/score.py diff --git a/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md index b6a8804..6b09b98 100644 --- a/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md +++ b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md @@ -1,7 +1,7 @@ # v1.12 Economy Rebalance — design **Date:** 2026-08-09 -**Status:** Draft, awaiting owner review. +**Status:** Calibrated and converged (15/16 acceptance criteria; see §6). Awaiting owner review before implementation. **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. @@ -20,10 +20,19 @@ harnesses were built and are committed under `tools/`: | `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/pace.mjs` takes `SHARED=shared` or `SHARED=shared-proposed`, 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. +| `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 @@ -202,36 +211,36 @@ 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.35 +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 proposal -takes it to 10 → 5.1×10⁵ (2.35x per tier), stretching the late game far more -than the early game — tier 0 is untouched, tier 13 becomes ~28x more expensive. +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 | -|---|---|---|---| -| 0 | 0.5 | 4 | 5 | -| 1 | 6 | 60 | 140 | -| 2 | 45 | 720 | 2,500 | -| 3 | 320 | 8,800 | 42,000 | -| 4 | 2,200 | 110,000 | 670,000 | -| 5 | 16,000 | 1.4e6 | 1.1e7 | -| 6 | 120,000 | 2.0e7 | 2.0e8 | -| 7 | 900,000 | 3.3e8 | 3.6e9 | -| 8 | 7.0e6 | 5.0e9 | 6.5e10 | -| 9 | 5.5e7 | 8.0e10 | 1.2e12 | -| 10 | 4.3e8 | 1.25e12 | 2.2e13 | -| 11 | 3.3e9 | 1.9e13 | 4.0e14 | -| 12 | 2.6e10 | 3.0e14 | 7.4e15 | -| 13 | 2.0e11 | 4.6e15 | 1.3e17 | - -`GROWTH` (1.14) and the milestone thresholds are **unchanged** — within-tier -depth is already well-shaped (going 25→50 costs ×27.4 for ×4 output, which -correctly pushes players to tier-hop). - -`RATIO` is the single dial for overall pacing and is the main thing §6 still -needs to converge. +| 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 @@ -259,21 +268,30 @@ falls from **55.1% → 3.8%.** ### 4.3 Prestige -Three coupled changes. This is the heart of the release. +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 = 1e9, migrateExponent = 0.42 (was sqrt(L/1e6), i.e. exponent 0.5, divisor 1e6) +// 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 = 500 → the core lane plateaus at ×26 +// corePercentPerCore = 0.05, coreBonusCap = 400 → the core lane plateaus at ×21 ``` Migrating past the cap still accumulates cores (for Singularity), but stops @@ -294,6 +312,30 @@ AdComm rank structure the v1.2–v1.5 spec set out to imitate. +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 @@ -417,9 +459,10 @@ 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 (1e9 / 0.42) -prestige.corePercentPerCore / coreBonusCap (0.05 / 500) +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) @@ -455,73 +498,135 @@ Recommend the owner hard-resets one account and leaves the live save untouched. ## 5. Acceptance criteria -Measured with `SHARED=shared-proposed DAYS=45 node tools/pace.mjs` (daily -player, 60 min/day). The release is done when: +Measured with `SHARED= DAYS=45 node tools/pace.mjs` (daily player, +60 min/day), graded by `tools/score.py`. -| # | criterion | shipped | target | -|---|---|---|---| -| A1 | tier 4 first reached | day 1 | day 1 | -| A2 | tier 7 first reached | day 1 | day 5–9 | -| A3 | tier 10 first reached | day 3 | day 18–25 | -| A4 | tier 13 first reached | day 14 | **day 28–45** | -| A5 | first Migrate | day 1 | day 4–8 | -| A6 | first Singularity | day 1 | day 11–21 | -| A7 | Singularities in 45 days | 18 | 2–4 | -| A8 | shard tree % maxed at day 45 | 100% (47x over) | < 40% | -| A9 | boost uptime in-session | 55.1% | < 8% | -| A10 | unmanaged risk drag | 1.89% | 8–12% | -| A11 | every supply EV | 1.00 / 0.30 / 0.19 | all ≥ 2.0x | -| A12 | overheats/24h, upgraded fleet, tapping vent | 3,096 | 0 | -| A13 | overheats/24h, upgraded fleet, idle | 3,096 | 5–20 | -| A14 | top event rung reachable in < 1s | yes | no | -| A15 | hours of minigames to max the wafer tree | 2.5 | > 100 | +| # | 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. Verified so far, and what still needs calibration +## 6. Calibration: converged -Already demonstrated against `shared-proposed` (three calibration passes): +Ten parallel sweeps, ~70 configurations, all graded by `tools/score.py`. -- **A9 met:** boost uptime 55.1% → **3.8%**. -- **A10/A11 met:** drag 1.89% → **9.4%**; supply EV **3.51 / 2.40 / 3.24**. -- **A1 met:** tier 4 on day 1. -- Heat band computed and sound (§4.5), pending in-sim confirmation of A12/A13. +### 6.1 The converged 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 +``` -**Still short of target — the whole middle and top of the curve:** +The resulting curve for a 60 min/day player: -| criterion | target | current proposal | +| day | milestone | +|---|---| +| 1 | tiers 0–5 | +| 2 | tiers 6–8 | +| 4 | tier 9, **first Migrate** | +| 7, 11 | Migrates 2 and 3 | +| 18 | **tier 10** — the wall | +| 21 | **first Singularity** | +| 27 | second Singularity | +| 38 | tier 11 | +| 43 | tier 12 | +| 44 | **tier 13** | +| 45 | 4 Singularities, shard tree 38%, 20 Migrates | + +Shipped, for comparison: all 14 tiers by day 14, and a Singularity *every day* +from day 1 (18 of them, 21.7M shards, tree maxed 47x over). + +The shape is the AdComm one: a generous onboarding through tiers 0–9 in the +first four days, a hard wall at tier 10 that takes two weeks to break, and a +late game that only opens once the meta layer has been fed. + +15 of 16 acceptance criteria pass. Two targets were revised against evidence +rather than met — both are documented below, because a target quietly moved to +fit a result is worthless. + +### 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 | |---|---|---| -| A2 tier 7 | day 5–9 | day 3 (too fast) | -| A3 tier 10 | day 18–25 | day 9 (too fast) | -| A4 tier 13 | day 28–45 | unreached at day 45 (too slow) | -| A5 first Migrate | day 4–8 | day 1 (too fast) | -| A6 first Singularity | day 11–21 | day 9 (close) | - -Note the shape of the miss: the curve is simultaneously **too fast up to tier 10 -and too slow after it**. That is not a `RATIO` problem — no single geometric -ratio fixes both ends. It is §2.5: cores never accumulate past ~95 because every -Singularity zeroes them and the shard tree buys back less than it destroys, so -the late game has no engine while the early game still has too much. - -**§4.3(b)'s core cap and §4.3(c)'s re-priced shard tree are the fix, and neither -has been simulated yet** — the Singularity defect was only identified on the -third calibration pass, after the numbers above were produced. Together they -should push the early game *down* (fewer cores early, capped benefit) and the -late game *up* (Singularity finally pays), which is precisely the two-ended -correction the table needs. - -Implementation should: - -1. apply §4.3(b) and (c) to `shared-proposed`; -2. re-run `tools/pace.mjs`; -3. tune `RATIO` (2.2–2.5), `migrateExponent` (0.38–0.45) and `coreBonusCap` - (300–800) until A1–A8 all hold. - -Expect 2–4 further passes; each run is ~8 minutes. If A2–A5 stay too fast after -the core cap lands, the next dial is `migrateExponent`, not `RATIO` — `RATIO` -should be reserved for the tier-10-to-13 span it uniquely controls. - ---- +| `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 @@ -541,8 +646,14 @@ exhaustive): - `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, and `secondsOfOutput` rung materialisation at join time. +with Signal Boost, `secondsOfOutput` rung materialisation at join time, and +`shardsPerCore` (including that it is read from config, not hardcoded). --- @@ -555,14 +666,19 @@ with Signal Boost, and `secondsOfOutput` rung materialisation at join time. 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. +- 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 pacing target is aggressive.** Day 1 currently delivers tiers 0–6 in - simulation against a target of 0–4. Slowing the opening further risks a weak - first session, which is the worst place to lose a player. Prefer overshooting - slightly at the start and gating harder from tier 7 on. +- **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. 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 index f68614b..25bae50 100644 --- a/tools/pace.mjs +++ b/tools/pace.mjs @@ -84,6 +84,20 @@ function bestBuy() { 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 @@ -108,7 +122,7 @@ for (let d = 0; d < DAYS; d++) { 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 % 6 === 0) { + 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++) { @@ -136,15 +150,39 @@ for (let d = 0; d < DAYS; d++) { } } - // prestige decisions, once per day at end of session + // 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 gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult); - // a real player resets when it at least TRIPLES their permanent multiplier - if (gain > 0 && gain >= Math.max(3, state.meta.legacyCores * 2)) { + 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 shards = Math.floor(Math.sqrt(state.meta.legacyCores || 0)); - if (shards >= 10 && shards >= state.meta.singularityShards * 1.5) { + 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}`); } @@ -153,21 +191,23 @@ for (let d = 0; d < DAYS; d++) { 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 | cores | shards | mig | sing | lvl | lifetime wafers'); +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) + - ' | ' + 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) + ' | ' + fmt(r.wafers).padStart(15)); + ' | ' + 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):'); 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])) From d0814db9cf6255031a997837d928f829307c03e1 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sun, 9 Aug 2026 22:51:39 -0400 Subject: [PATCH 03/14] Plan v1.12 Economy Rebalance: 13 tasks Implements the calibrated design in docs/superpowers/specs/2026-08-09-economy-rebalance-design.md. Each task is TDD-shaped with the actual test and implementation code inline, and ends with an independently testable deliverable. Appendix A enumerates the 22 existing tests these changes break, across 6 files, assigned to the task that causes each one. That list was produced empirically - the suite was run against the finished math and every failure recorded - rather than guessed, which caught roughly twice as many breakages as reading the diff suggested. Notable ordering constraint: Task 1 lands the config surface first because every later task reads from it. migrateGain, singularity, rungProgress and joinEventIfEligible all change signature; every call site including the two in client/src/RackStack.jsx is named explicitly. Co-Authored-By: Claude Opus 5 --- .../2026-08-09-v1.12-economy-rebalance.md | 1651 +++++++++++++++++ 1 file changed, 1651 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md 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). From ae4a9a28065fe3ce5b49e645cca76b45ce79cf6f Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:07:33 -0400 Subject: [PATCH 04/14] v1.12 Task 1: config surface for the rebalance Adds 21 tunables covering reward magnitudes that were hardcoded - anomaly payouts, heat per-level rates, the prestige curve, the balance minigame coefficient - and lands the recalibrated heat/anomaly/risk/minigame values. Co-Authored-By: Claude Opus 5 --- shared/configSchema.js | 106 +++++++++++++++++++++++++++------- tests/configSchema.test.js | 63 ++++++++++++++++++-- tests/reducer.economy.test.js | 21 +++---- tests/reducer.meta.test.js | 11 ++-- 4 files changed, 160 insertions(+), 41 deletions(-) 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/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/reducer.economy.test.js b/tests/reducer.economy.test.js index 114e130..fba2d40 100644 --- a/tests/reducer.economy.test.js +++ b/tests/reducer.economy.test.js @@ -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', () => { diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js index bc57c7a..3655079 100644 --- a/tests/reducer.meta.test.js +++ b/tests/reducer.meta.test.js @@ -229,9 +229,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' }); @@ -325,7 +325,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); }); }); From bc15c13af02bac6dfa1f52abfa63982deaab3e07 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:09:15 -0400 Subject: [PATCH 05/14] v1.12 Task 2: recalibrated tier cost curve baseCost is re-derived as baseProd * 10 * 2.50^tier, so the cost:production ratio grows ~2.5x per tier instead of ~1.95x. baseProd is untouched, which is what keeps goals, contracts and existing saves meaningful. Tier 0 stays cheap (5); tier 13 becomes 65x dearer. Co-Authored-By: Claude Opus 5 --- shared/gameData.js | 38 ++++++++++++++++++++++------------- tests/gameData.test.js | 20 ++++++++++++++++-- tests/gameRules.test.js | 10 +++++---- tests/reducer.economy.test.js | 4 ++-- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/shared/gameData.js b/shared/gameData.js index 0337df9..1691563 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 = [ 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..2b40557 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); }); @@ -48,7 +49,8 @@ describe('gameRules', () => { expect(migrateGain(4e6, 1)).toBe(2); }); 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); }); diff --git a/tests/reducer.economy.test.js b/tests/reducer.economy.test.js index fba2d40..496f139 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', () => { From fe2e4215baee0e01d6918fc64e7e9414d0989d27 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:10:06 -0400 Subject: [PATCH 06/14] v1.12 Tasks 3-4: config-driven heat curve, capped level bonus Heat per-level rates and the discount floor now come from config instead of being literals, and the curve is retuned so passive venting can sustain a real fleet - attention is worth ~2.5x the sustainable fleet rather than ~26x. The account-level output bonus is capped. It was uncapped while the repeatable goals never run out, so level and therefore output grew forever. Co-Authored-By: Claude Opus 5 --- shared/gameRules.js | 17 ++++++++++++++--- tests/gameRules.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/shared/gameRules.js b/shared/gameRules.js index 000ad9e..7edbf77 100644 --- a/shared/gameRules.js +++ b/shared/gameRules.js @@ -58,9 +58,20 @@ 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), diff --git a/tests/gameRules.test.js b/tests/gameRules.test.js index 2b40557..dc1dfca 100644 --- a/tests/gameRules.test.js +++ b/tests/gameRules.test.js @@ -56,6 +56,44 @@ describe('gameRules', () => { }); }); +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 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); + }); +}); + describe('overclockBoost (v1.11)', () => { it('is exactly 1 with an empty overclock lane', () => { const s = initialState(); From e12ba5ca67794c8b762b79c99aeab680dd76cfc5 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:11:20 -0400 Subject: [PATCH 07/14] v1.12 Task 5: migrate gain curve and the Legacy Core cap migrateGain becomes (lifetimeRun / 2e12) ** 1.0, replacing sqrt(L / 1e6) which granted ~3,900 cores after one day. The divisor is pinned by first-Migrate timing, so the exponent is the only dial that reaches the cap; near-linear is safe only because the cap bounds it. The core bonus now plateaus at 400 cores. That plateau is the gate that makes Singularity worth taking at all. Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 2 +- shared/gameRules.js | 27 ++++++++++++++++++++++++--- shared/reducer.js | 2 +- tests/gameRules.test.js | 29 +++++++++++++++++++++++++++-- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index f29275c..44292db 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -1093,7 +1093,7 @@ 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 gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data); const singularityGain = Math.floor(Math.sqrt(state.meta.legacyCores || 0)); // Single source of truth with the tour's auto-start effect above. diff --git a/shared/gameRules.js b/shared/gameRules.js index 7edbf77..679838b 100644 --- a/shared/gameRules.js +++ b/shared/gameRules.js @@ -96,7 +96,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, @@ -146,8 +155,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) { diff --git a/shared/reducer.js b/shared/reducer.js index b97e286..e716d19 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -140,7 +140,7 @@ 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; diff --git a/tests/gameRules.test.js b/tests/gameRules.test.js index dc1dfca..17d1655 100644 --- a/tests/gameRules.test.js +++ b/tests/gameRules.test.js @@ -45,8 +45,12 @@ 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', () => { // v1.12 divisors: rush 6 (was 4), debug 3 (was 2) @@ -76,6 +80,27 @@ describe('v1.12 heat curve is config-driven', () => { }); }); +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, From 55d3987e3ae5b128c24a32778a741698d14d9c06 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:12:57 -0400 Subject: [PATCH 08/14] v1.12 Task 6: shard tree re-pricing, bootstrap, proportional echo cores Engine gains a longer tail (maxLevel 8 -> 12) so that reaching tier 13 and leaving the shard tree unmaxed at day 45 stop being mutually exclusive - at 8 levels every calibration setting that reached tier 13 also maxed the tree. Bootstrap drops to x3/level; maxed with Deep Cache it used to hand out 11M credits at every Migrate. Echo Cores becomes a share of the gain rather than a flat grant, which would otherwise be farmable by cheap repeat Migrates. Also fixes four upgrade descriptions the rebalance turned into lies: Bootstrap x10, Heat Sink -25%, Thermal -8%, Auto-Vent 0.5/sec. Co-Authored-By: Claude Opus 5 --- shared/gameData.js | 16 ++++++++++------ shared/gameRules.js | 5 ++++- shared/reducer.js | 5 ++++- tests/reducer.meta.test.js | 24 ++++++++++++++++-------- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/shared/gameData.js b/shared/gameData.js index 1691563..4cfb951 100644 --- a/shared/gameData.js +++ b/shared/gameData.js @@ -51,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 679838b..2ea68e4 100644 --- a/shared/gameRules.js +++ b/shared/gameRules.js @@ -74,7 +74,10 @@ export function computeEffects(meta, config) { 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, }; diff --git a/shared/reducer.js b/shared/reducer.js index e716d19..2cb17d8 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -143,7 +143,10 @@ function migrate(s, action, config) { 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 }; diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js index 3655079..a25a9c4 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); }); }); From f08f7b492c2b1b2d2def73febf960711c602e1bf Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:13:50 -0400 Subject: [PATCH 09/14] v1.12 Task 7: Singularity yield linear in cores Was floor(sqrt(cores)). With the core bonus now capped at 400, sqrt returned 20 shards against a ~17k-shard tree, so the meta layer could never progress. Once cores are bounded the square root has nothing left to damp and only starves the tree. The client duplicated the same formula for its Singularity confirm modal; that call site is updated too. Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 2 +- shared/reducer.js | 11 +++++++++-- tests/reducer.meta.test.js | 19 +++++++++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index 44292db..e9d1977 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -1094,7 +1094,7 @@ export default function RackStack({ user }) { const ctx = goalCtx(state, config.data, now); const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data); - const singularityGain = Math.floor(Math.sqrt(state.meta.legacyCores || 0)); + 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/shared/reducer.js b/shared/reducer.js index 2cb17d8..18d6c2e 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -155,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); diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js index a25a9c4..007b68d 100644 --- a/tests/reducer.meta.test.js +++ b/tests/reducer.meta.test.js @@ -65,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', () => { @@ -314,11 +321,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); From b3b7fed175a5478088821b40dcd3c6e9c06ada38 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:14:42 -0400 Subject: [PATCH 10/14] v1.12 Task 8: Signal Boost no longer scales anomaly boost duration The headline defect. eventRewardMult scaled both the credit payout and the boost DURATION, so at max Signal Boost the boost lasted 135-225s against a 70-150s respawn interval - a 2-4x global multiplier that was permanently on. Measured boost uptime was 55% of session time, worth roughly +245% output. Signal Boost now scales the payout only. The reward magnitudes come from config rather than literals. Co-Authored-By: Claude Opus 5 --- shared/reducer.js | 13 ++++++++++--- tests/reducer.meta.test.js | 22 +++++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/shared/reducer.js b/shared/reducer.js index 18d6c2e..666e9b4 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -463,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 }; } diff --git a/tests/reducer.meta.test.js b/tests/reducer.meta.test.js index 007b68d..3ebc720 100644 --- a/tests/reducer.meta.test.js +++ b/tests/reducer.meta.test.js @@ -257,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 }); }); From 9fc0885711193912aa4126da71f0914d548f56c4 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:16:37 -0400 Subject: [PATCH 11/14] v1.12 Tasks 9-10: tunable balance payout, hazards hit the top tier The balance minigame's wafer coefficient was a hardcoded 1.5, paying 562 wafers per 12-second game at max score with Lucky Silicon - about 2.5 hours of minigames maxed every permanent upgrade in the game. Drive failures and overheats now down the TOP owned rack tier instead of a derived-random one. A random victim was unpredictable and usually trivial (~1/14 of output); the top tier is legible and worth insuring against. Both are switchable from the Balancing tab. The random-vs-top test asserts the two paths genuinely differ - the derived pick happened to select the top tier for the first timestamps tried, which would have made the assertion pass for the wrong reason. Co-Authored-By: Claude Opus 5 --- shared/gameRules.js | 5 +++- shared/outages.js | 16 ++++++++-- tests/gameRules.test.js | 8 ++++- tests/outages.test.js | 66 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/shared/gameRules.js b/shared/gameRules.js index 2ea68e4..7e06b6f 100644 --- a/shared/gameRules.js +++ b/shared/gameRules.js @@ -180,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/tests/gameRules.test.js b/tests/gameRules.test.js index 17d1655..fe0da6e 100644 --- a/tests/gameRules.test.js +++ b/tests/gameRules.test.js @@ -56,7 +56,13 @@ describe('gameRules', () => { // 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); }); }); 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(); + }); +}); From 3aef16b25da6abfc7e41e0afae78a3a9178b74bb Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:19:10 -0400 Subject: [PATCH 12/14] v1.12 Task 11: event ladders scale with output Ladder rungs gain an optional unit: 'secondsOfOutput'. Absolute FLOPS targets were the one reward system in the game not priced against the player's rate - contracts and streaks already were - so the entire FLOPS ladder of every seasonal event cleared in under 0.02 seconds. Targets are materialised once at join time, the same snapshot rolloverContracts uses and for the same reason: a rate-scaled target recomputed on every read would recede as fast as the player approached it. All four seasonal ladders are reseeded, with count rungs raised to suit each event's duration and every literal FLOPS reward converted to wafers/tapes - a FLOPS payout has exactly the scaling problem the targets did. Co-Authored-By: Claude Opus 5 --- server/data/seasonalEvents.js | 85 +++++++++++++++++++---------------- server/eventService.js | 14 +++++- server/stateService.js | 2 +- shared/events.js | 40 +++++++++++++++-- shared/reducer.js | 3 +- tests/events.test.js | 35 +++++++++++++++ 6 files changed, 134 insertions(+), 45 deletions(-) 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/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/reducer.js b/shared/reducer.js index 666e9b4..56d0ac3 100644 --- a/shared/reducer.js +++ b/shared/reducer.js @@ -566,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/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); + }); +}); From 011cf7dd18e4743150370dbda322949bb22b5928 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:21:07 -0400 Subject: [PATCH 13/14] v1.12 Task 12: meltdown modal names the downed tier server.overheated now carries { tierIndex } so the client can say which rack went dark. It stays truthy, so every existing `if (server.overheated)` check is unaffected. The modal copy was also simply wrong: it still said the Overclock lane freezes and "no nodes were lost", which described pre-v1.11 behaviour. Since v1.11 an overheat downs a rack tier, and 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. Co-Authored-By: Claude Opus 5 --- client/src/RackStack.jsx | 6 +++--- .../game/components/modals/MessageModal.jsx | 15 +++++++++++++-- shared/state.js | 10 ++++++++-- tests/state.test.js | 18 +++++++++++++++++- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index e9d1977..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]); 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/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/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); + }); +}); From ac9bdda8c1c571a880a5be60b8f6b7af88805ed2 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 10 Aug 2026 08:26:37 -0400 Subject: [PATCH 14/14] v1.12.0: economy rebalance Ships the retuned economy and records an important correction to the calibration record. tools/pace.mjs was not reproducible: it started its timeline at Date.now(), so UTC day boundaries (contract rollovers, streak claims) and the Cold Storage track alignment depended on the time of day the run was launched. The sandbox that scored 7/8 during calibration re-scored 5/8 when re-run unchanged. The harness now pins T0 to a fixed epoch and takes SEED; two runs of a seed are byte-identical. Measured across five seeds, the implementation reproduces the reference sandbox exactly, and 12 of 16 acceptance criteria hold. A3/A4/A6/A7 are marginal - tier 13 lands inside 45 days in 2 of 5 seeds and the first Singularity is consistently a day or two late. That is a calibration gap, not an implementation defect, and spec 6.1.2 records it rather than the earlier single-sample claim. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 56 ++++++++++++++ README.md | 9 +++ .../2026-08-09-economy-rebalance-design.md | 77 ++++++++++++------- package.json | 2 +- tests/reducer.economy.test.js | 2 +- tools/pace.mjs | 13 +++- 6 files changed, 126 insertions(+), 33 deletions(-) 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/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md index 6b09b98..305535d 100644 --- a/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md +++ b/docs/superpowers/specs/2026-08-09-economy-rebalance-design.md @@ -1,7 +1,7 @@ # v1.12 Economy Rebalance — design **Date:** 2026-08-09 -**Status:** Calibrated and converged (15/16 acceptance criteria; see §6). Awaiting owner review before implementation. +**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. @@ -521,11 +521,11 @@ Measured with `SHARED= DAYS=45 node tools/pace.mjs` (daily player, --- -## 6. Calibration: converged +## 6. Calibration Ten parallel sweeps, ~70 configurations, all graded by `tools/score.py`. -### 6.1 The converged constants +### 6.1 The constants ``` RATIO 2.50 (tier cost curve, §4.1) @@ -541,32 +541,51 @@ SINGULARITY engine maxLevel 12 (was 8) minigames.winCooldownMs 300000 ``` -The resulting curve for a 60 min/day player: - -| day | milestone | -|---|---| -| 1 | tiers 0–5 | -| 2 | tiers 6–8 | -| 4 | tier 9, **first Migrate** | -| 7, 11 | Migrates 2 and 3 | -| 18 | **tier 10** — the wall | -| 21 | **first Singularity** | -| 27 | second Singularity | -| 38 | tier 11 | -| 43 | tier 12 | -| 44 | **tier 13** | -| 45 | 4 Singularities, shard tree 38%, 20 Migrates | - -Shipped, for comparison: all 14 tiers by day 14, and a Singularity *every day* -from day 1 (18 of them, 21.7M shards, tree maxed 47x over). - -The shape is the AdComm one: a generous onboarding through tiers 0–9 in the -first four days, a hard wall at tier 10 that takes two weeks to break, and a -late game that only opens once the meta layer has been fed. - -15 of 16 acceptance criteria pass. Two targets were revised against evidence -rather than met — both are documented below, because a target quietly moved to -fit a result is worthless. +### 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 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/tests/reducer.economy.test.js b/tests/reducer.economy.test.js index 496f139..091d445 100644 --- a/tests/reducer.economy.test.js +++ b/tests/reducer.economy.test.js @@ -400,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/tools/pace.mjs b/tools/pace.mjs index 25bae50..09b2e21 100644 --- a/tools/pace.mjs +++ b/tools/pace.mjs @@ -22,7 +22,7 @@ const { scheduleNextHazard, scheduleGridMaintenance } = await import(`../${DIR}/ const { rolloverContracts } = await import(`../${DIR}/contracts.js`); const config = structuredClone(DEFAULT_CONFIG); -let seed = 4242; +let seed = Number(process.env.SEED || 4242); const rng = () => { seed = (seed + 0x6d2b79f5) >>> 0; let x = seed; @@ -31,9 +31,18 @@ const rng = () => { 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 = Date.now(); +let t = T0_EPOCH; const T0 = t; +state.meta.coldStorage.trackStartedAt = t; let lastEval = t; scheduleAnomaly(state.server, config, t, rng);