diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d611f..5afffa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## v1.12.0 + +- **The whole economy is retuned to a weeks-long curve.** A daily player now + reaches tiers 0-9 in the first four days, tier 10 around day 18, and the top + of the ladder over the following weeks. Previously the entire 14-tier ladder + fell inside two weeks and a Singularity was available *every single day*. + + The rack ladder itself was never the problem - in isolation it was a clean + doubling curve. What broke it was every reward system layered on top, which + together multiplied progression by about 21x. + +- **Signal Boost no longer makes the anomaly boost permanent.** It scaled the + boost's *duration* as well as its payout, so at max level a 2-4x global + multiplier lasted longer than the interval between anomalies. Measured boost + uptime was 55% of session time. Signal Boost now scales the payout only, and + anomalies are rarer, more valuable, and give you twice as long to catch one. + +- **Legacy Cores plateau, and Singularity is finally worth taking.** Cores + stop buying output past a cap; beyond it they are fuel for the next + Singularity. That plateau is the point - Singularity used to be a strict + downgrade at every scale (it zeroes cores and returned far less than it + destroyed), and was only survivable because cores regrew within a day. + Singularity yield is now linear in cores rather than a square root, and the + Engine upgrade has a longer tail so the shard tree stays a long-term goal. + +- **Quantum Bootstrap is x3 per level, not x10.** Maxed alongside Deep Cache it + handed you 11,000,000 credits at every Migrate - enough to buy straight back + into the mid-game, so Migrate had stopped being a reset. Echo Cores now + grants a share of the Migrate gain instead of a flat amount. + +- **Incidents are twice as frequent but individually softer, and preparing is + now clearly worth it.** Supplies used to be break-even at best and an + outright loss for two of the three, so the rational play was to ignore the + entire prepaid economy. Drive failures and overheats now hit your *top* rack + tier - a random victim was both unpredictable and usually trivial. + +- **Overheating is a real trade-off instead of a cliff.** Venting used to + supply far more cooling than any fleet could generate, so an attentive player + could never overheat and an inattentive one was punished constantly. Venting + is slower now, passive Auto-Vent is much stronger, and the meltdown notice + tells you which rack went dark and why. + +- **Live Event ladders scale with your output.** Their FLOPS rungs were fixed + numbers, so every seasonal event's FLOPS ladder cleared in a fraction of a + second. Targets are now expressed in seconds of your own production and + snapshotted when you join. + +- **Minigames pay far fewer wafers.** The whole permanent upgrade tree used to + be reachable in about two and a half hours of minigames. + +- **Reward magnitudes are now admin-tunable.** The root cause of most of the + above was that rate curves were tunable while payout sizes were hardcoded + constants calibrated for the early game. 21 new tunables cover them. + +Existing saves are unaffected in balance terms - no progress is rewritten. + ## v1.11.0 - **Things can now go wrong.** Every few hours something breaks: ransomware diff --git a/README.md b/README.md index bdbe2b3..8bd6825 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ global leaderboards, and a badge case of achievements that unlock on their own as you play. A daily login streak sits in the header. See [Social & Retention](#social--retention) below. +As of v1.12, the economy is retuned to a weeks-long curve: tiers 0-9 in the +first few days, tier 10 as a wall around day 18, and the top of the ladder over +the weeks after. Legacy Cores plateau at a cap, which is what makes Singularity +worth taking; anomaly boosts no longer run permanently; incidents are more +frequent but softer and preparing for them now pays; overheating is a genuine +trade-off; and Live Event ladders scale with your output instead of using fixed +targets. Reward magnitudes that used to be hardcoded are admin-tunable. Existing +saves keep their progress - nothing is rewritten. + ## Architecture - `shared/` - a package used by both server and client (via a `@shared` Vite diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index f29275c..d8baa3b 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -339,7 +339,7 @@ export default function RackStack({ user }) { lastTickAtRef.current = now; setState(next); - if (serverState.server.overheated) setModal({ type: 'meltdown' }); + if (serverState.server.overheated) setModal({ type: 'meltdown', tierIndex: serverState.server.overheated?.tierIndex }); // v1.11: one-shot outage notices, same lifecycle as `overheated` above. // Toast, not modal - these are information, not a reward (the v1.10 rule: @@ -526,7 +526,7 @@ export default function RackStack({ user }) { if (stateRes.offlineGain > 1) { setModal({ type: 'welcome', amount: stateRes.offlineGain }); } else if (initial.server.overheated) { - setModal({ type: 'meltdown' }); + setModal({ type: 'meltdown', tierIndex: initial.server.overheated?.tierIndex }); } // Live Events (v1.4): GET /api/state already carries this player's // claimable event identity - seed activeEvent from it directly so the @@ -566,7 +566,7 @@ export default function RackStack({ user }) { lastTickAtRef.current = now; stateRef.current = next; setState(next); - if (next.server.overheated) setModal({ type: 'meltdown' }); + if (next.server.overheated) setModal({ type: 'meltdown', tierIndex: next.server.overheated?.tierIndex }); }, TICK_MS); return () => clearInterval(iv); }, [loaded]); @@ -1093,8 +1093,8 @@ export default function RackStack({ user }) { const runForOverclock = { ...state.run, heat: heatPct }; const ctx = goalCtx(state, config.data, now); - const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult); - const singularityGain = Math.floor(Math.sqrt(state.meta.legacyCores || 0)); + const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data); + const singularityGain = Math.floor((state.meta.legacyCores || 0) * config.data.prestige.shardsPerCore); // Single source of truth with the tour's auto-start effect above. const { gridUnlocked, overclockUnlocked, singularityUnlocked, coldStorageUnlocked } = buildTourCtx(state, now); diff --git a/client/src/game/components/modals/MessageModal.jsx b/client/src/game/components/modals/MessageModal.jsx index e74305b..4dbb727 100644 --- a/client/src/game/components/modals/MessageModal.jsx +++ b/client/src/game/components/modals/MessageModal.jsx @@ -1,4 +1,5 @@ import { AlertTriangle } from 'lucide-react'; +import { TIER_DEFS } from '@shared/gameData.js'; import { amber, violet, danger, textDim, textMain } from '../../theme.js'; import { fmt } from '../../helpers.js'; @@ -38,14 +39,24 @@ export default function MessageModal({ modal, onClose }) { ); - case 'meltdown': + // The pre-v1.12 copy here described PRE-v1.11 behaviour ("the lane is + // frozen ... no nodes were lost"), which stopped being true when the + // overheat penalty moved to the Racks lane. Name the actual victim. + case 'meltdown': { + const downed = typeof modal.tierIndex === 'number' ? TIER_DEFS[modal.tierIndex] : null; return ( <>

Overheated!

-

Your Overclock Bay hit 100% heat and the lane is frozen for a short cooldown - no nodes were lost. Keep an eye on the heat gauge and vent regularly, or invest in Thermal Regulators / Auto-Vent upgrades to avoid the lockout.

+

+ {downed + ? `Your Overclock Bay hit 100% heat and took your ${downed.name} offline while it cools. Overclocking is what multiplies your racks, so running it hot risks the very thing it amplifies.` + : 'Your Overclock Bay hit 100% heat and the lane is frozen while it cools.'} + {' '}Vent before the gauge fills, or invest in Thermal Regulators / Auto-Vent to raise the fleet you can run unattended. +

); + } case 'singularityDone': return ( <> diff --git a/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md b/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md new file mode 100644 index 0000000..d0713d1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-v1.12-economy-rebalance.md @@ -0,0 +1,1651 @@ +# v1.12 Economy Rebalance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Retune the RackStack economy to AdVenture-Communist pacing — tiers 0–9 in the first four days, tier 10 at ~day 18, tier 13 at ~day 44 — by applying the calibrated constants and eight formula fixes from the design spec. + +**Architecture:** Almost every change lands in `shared/`, which is the single source of truth for game math (the client imports it via the `@shared` Vite alias; there is no second copy). Config values go in `DEFAULT_CONFIG` + `TUNABLES` in `shared/configSchema.js` so they stay admin-tunable and live-event overlayable. Formula fixes go in `shared/gameRules.js`, `shared/reducer.js`, `shared/outages.js`, and `shared/events.js`. Two small client changes fix call sites and stale copy. No save migration: `migrateSave` already defaults new fields and `upgradeConfig` folds new tunables into stored configs on read. + +**Tech Stack:** Node 20+, ES modules, vitest, Express, React (Vite), better-sqlite3 / pg. + +**Spec:** `docs/superpowers/specs/2026-08-09-economy-rebalance-design.md`. Section references below (§4.1, §4.3a…) point at it. + +## Global Constraints + +- **Branch:** `v1.12-balance-audit`. It already contains the spec and the `tools/` harness. Do not branch off `main` (which is v1.10.0 and lacks v1.11). +- **Never re-implement game math outside `shared/`.** The client and server both import it. If a formula needs a new input, thread `config` through rather than duplicating a constant. +- **Every new config leaf needs a matching `TUNABLES` row.** `validateConfig` rejects unknown leaf paths *and* requires every `TUNABLES` entry to be present, so a leaf without a row (or a row without a leaf) fails the whole config. +- **Boolean tunables use `type: 'boolean'`**, never 0/1 numbers. `validateConfig` enforces both directions. +- **Do not change `GROWTH` (1.14) or `MILESTONES`.** Tested during calibration; neither moves pacing, and raising `GROWTH` collapses within-tier depth (§6.2). +- **Run tests with `npx vitest run `** for a single file. Full suite is `npm test`. +- **Commit after every task.** Do not squash tasks together. +- **No save migration and no rewriting of player balances** (§4.9 — the owner chose to grandfather). +- **Appendix A lists every existing test these changes break.** It was produced + empirically, by running the suite against the finished math — not guessed. Each + task's final step updates the rows assigned to it. Do not skip them and leave + the suite red for later tasks to trip over. + +--- + +## File Structure + +| File | Responsibility | Tasks | +|---|---|---| +| `shared/configSchema.js` | `DEFAULT_CONFIG` values + `TUNABLES` rows | 1 | +| `shared/gameData.js` | `TIER_DEFS` costs, `SINGULARITY_DEFS` costs/levels/copy | 2, 6 | +| `shared/gameRules.js` | `computeEffects`, `computeMults`, `migrateGain`, `minigameWafers` | 3, 4, 5, 6, 9 | +| `shared/reducer.js` | `migrate`, `singularity`, `claimAnomaly`, `claimEventRung` | 5, 6, 7, 8, 11 | +| `shared/outages.js` | `hazardFrom`, `overheatOutage` targeting | 10 | +| `shared/events.js` | `validateLadder`, `rungProgress` — rate-scaled rungs | 11 | +| `shared/state.js` | `overheated` signal carries the downed tier | 12 | +| `server/eventService.js` | materialises rung targets at join time | 11 | +| `server/data/seasonalEvents.js` | reseeded seasonal ladders | 11 | +| `client/src/RackStack.jsx` | two call sites that duplicate shared math | 5, 7, 12 | +| `client/src/game/components/modals/MessageModal.jsx` | meltdown copy | 12 | + +--- + +## Task 1: Config surface — new tunables and recalibrated values + +Everything downstream reads these, so this lands first. Values are the converged set from spec §6.1. + +**Files:** +- Modify: `shared/configSchema.js` (`DEFAULT_CONFIG` and `TUNABLES`) +- Test: `tests/configSchema.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `config.heat.{autoVentPerLevel,thermalPerLevel,heatsinkPerLevel,discountFloor}`, `config.anomaly.{creditsSecondsMin,creditsSecondsMax,boostDurationMinMs,boostDurationMaxMs,boostMultMin,boostMultMax}`, `config.production.{levelBonusPerLevel,levelBonusMaxLevel}`, `config.prestige.{migrateDivisor,migrateExponent,corePercentPerCore,coreBonusCap,echoPercentPerLevel,shardsPerCore}`, `config.minigames.balance.waferPerPoint`, `config.risk.{driveFailureTargetsTopTier,overheatTargetsTopTier}`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/configSchema.test.js`. The file already imports +`DEFAULT_CONFIG, TUNABLES, validateConfig, upgradeConfig, getAtPath` — do **not** +re-import them, that is a duplicate-declaration error. + +```js +describe('v1.12 config surface', () => { + it('DEFAULT_CONFIG still validates with the new leaves', () => { + expect(validateConfig(DEFAULT_CONFIG)).toEqual({ ok: true }); + }); + + it('every new v1.12 path exists and has a TUNABLES row', () => { + const paths = [ + 'heat.autoVentPerLevel', 'heat.thermalPerLevel', 'heat.heatsinkPerLevel', 'heat.discountFloor', + 'anomaly.creditsSecondsMin', 'anomaly.creditsSecondsMax', + 'anomaly.boostDurationMinMs', 'anomaly.boostDurationMaxMs', + 'anomaly.boostMultMin', 'anomaly.boostMultMax', + 'production.levelBonusPerLevel', 'production.levelBonusMaxLevel', + 'prestige.migrateDivisor', 'prestige.migrateExponent', 'prestige.corePercentPerCore', + 'prestige.coreBonusCap', 'prestige.echoPercentPerLevel', 'prestige.shardsPerCore', + 'minigames.balance.waferPerPoint', + 'risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier', + ]; + const rows = new Set(TUNABLES.map((t) => t.path)); + for (const p of paths) { + expect(getAtPath(DEFAULT_CONFIG, p), `missing DEFAULT_CONFIG leaf ${p}`).toBeDefined(); + expect(rows.has(p), `missing TUNABLES row ${p}`).toBe(true); + } + }); + + it('the two new risk switches are boolean-typed tunables', () => { + for (const p of ['risk.driveFailureTargetsTopTier', 'risk.overheatTargetsTopTier']) { + expect(TUNABLES.find((t) => t.path === p).type).toBe('boolean'); + } + }); + + it('upgradeConfig folds the new paths into a stored pre-v1.12 config', () => { + // a stored config written before v1.12 simply lacks these leaves + const old = structuredClone(DEFAULT_CONFIG); + delete old.prestige; + delete old.production.levelBonusPerLevel; + const upgraded = upgradeConfig(old); + expect(upgraded.prestige.coreBonusCap).toBe(400); + expect(upgraded.production.levelBonusPerLevel).toBe(0.02); + expect(validateConfig(upgraded)).toEqual({ ok: true }); + }); + + it('carries the recalibrated v1.12 values', () => { + expect(DEFAULT_CONFIG.anomaly.minDelayMs).toBe(420000); + expect(DEFAULT_CONFIG.heat.ventCooldownMs).toBe(15000); + expect(DEFAULT_CONFIG.risk.hazardMinDelayMs).toBe(7200000); + expect(DEFAULT_CONFIG.minigames.winCooldownMs).toBe(300000); + expect(DEFAULT_CONFIG.upgrades.maxLevels.engine).toBe(12); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/configSchema.test.js` +Expected: FAIL — `missing DEFAULT_CONFIG leaf heat.autoVentPerLevel`. + +- [ ] **Step 3: Update `DEFAULT_CONFIG`** + +In `shared/configSchema.js`, replace these lines: + +```js + heat: { capacity: 2000, ventPercent: 35, ventCooldownMs: 15000, overheatCooldownMs: 10000, overheatPopupMs: 15000, + // v1.12: the heat curve is config-driven so a rebalance never needs a deploy. + autoVentPerLevel: 4.0, thermalPerLevel: 0.05, heatsinkPerLevel: 0.15, discountFloor: 0.40 }, + anomaly: { windowMs: 30000, minDelayMs: 420000, maxDelayMs: 900000, + // v1.12: payout magnitudes were hardcoded in claimAnomaly. Note the + // boost DURATION is deliberately separate from the payout, and must + // never be scaled by Signal Boost - that made the boost permanent. + creditsSecondsMin: 30, creditsSecondsMax: 90, + boostDurationMinMs: 45000, boostDurationMaxMs: 75000, + boostMultMin: 1.5, boostMultMax: 3.0 }, +``` + +Replace the `production` line with: + +```js + production: { globalMult: 1, racksMult: 1, gridMult: 1, overclockMult: 1, + levelBonusPerLevel: 0.02, levelBonusMaxLevel: 200 }, + // v1.12 prestige. `coreBonusCap` is load-bearing: it is what turns Singularity + // from a strict downgrade into the required next step, and it is also what makes + // a near-linear `migrateExponent` safe (it bounds the runaway). + prestige: { + migrateDivisor: 2e12, + migrateExponent: 1.0, + corePercentPerCore: 0.05, + coreBonusCap: 400, + echoPercentPerLevel: 0.05, + shardsPerCore: 0.4, + }, +``` + +In the `minigames` block set `winCooldownMs: 300000`, `rush.waferDivisor: 6`, `debug.waferDivisor: 3`, and add `waferPerPoint: 0.20` to the `balance` object. + +In `upgrades.maxLevels` set `engine: 12`. + +In the `risk` block set: `hazardMinDelayMs: 7200000`, `hazardMaxDelayMs: 14400000`, `ransomwareFactor: 0.35`, `ransomwareDurationMs: 2700000`, `ispOutageDurationMs: 2400000`, `driveFailureDurationMs: 2700000`, `antivirusPriceSeconds: 500`, `backupIspPriceSeconds: 200`, `spareDrivesPriceSeconds: 250`, `overheatOutageMs: 900000`, and add: + +```js + // v1.12: a random victim made both hazards unpredictable AND usually trivial. + // The top owned tier is legible ("your Quantum Foam Harvester lost a drive") + // and actually consequential. + driveFailureTargetsTopTier: true, + overheatTargetsTopTier: true, +``` + +- [ ] **Step 4: Add the `TUNABLES` rows** + +Append after the `risk.overclockBoostGain` row: + +```js + { path: 'risk.driveFailureTargetsTopTier', label: 'Drive failure hits the top tier', type: 'boolean' }, + { path: 'risk.overheatTargetsTopTier', label: 'Overheat hits the top tier', type: 'boolean' }, + { path: 'heat.autoVentPerLevel', label: 'Auto-vent per level (heat/s)', min: 0, max: 100, integer: false }, + { path: 'heat.thermalPerLevel', label: 'Thermal Regulators per level', min: 0, max: 1, integer: false }, + { path: 'heat.heatsinkPerLevel', label: 'Heat Sink Mastery per level', min: 0, max: 1, integer: false }, + { path: 'heat.discountFloor', label: 'Heat generation discount floor', min: 0, max: 1, integer: false }, + { path: 'anomaly.creditsSecondsMin', label: 'Anomaly credits (min seconds of output)', min: 0, max: 3600, integer: false }, + { path: 'anomaly.creditsSecondsMax', label: 'Anomaly credits (max seconds of output)', min: 0, max: 3600, integer: false }, + { path: 'anomaly.boostDurationMinMs', label: 'Anomaly boost duration min (ms)', min: 0, max: 3600000, integer: true }, + { path: 'anomaly.boostDurationMaxMs', label: 'Anomaly boost duration max (ms)', min: 0, max: 3600000, integer: true }, + { path: 'anomaly.boostMultMin', label: 'Anomaly boost multiplier min', min: 1, max: 100, integer: false }, + { path: 'anomaly.boostMultMax', label: 'Anomaly boost multiplier max', min: 1, max: 100, integer: false }, + { path: 'production.levelBonusPerLevel', label: 'Output bonus per account level', min: 0, max: 1, integer: false }, + { path: 'production.levelBonusMaxLevel', label: 'Account level bonus cap (levels)', min: 1, max: 10000, integer: true }, + { path: 'prestige.migrateDivisor', label: 'Migrate: lifetime divisor', min: 1, max: 1e18, integer: false }, + { path: 'prestige.migrateExponent', label: 'Migrate: gain exponent', min: 0.05, max: 2, integer: false }, + { path: 'prestige.corePercentPerCore', label: 'Output per Legacy Core', min: 0, max: 1, integer: false }, + { path: 'prestige.coreBonusCap', label: 'Legacy Core bonus cap (cores)', min: 1, max: 1e9, integer: true }, + { path: 'prestige.echoPercentPerLevel', label: 'Echo Cores: % of Migrate gain per level', min: 0, max: 1, integer: false }, + { path: 'prestige.shardsPerCore', label: 'Singularity: shards per Legacy Core', min: 0, max: 10, integer: false }, + { path: 'minigames.balance.waferPerPoint', label: 'Balance wafers per point', min: 0, max: 100, integer: false }, +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run tests/configSchema.test.js tests/reducer.economy.test.js tests/reducer.meta.test.js` +Expected: the new block PASSES. Nine pre-existing tests now fail on the changed +values — **fix them now**, they are the Task 1 rows in Appendix A: three in +`configSchema.test.js` (§3.6 defaults, the v1.6 vent default, the v1.11 risk +defaults), four `reducer: vent` tests (vent is 700 per 15000ms, was 500 per +2500ms), `buySupply > supplies survive a Migrate` (antivirus is 500s), and +`scheduleAnomaly > mutates the passed server object` (`now + 420000 + rng*480000`, +window 30000). + +- [ ] **Step 6: Commit** + +```bash +git add shared/configSchema.js tests/configSchema.test.js tests/reducer.economy.test.js tests/reducer.meta.test.js +git commit -m "v1.12 Task 1: config surface for the rebalance" +``` + +--- + +## Task 2: Tier cost curve (§4.1) + +**Files:** +- Modify: `shared/gameData.js` (`TIER_DEFS` `baseCost` only) +- Test: `tests/gameData.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: recalibrated `TIER_DEFS[i].baseCost`. Every `baseProd` and `managerCost` is unchanged. + +- [ ] **Step 1: Write the failing test** + +First fix the two assertions in the existing `'matches the v1.1 numeric content'` +test that pin the old costs — they break as soon as Step 3 lands: + +```js + expect(TIER_DEFS[0]).toEqual({ id: 0, name: 'Spare Raspberry Pi', baseCost: 5, baseProd: 0.5, managerCost: 500 }); + expect(TIER_DEFS[13].baseCost).toBe(3e17); +``` + +Then append (the file already imports `TIER_DEFS` — do not re-import): + +```js +describe('v1.12 tier cost curve', () => { + it('cost:production ratio grows ~2.5x per tier', () => { + const ratios = TIER_DEFS.map((d) => d.baseCost / d.baseProd); + for (let i = 1; i < ratios.length; i++) { + const step = ratios[i] / ratios[i - 1]; + expect(step, `tier ${i} step ${step}`).toBeGreaterThan(2.2); + expect(step, `tier ${i} step ${step}`).toBeLessThan(2.8); + } + }); + + it('keeps the opening cheap and makes the top tier the long goal', () => { + expect(TIER_DEFS[0].baseCost).toBe(5); + expect(TIER_DEFS[13].baseCost).toBe(3e17); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/gameData.test.js` +Expected: FAIL — the shipped ratio step is ~1.95, below the 2.2 floor. + +- [ ] **Step 3: Replace the `baseCost` values** + +In `shared/gameData.js`, set `TIER_DEFS` baseCosts to (leaving `name`, `baseProd`, `managerCost` untouched): + +``` + 0: 5 7: 5500000000 + 1: 150 8: 110000000000 + 2: 2800 9: 2100000000000 + 3: 50000 10: 41000000000000 + 4: 860000 11: 790000000000000 + 5: 16000000 12: 15000000000000000 + 6: 290000000 13: 300000000000000000 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/gameData.test.js tests/gameRules.test.js tests/reducer.economy.test.js` +Expected: PASS. Two further pre-existing tests break on the new tier 0 cost — +the Task 2 rows in Appendix A. Fix both now: `gameRules > cost math matches v1.1 +formulas` (rescale the expected `costAt`/`costForN`/`maxAffordable` values by +5/4 — the formulas are unchanged) and `reducer: buy (tiers) > buy 1 tier deducts +exact cost` (tier 0 now costs 5). + +- [ ] **Step 5: Commit** + +```bash +git add shared/gameData.js tests/gameData.test.js tests/gameRules.test.js tests/reducer.economy.test.js +git commit -m "v1.12 Task 2: recalibrated tier cost curve" +``` + +--- + +## Task 3: Config-driven heat curve (§4.5) + +**Files:** +- Modify: `shared/gameRules.js` (`computeEffects`) +- Test: `tests/gameRules.test.js` + +**Interfaces:** +- Consumes: `config.heat.{discountFloor,thermalPerLevel,heatsinkPerLevel,autoVentPerLevel}` (Task 1). +- Produces: `computeEffects(meta, config).heatDiscount` and `.autoVentPerSec`, unchanged names. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/gameRules.test.js`: + +```js +describe('v1.12 heat curve is config-driven', () => { + const meta = (upgrades = {}, shardUpgrades = {}) => ({ + upgrades, shardUpgrades, level: 0, legacyCores: 0, + coldStorage: { upgrades: {} }, + }); + + it('reads per-level rates and the floor from config', () => { + const eff = computeEffects(meta({ thermal: 8, autovent: 8 }, { heatsink: 4 }), DEFAULT_CONFIG); + // 1 - 0.05*8 - 0.15*4 = 0, clamped to the 0.40 floor + expect(eff.heatDiscount).toBeCloseTo(0.40); + expect(eff.autoVentPerSec).toBeCloseTo(32); + }); + + it('an un-upgraded save generates full heat and vents nothing passively', () => { + const eff = computeEffects(meta(), DEFAULT_CONFIG); + expect(eff.heatDiscount).toBeCloseTo(1); + expect(eff.autoVentPerSec).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: FAIL — `heatDiscount` is 0.15 (hardcoded floor), `autoVentPerSec` is 4. + +- [ ] **Step 3: Make the heat curve read config** + +In `shared/gameRules.js`, inside `computeEffects`, replace: + +```js + heatDiscount: Math.max(0.15, 1 - 0.08 * (lv.thermal || 0) - 0.25 * (sv.heatsink || 0)), +``` + +with: + +```js + heatDiscount: Math.max(config.heat.discountFloor, + 1 - config.heat.thermalPerLevel * (lv.thermal || 0) + - config.heat.heatsinkPerLevel * (sv.heatsink || 0)), +``` + +and replace: + +```js + autoVentPerSec: 0.5 * (lv.autovent || 0), +``` + +with: + +```js + autoVentPerSec: config.heat.autoVentPerLevel * (lv.autovent || 0), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add shared/gameRules.js tests/gameRules.test.js +git commit -m "v1.12 Task 3: config-driven heat curve" +``` + +--- + +## Task 4: Cap the account-level output bonus (§4.8) + +`levelBonusMult` was `1 + 0.02 * level` with no cap, and the repeatable goals never run out — so level, and therefore output, grew forever. + +**Files:** +- Modify: `shared/gameRules.js` (`computeEffects`) +- Test: `tests/gameRules.test.js` + +**Interfaces:** +- Consumes: `config.production.{levelBonusPerLevel,levelBonusMaxLevel}` (Task 1). +- Produces: `computeEffects(...).levelBonusMult`, unchanged name. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/gameRules.test.js`: + +```js +describe('v1.12 level bonus is capped', () => { + const meta = (level) => ({ + upgrades: {}, shardUpgrades: {}, level, legacyCores: 0, + coldStorage: { upgrades: {} }, + }); + + it('scales below the cap', () => { + expect(computeEffects(meta(50), DEFAULT_CONFIG).levelBonusMult).toBeCloseTo(1 + 0.02 * 50); + }); + + it('stops scaling at the cap', () => { + const atCap = computeEffects(meta(200), DEFAULT_CONFIG).levelBonusMult; + const wayPast = computeEffects(meta(5000), DEFAULT_CONFIG).levelBonusMult; + expect(atCap).toBeCloseTo(1 + 0.02 * 200); + expect(wayPast).toBeCloseTo(atCap); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: FAIL — level 5000 yields 101, not 5. + +- [ ] **Step 3: Apply the cap** + +In `computeEffects`, replace: + +```js + levelBonusMult: 1 + 0.02 * (meta.level || 0), +``` + +with: + +```js + levelBonusMult: 1 + config.production.levelBonusPerLevel + * Math.min(meta.level || 0, config.production.levelBonusMaxLevel), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add shared/gameRules.js tests/gameRules.test.js +git commit -m "v1.12 Task 4: cap the account-level output bonus" +``` + +--- + +## Task 5: Migrate gain and the Legacy Core cap (§4.3a, §4.3b) + +The heart of the release. `migrateGain` gains a `config` parameter — update **all three** call sites. + +**Files:** +- Modify: `shared/gameRules.js` (`migrateGain`, `computeMults`) +- Modify: `shared/reducer.js:143` (pass `config`) +- Modify: `client/src/RackStack.jsx:1096` (pass `config.data`) +- Test: `tests/gameRules.test.js` + +**Interfaces:** +- Consumes: `config.prestige.{migrateDivisor,migrateExponent,corePercentPerCore,coreBonusCap}` (Task 1). +- Produces: `migrateGain(lifetimeRun, legacyGainMult, config) -> number`. `computeMults(meta, config, boostMult)` return shape unchanged. + +- [ ] **Step 1: Write the failing test** + +Replace the two `migrateGain` assertions in the existing `'xp and migrate math'` test in `tests/gameRules.test.js` with: + +```js + it('xp and migrate math', () => { + expect(xpForLevel(0)).toBe(50); + // v1.12: (L / 2e12) ** 1.0. Below the divisor there is nothing to claim yet. + expect(migrateGain(1e6, 1, DEFAULT_CONFIG)).toBe(0); + expect(migrateGain(2e12, 1, DEFAULT_CONFIG)).toBe(1); + expect(migrateGain(4e13, 1, DEFAULT_CONFIG)).toBe(20); + expect(migrateGain(0, 1, DEFAULT_CONFIG)).toBe(0); + expect(migrateGain(-5, 1, DEFAULT_CONFIG)).toBe(0); + }); +``` + +And append: + +```js +describe('v1.12 Legacy Core bonus is capped', () => { + const meta = (legacyCores) => ({ + upgrades: {}, shardUpgrades: {}, level: 0, legacyCores, + coldStorage: { upgrades: {} }, + }); + + it('scales below the cap', () => { + const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult; + const b = computeMults(meta(100), DEFAULT_CONFIG).racksMult; + expect(b / a).toBeCloseTo(1 + 0.05 * 100); + }); + + it('plateaus at the cap - this is what makes Singularity necessary', () => { + const a = computeMults(meta(0), DEFAULT_CONFIG).racksMult; + const atCap = computeMults(meta(400), DEFAULT_CONFIG).racksMult; + const wayPast = computeMults(meta(1e9), DEFAULT_CONFIG).racksMult; + expect(atCap / a).toBeCloseTo(1 + 0.05 * 400); + expect(wayPast).toBeCloseTo(atCap); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: FAIL — `migrateGain(1e6, 1, ...)` returns 1 (old `sqrt(L/1e6)`), and the cap test shows an uncapped multiplier. + +- [ ] **Step 3: Rewrite `migrateGain`** + +In `shared/gameRules.js` replace: + +```js +export function migrateGain(lifetimeRun, legacyGainMult) { + return Math.floor(Math.sqrt(lifetimeRun / 1e6) * legacyGainMult); +} +``` + +with: + +```js +/** + * v1.12: was sqrt(lifetimeRun / 1e6), which handed out thousands of cores after + * a single day and made every later prestige explosive. + * + * `migrateDivisor` is fixed by one requirement - the first Migrate should land + * on day 4-8 - which leaves `migrateExponent` as the only dial controlling how + * fast cores climb toward `coreBonusCap`. It has to be near-linear to reach the + * cap at all, and that is safe ONLY because computeMults caps the payoff. If you + * ever remove that cap, this exponent restores the runaway. + */ +export function migrateGain(lifetimeRun, legacyGainMult, config) { + if (!(lifetimeRun > 0)) return 0; + const p = config.prestige; + return Math.floor(Math.pow(lifetimeRun / p.migrateDivisor, p.migrateExponent) * legacyGainMult); +} +``` + +- [ ] **Step 4: Cap the core bonus in `computeMults`** + +Replace: + +```js + const base = (1 + (meta.legacyCores || 0) * 0.05) * eff.firmwareMult * eff.engineMult +``` + +with: + +```js + // v1.12: the core bonus PLATEAUS. Past the cap, extra cores buy no output at + // all - they are only fuel for the next Singularity. That plateau is the gate + // that makes Singularity worth taking (see the spec's finding 2.5). + const pr = config.prestige; + const coreMult = 1 + pr.corePercentPerCore + * Math.min(meta.legacyCores || 0, pr.coreBonusCap); + const base = coreMult * eff.firmwareMult * eff.engineMult +``` + +- [ ] **Step 5: Update both call sites** + +`shared/reducer.js` line ~143: + +```js + const gain = migrateGain(s.run.lifetimeRun, eff.legacyGainMult, config); +``` + +`client/src/RackStack.jsx` line ~1096: + +```js + const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult, config.data); +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run tests/gameRules.test.js tests/reducer.meta.test.js` +Expected: `gameRules` PASS. `reducer.meta` may still fail on the Task 6 assertions — that is expected and fixed next. + +- [ ] **Step 7: Commit** + +```bash +git add shared/gameRules.js shared/reducer.js client/src/RackStack.jsx tests/gameRules.test.js +git commit -m "v1.12 Task 5: migrate gain curve and the Legacy Core cap" +``` + +--- + +## Task 6: Shard tree re-pricing, bootstrap, and echo cores (§4.3c, §4.3e) + +**Files:** +- Modify: `shared/gameData.js` (`SINGULARITY_DEFS` costs, `engine` maxLevel, stale copy) +- Modify: `shared/gameRules.js` (`bootstrapMult`) +- Modify: `shared/reducer.js` (`migrate`'s `echoBonus`) +- Test: `tests/reducer.meta.test.js` + +**Interfaces:** +- Consumes: `config.prestige.echoPercentPerLevel` (Task 1), `migrateGain(...)` (Task 5). +- Produces: `SINGULARITY_DEFS` with `engine.maxLevel === 12`; `computeEffects(...).bootstrapMult === 3 ** level`. + +- [ ] **Step 1: Write the failing test** + +Replace the existing `'applies deepCacheBonus and bootstrapMult...'` test in `tests/reducer.meta.test.js` with: + +```js + it('applies deepCacheBonus and bootstrapMult to start credits, and echoCores as a share of gain', () => { + const s = initialState(); + s.run.lifetimeRun = 4e13; // migrateGain = floor((4e13/2e12)^1) = 20 + s.meta.upgrades.deepcache = 2; // +10 each => +20 + s.meta.shardUpgrades.bootstrap = 1; // v1.12: x3, not x10 + s.meta.shardUpgrades.echocores = 3; // v1.12: +5% of gain per level => +15% of 20 = 3 + const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW); + expect(s2.run.credits).toBe((10 + 20) * 3); + expect(s2.meta.legacyCores).toBe(20 + 3); + }); + + it('echoCores cannot be farmed by cheap repeat Migrates', () => { + const s = initialState(); + s.run.lifetimeRun = 2e12; // gain = 1 + s.meta.shardUpgrades.echocores = 10; // 10 levels => +50% of gain => floor(0.5) = 0 + const { state: s2 } = applyAction(s, { type: 'migrate' }, DEFAULT_CONFIG, NOW); + expect(s2.meta.legacyCores).toBe(1); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: FAIL — credits are `(10+20)*10` and cores are `20 + 3` via the flat bonus. + +- [ ] **Step 3: Re-price the shard tree and fix the stale copy** + +In `shared/gameData.js`, replace `SINGULARITY_DEFS` with: + +```js +export const SINGULARITY_DEFS = [ + { id: 'bootstrap', name: 'Quantum Bootstrap', desc: 'Starting Compute Balance after Migrate x3 per level', baseCost: 3, costMult: 2.2, maxLevel: 5 }, + { id: 'temporal', name: 'Temporal Compression', desc: 'Legacy Core gain from Migrate +25% per level', baseCost: 4, costMult: 2.4, maxLevel: 5 }, + // v1.12: maxLevel 8 -> 12. At 8 levels "tier 13 is reachable" and "the shard + // tree is still a goal at day 45" are mutually exclusive - every setting that + // reached tier 13 also maxed the tree. The longer tail decouples them: the x5 + // that powers the late tiers costs ~7% of the tree. + { id: 'engine', name: 'Singularity Engine', desc: '+50% output on every lane per level', baseCost: 6, costMult: 1.9, maxLevel: 12 }, + { id: 'heatsink', name: 'Heat Sink Mastery', desc: 'Overclock Bay heat generation -15% per level', baseCost: 3, costMult: 2.2, maxLevel: 4 }, + { id: 'infiniteloop', name: 'Infinite Loop', desc: 'Milestone thresholds -10% per level, easier to reach', baseCost: 5, costMult: 2.5, maxLevel: 5 }, + { id: 'echocores', name: 'Echo Cores', desc: 'Migrate grants +5% bonus Legacy Cores per level', baseCost: 4, costMult: 1.8, maxLevel: 10 }, +]; +``` + +Note the three copy fixes: bootstrap is now x3, Heat Sink is -15% (Task 3), and Echo Cores is proportional. Also update `UPGRADE_DEFS`' `thermal` description to `'Overclock Bay heat generation -5% per level'`. + +- [ ] **Step 4: Weaken bootstrap and make echo cores proportional** + +`shared/gameRules.js`: + +```js + bootstrapMult: Math.pow(3, sv.bootstrap || 0), +``` + +`shared/reducer.js`, in `migrate`, replace: + +```js + const echoBonus = eff.echoCoresBonus || 0; +``` + +with: + +```js + // v1.12: a share of the gain, not a flat grant. A flat +10 cores per Migrate is + // farmable once cores are scarce - migrate cheaply, repeatedly, for free cores. + const echoBonus = Math.floor(gain * config.prestige.echoPercentPerLevel * (eff.echoCoresBonus || 0)); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: the two tests above PASS. Also fix the Task 6 row in Appendix A: +`reducer: migrate > happy path: fresh run with deepcache/bootstrap start +credits...` breaks because bootstrap is now x3 and echo cores are proportional — +recompute its expected credits and cores against the new formulas. The +singularity tests still fail; that is Task 7. + +- [ ] **Step 6: Commit** + +```bash +git add shared/gameData.js shared/gameRules.js shared/reducer.js tests/reducer.meta.test.js +git commit -m "v1.12 Task 6: shard tree re-pricing, bootstrap, proportional echo cores" +``` + +--- + +## Task 7: Singularity yield linear in cores (§4.3d) + +With cores capped at 400, `floor(sqrt(cores))` returns 20 shards against a 17,277-shard tree — the meta layer can never progress. `singularity` gains `action` and `config` params; it is registered in `HANDLERS` and already invoked as `handler(s, action, config, now, rng)`, so no call-site change is needed on the server. + +**Files:** +- Modify: `shared/reducer.js` (`singularity`) +- Modify: `client/src/RackStack.jsx:1097` (duplicated formula) +- Test: `tests/reducer.meta.test.js` + +**Interfaces:** +- Consumes: `config.prestige.shardsPerCore` (Task 1). +- Produces: `singularity(s, action, config) -> { ok, shardsGained }`. + +- [ ] **Step 1: Write the failing test** + +Replace the `'happy path: resets run + legacyCores...'` test in `tests/reducer.meta.test.js` with: + +```js + it('happy path: resets run + legacyCores, grants shards, bumps stats.singularities', () => { + const s = initialState(); + s.meta.legacyCores = 400; // v1.12: floor(400 * 0.4) = 160 + s.run.tiers[0].owned = 3; + s.meta.wafers = 42; // untouched + const { state: s2, result } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW); + expect(result.ok).toBe(true); + expect(s2.meta.legacyCores).toBe(0); + expect(s2.meta.singularityShards).toBe(160); + expect(s2.meta.stats.singularities).toBe(1); + expect(s2.run.tiers[0].owned).toBe(0); + expect(s2.meta.wafers).toBe(42); + }); + + it('yield is linear in cores, so a capped core pool still funds the tree', () => { + const s = initialState(); + s.meta.legacyCores = 800; + const { state: s2 } = applyAction(s, { type: 'singularity' }, DEFAULT_CONFIG, NOW); + expect(s2.meta.singularityShards).toBe(320); // 2x the cores => 2x the shards + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: FAIL — 400 cores yields 20 shards, not 160. + +- [ ] **Step 3: Make the yield linear** + +In `shared/reducer.js` replace: + +```js +function singularity(s) { + const shardsGained = Math.floor(Math.sqrt(s.meta.legacyCores || 0)); +``` + +with: + +```js +// v1.12: linear in cores, not sqrt. Once legacyCores is capped (computeMults), +// the square root has nothing left to damp and only starves the shard tree - +// 400 cores returned 20 shards against a 17k-shard tree, so the tree could never +// progress and the late tiers kept no engine. +function singularity(s, action, config) { + const shardsGained = Math.floor((s.meta.legacyCores || 0) * config.prestige.shardsPerCore); +``` + +- [ ] **Step 4: Update the client's duplicate** + +`client/src/RackStack.jsx` line ~1097: + +```js + const singularityGain = Math.floor((state.meta.legacyCores || 0) * config.data.prestige.shardsPerCore); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: PASS, once you also fix the Task 7 row in Appendix A — +`bestLegacyCores > survives Migrate then Singularity applied in ONE batch` +asserts specific core/shard numbers that move with the new yield. The behaviour +under test (the peak survives a batched Migrate+Singularity) must still hold; +only the numbers change. + +- [ ] **Step 6: Commit** + +```bash +git add shared/reducer.js client/src/RackStack.jsx tests/reducer.meta.test.js +git commit -m "v1.12 Task 7: Singularity yield linear in cores" +``` + +--- + +## Task 8: Decouple anomaly payout from boost duration (§4.2) + +The headline defect: `eventRewardMult` (Signal Boost) scaled the boost's *duration* as well as its payout, so at max level a 2–4x global multiplier ran permanently. + +**Files:** +- Modify: `shared/reducer.js` (`claimAnomaly`) +- Test: `tests/reducer.meta.test.js` + +**Interfaces:** +- Consumes: `config.anomaly.*` (Task 1). +- Produces: unchanged reward shapes — `{ kind: 'credits', amount }` and `{ kind: 'boost', mult, until }`. + +- [ ] **Step 1: Write the failing test** + +Replace the two anomaly tests at `tests/reducer.meta.test.js:223` and `:241` with: + +```js + it('deterministic credits branch (rng=0.1), grants credits, reschedules, then re-claim is cooldown_active', () => { + const s = openState(); + const { state: s2, result } = applyAction(s, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1); + expect(result.ok).toBe(true); + expect(result.reward.kind).toBe('credits'); + // totalOutputPerSec is 0 (no lanes owned) -> amount = max(0, 20) * eventRewardMult(1) = 20 + expect(result.reward.amount).toBeCloseTo(20); + expect(s2.run.credits).toBeCloseTo(10 + 20); + + // v1.12 anomaly cadence: next = now + 420000 + 0.1*(900000-420000) = now + 468000 + expect(s2.server.nextAnomalyAt).toBeCloseTo(NOW + 468000); + expect(s2.server.anomalyExpiresAt).toBeCloseTo(NOW + 468000 + 30000); + + const { result: result2 } = applyAction(s2, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1); + expect(result2).toEqual({ ok: false, error: 'cooldown_active' }); + }); + + it('boost branch (rng=0.9) stores a mult/until boost on server', () => { + const s = openState(); + const { state: s2, result } = applyAction(s, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9); + expect(result.ok).toBe(true); + expect(result.reward.kind).toBe('boost'); + // mult = 1.5 + 0.9*(3.0-1.5) = 2.85 + expect(result.reward.mult).toBeCloseTo(2.85); + // duration = 45000 + 0.9*(75000-45000) = 72000ms + expect(s2.server.boost.until).toBeCloseTo(NOW + 72000); + }); + + it('Signal Boost scales the PAYOUT but never the boost duration', () => { + const withSignal = openState(); + withSignal.meta.upgrades.signal = 10; // eventRewardMult = 3 + const { state: sBoost } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.9); + // identical duration to the un-upgraded save above - this is the whole fix + expect(sBoost.server.boost.until).toBeCloseTo(NOW + 72000); + + const { result: credits } = applyAction(withSignal, { type: 'claimAnomaly' }, DEFAULT_CONFIG, NOW, () => 0.1); + expect(credits.reward.amount).toBeCloseTo(60); // 20 * 3 + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: FAIL — mult is 4 (from the `[2,3,4]` literal) and the Signal Boost test shows a 216000ms duration. + +- [ ] **Step 3: Read the reward magnitudes from config** + +In `shared/reducer.js`, in `claimAnomaly`, replace: + +```js + const seconds = 30 + rng() * 60; +``` + +with: + +```js + const ac = config.anomaly; + const seconds = ac.creditsSecondsMin + rng() * (ac.creditsSecondsMax - ac.creditsSecondsMin); +``` + +and replace: + +```js + const mult = [2, 3, 4][Math.floor(rng() * 3)]; + const duration = (45 + rng() * 30) * eff.eventRewardMult; +``` + +with: + +```js + // v1.12: the boost's DURATION must never be scaled by eventRewardMult. It was, + // and at max Signal Boost that made the duration exceed the respawn interval - + // a permanent 2-4x global multiplier. Signal Boost scales the payout only. + const ab = config.anomaly; + const mult = ab.boostMultMin + rng() * (ab.boostMultMax - ab.boostMultMin); + const duration = (ab.boostDurationMinMs + rng() * (ab.boostDurationMaxMs - ab.boostDurationMinMs)) / 1000; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/reducer.meta.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add shared/reducer.js tests/reducer.meta.test.js +git commit -m "v1.12 Task 8: Signal Boost no longer scales anomaly boost duration" +``` + +--- + +## Task 9: Balance minigame wafer coefficient (§4.7) + +**Files:** +- Modify: `shared/gameRules.js` (`minigameWafers`) +- Test: `tests/gameRules.test.js` + +**Interfaces:** +- Consumes: `config.minigames.balance.waferPerPoint` (Task 1). +- Produces: `minigameWafers(game, metric, meta, config)` — signature unchanged. + +- [ ] **Step 1: Write the failing test** + +Replace the `'minigame payouts'` test in `tests/gameRules.test.js` with: + +```js + it('minigame payouts', () => { + // v1.12 divisors: rush 6, debug 3; balance is a config coefficient + expect(minigameWafers('rush', 40, meta0, DEFAULT_CONFIG)).toBe(6); + expect(minigameWafers('match', 10, meta0, DEFAULT_CONFIG)).toBe(20); + expect(minigameWafers('balance', 150, meta0, DEFAULT_CONFIG)).toBe(30); // 150 * 0.20 + }); + + it('balance payout is config-driven, not a hardcoded 1.5', () => { + const doubled = structuredClone(DEFAULT_CONFIG); + doubled.minigames.balance.waferPerPoint = 0.40; + expect(minigameWafers('balance', 150, meta0, doubled)).toBe(60); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: FAIL — balance returns 225 (`150 * 1.5`). + +- [ ] **Step 3: Read the coefficient from config** + +In `shared/gameRules.js` replace: + +```js + if (game === 'balance') return Math.max(1, Math.floor(metric * 1.5 * lucky)); +``` + +with: + +```js + if (game === 'balance') return Math.max(1, Math.floor(metric * mg.balance.waferPerPoint * lucky)); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/gameRules.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add shared/gameRules.js tests/gameRules.test.js +git commit -m "v1.12 Task 9: balance minigame wafer coefficient is tunable" +``` + +--- + +## Task 10: Drive failure and overheat hit the top owned tier (§4.4) + +**Files:** +- Modify: `shared/outages.js` (`hazardFrom`, `overheatOutage`) +- Test: `tests/outages.test.js` + +**Interfaces:** +- Consumes: `config.risk.{driveFailureTargetsTopTier,overheatTargetsTopTier}` (Task 1). +- Produces: `hazardFrom(scheduledAt, config, state)` and `overheatOutage(state, config, now)` — signatures unchanged. + +- [ ] **Step 1: Write the failing test** + +`tests/outages.test.js` already imports `hazardFrom` from `shared/outages.js` +but does **not** import `overheatOutage`, `DEFAULT_CONFIG` or `initialState`. +Add `overheatOutage` to the existing `shared/outages.js` import list, and add +these two new lines below it: + +```js +import { DEFAULT_CONFIG } from '../shared/configSchema.js'; +import { initialState } from '../shared/state.js'; +``` + +Then append: + +```js +function stateWithTiers(indices) { + const s = initialState(); + for (const i of indices) s.run.tiers[i].owned = 5; + return s; +} + +describe('v1.12 hazards target the top owned tier', () => { + const driveOnly = () => { + const c = structuredClone(DEFAULT_CONFIG); + c.risk.ransomwareEnabled = false; + c.risk.ispOutageEnabled = false; + return c; + }; + + it('drive failure always picks the highest owned tier', () => { + const c = driveOnly(); + const s = stateWithTiers([0, 3, 7]); + for (const at of [1e12, 1e12 + 137, 1e12 + 9999]) { + const h = hazardFrom(at, c, s); + expect(h.kind).toBe('driveFailure'); + expect(h.scope).toEqual({ lane: 'tiers', index: 7 }); + } + }); + + it('the switch restores random targeting', () => { + const c = driveOnly(); + c.risk.driveFailureTargetsTopTier = false; + const s = stateWithTiers([0, 3, 7]); + const picks = new Set([1e12, 2e12, 3e12, 4e12, 5e12].map((at) => hazardFrom(at, c, s).scope.index)); + expect([...picks].every((i) => [0, 3, 7].includes(i))).toBe(true); + }); + + it('overheat downs the top owned tier', () => { + const s = stateWithTiers([0, 2, 9]); + const o = overheatOutage(s, DEFAULT_CONFIG, 1e12); + expect(o.scope).toEqual({ lane: 'tiers', index: 9 }); + expect(o.factor).toBe(0); + expect(o.endAt - o.startAt).toBe(DEFAULT_CONFIG.risk.overheatOutageMs); + }); + + it('overheat with no owned tier still returns null', () => { + expect(overheatOutage(initialState(), DEFAULT_CONFIG, 1e12)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/outages.test.js` +Expected: FAIL — the victim varies with the timestamp. + +- [ ] **Step 3: Target the top owned tier** + +In `shared/outages.js`, in `hazardFrom`'s drive-failure branch, replace: + +```js + scope = { lane: 'tiers', index: owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)] }; +``` + +with: + +```js + // v1.12: the TOP owned tier. A random victim was both unpredictable and + // usually trivial (~1/14 of output); the top tier is legible in the UI and + // actually worth insuring against. `owned` is built in ascending order. + scope = { + lane: 'tiers', + index: config.risk.driveFailureTargetsTopTier + ? owned[owned.length - 1] + : owned[Math.floor(unitAt(scheduledAt, 1) * owned.length)], + }; +``` + +In `overheatOutage`, replace: + +```js + const index = owned[Math.floor(unitAt(now, 2) * owned.length)]; +``` + +with: + +```js + const index = config.risk.overheatTargetsTopTier + ? owned[owned.length - 1] + : owned[Math.floor(unitAt(now, 2) * owned.length)]; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/outages.test.js` +Expected: PASS, including the pre-existing cure-vs-supply property test. Two +pre-existing tests need updating first — the Task 10 rows in Appendix A: +`scopes each kind as the spec table says` (drive failure now targets the highest +owned index) and `reports a rate, never a next time` (`hazardRatePerHour` is now +1/3, was 1/6). + +- [ ] **Step 5: Commit** + +```bash +git add shared/outages.js tests/outages.test.js +git commit -m "v1.12 Task 10: drive failure and overheat hit the top owned tier" +``` + +--- + +## Task 11: Rate-scaled Live Event ladders (§4.6) + +Ladder targets are absolute constants, so every FLOPS rung of every seasonal event clears in under 0.02 seconds. Targets become *seconds of output*, materialised at join time — the same snapshot pattern `rolloverContracts` already uses, and for the same reason: a rate-scaled target recomputed on every read would recede as fast as the player approached it. + +**Files:** +- Modify: `shared/events.js` (`validateLadder`, `rungProgress`) +- Modify: `server/eventService.js` (`joinEventIfEligible`) +- Modify: `shared/reducer.js` (`claimEventRung`) +- Modify: `server/data/seasonalEvents.js` +- Test: `tests/events.test.js` + +**Interfaces:** +- Consumes: `goalCtx(state, config, now).totalOutputPerSec`. +- Produces: `rungProgress(rung, meta, baseline, materialisedTarget)` — a 4th optional param; `meta.eventProgress.targets: number[]` parallel to the ladder. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/events.test.js`. It already imports `validateLadder` and +`rungProgress` — do not re-import them. + +```js +describe('v1.12 rate-scaled event rungs', () => { + it('accepts a secondsOfOutput unit', () => { + expect(validateLadder([ + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 10 } }, + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 20 } }, + ])).toEqual({ ok: true }); + }); + + it('rejects an unknown unit', () => { + const r = validateLadder([{ metric: 'flopsEarned', target: 600, unit: 'furlongs', reward: { wafers: 10 } }]); + expect(r.ok).toBe(false); + expect(r.errors[0]).toMatch(/unit/); + }); + + it('still requires targets to strictly increase within a (metric, unit) pair', () => { + const r = validateLadder([ + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 10 } }, + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } }, + ]); + expect(r.ok).toBe(false); + }); + + it('uses the materialised target when one is supplied', () => { + const rung = { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: {} }; + const meta = { stats: { lifetimeFlopsAllTime: 5_000_000 } }; + // 600s of output at 10k/s = 6,000,000 - not met by 5,000,000 + expect(rungProgress(rung, meta, {}, 6_000_000).met).toBe(false); + expect(rungProgress(rung, meta, {}, 4_000_000).met).toBe(true); + }); + + it('falls back to the literal target for absolute rungs', () => { + const rung = { metric: 'minigamesWon', target: 5, reward: {} }; + expect(rungProgress(rung, { stats: { minigamesWon: 5 } }, {}).met).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/events.test.js` +Expected: FAIL — `validateLadder` has no `unit` concept and `rungProgress` takes three params. + +- [ ] **Step 3: Teach `validateLadder` about `unit`** + +In `shared/events.js`, add near `REWARD_KEYS`: + +```js +const RUNG_UNITS = ['absolute', 'secondsOfOutput']; +``` + +Inside `validateLadder`'s `forEach`, after the `metric` check add: + +```js + const unit = rung.unit === undefined ? 'absolute' : rung.unit; + if (!RUNG_UNITS.includes(unit)) { + errors.push(`rung ${i}: unknown unit ${rung.unit}`); + } +``` + +and change the strictly-increasing key from `metric` to `` `${metric}:${unit}` `` so a ladder may carry both an absolute and a rate-scaled series for the same metric: + +```js + const key = `${metric}:${unit}`; + const prev = Object.prototype.hasOwnProperty.call(lastTargetByMetric, key) ? lastTargetByMetric[key] : -Infinity; + if (target <= prev) errors.push(`rung ${i}: target must strictly increase within metric ${metric}`); + lastTargetByMetric[key] = target; +``` + +- [ ] **Step 4: Let `rungProgress` take a materialised target** + +Replace `rungProgress` with: + +```js +/** + * `materialisedTarget` is the join-time snapshot from meta.eventProgress.targets + * (see server/eventService.js). It is passed for EVERY rung, absolute or not, so + * this function never needs to know the player's output. When it is absent - an + * older save that joined before v1.12 - fall back to the literal target, which + * is exactly the pre-v1.12 behaviour. + */ +export function rungProgress(rung, meta, baseline, materialisedTarget) { + const value = eventMetricValue(rung.metric, meta) ?? 0; + const hasBaseline = baseline && typeof rung.metric === 'string' + && Object.prototype.hasOwnProperty.call(baseline, rung.metric); + const base = hasBaseline ? baseline[rung.metric] : 0; + const current = Math.max(0, value - (typeof base === 'number' ? base : 0)); + const target = Number.isFinite(materialisedTarget) ? materialisedTarget : rung.target; + return { current, target, met: current >= target }; +} +``` + +- [ ] **Step 5: Materialise targets at join time** + +In `server/eventService.js`, add the import: + +```js +import { goalCtx } from '../shared/goals.js'; +``` + +`joinEventIfEligible` needs the config to build `goalCtx`. Change its signature to +`joinEventIfEligible(userId, state, now = Date.now(), config = null)` and update the +single call site in `server/stateService.js` to `joinEventIfEligible(userId, state, now, config)`. + +Then, just before the `state.meta.eventProgress = {...}` assignment, add: + +```js + // Materialise each rung's effective target ONCE, at join. A rate-scaled target + // recomputed on every read would recede as fast as the player approached it - + // the same reasoning as rolloverContracts' snapshot. + const outputPerSec = config ? goalCtx(state, config, now).totalOutputPerSec : 0; + const targets = (activeEvent.ladder || []).map((rung) => ( + rung.unit === 'secondsOfOutput' ? rung.target * outputPerSec : rung.target + )); +``` + +and include `targets` in the record: + +```js + state.meta.eventProgress = { + eventId: activeEvent.id, + joinedAt: now, + endsAt, + baseline, + targets, + rungsClaimed: [], + }; +``` + +- [ ] **Step 6: Pass the materialised target through `claimEventRung`** + +In `shared/reducer.js`, in `claimEventRung`, replace: + +```js + } else if (!rungProgress(ladder[index], s.meta, ep.baseline).met) { +``` + +with: + +```js + } else if (!rungProgress(ladder[index], s.meta, ep.baseline, + Array.isArray(ep.targets) ? ep.targets[index] : undefined).met) { +``` + +- [ ] **Step 7: Reseed the seasonal ladders** + +In `server/data/seasonalEvents.js`, convert every `flopsEarned` rung to `unit: 'secondsOfOutput'` and raise the count-based rungs to match the retuned economy. For Summer Surge (14 days) the ladder becomes: + +```js + ladder: [ + { metric: 'wafersEarned', target: 400, reward: { wafers: 20 } }, + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } }, + { metric: 'minigamesWon', target: 10, reward: { wafers: 15 } }, + { metric: 'tapesEarned', target: 150, reward: { tapes: 10 } }, + { metric: 'blocksClaimed', target: 10, reward: { wafers: 25 } }, + { metric: 'wafersEarned', target: 1500, reward: { wafers: 40 } }, + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 35 } }, + { metric: 'minigamesWon', target: 30, reward: { wafers: 35 } }, + { metric: 'tapesEarned', target: 500, reward: { tapes: 30 } }, + { metric: 'blocksClaimed', target: 30, reward: { wafers: 75, tapes: 20 } }, + ], +``` + +Spooky Packets (8 days, minigame-themed): + +```js + ladder: [ + { metric: 'minigamesWon', target: 12, reward: { wafers: 15 } }, + { metric: 'tapesEarned', target: 100, reward: { tapes: 8 } }, + { metric: 'wafersEarned', target: 250, reward: { wafers: 20 } }, + { metric: 'blocksClaimed', target: 6, reward: { wafers: 15 } }, + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 15 } }, + { metric: 'minigamesWon', target: 36, reward: { wafers: 40 } }, + { metric: 'tapesEarned', target: 300, reward: { tapes: 20 } }, + { metric: 'wafersEarned', target: 900, reward: { wafers: 45 } }, + { metric: 'blocksClaimed', target: 18, reward: { tapes: 35 } }, + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 60 } }, + ], +``` + +Black Frame Friday (4 days, short and brutal): + +```js + ladder: [ + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 20 } }, + { metric: 'wafersEarned', target: 150, reward: { wafers: 25 } }, + { metric: 'tapesEarned', target: 50, reward: { tapes: 15 } }, + { metric: 'blocksClaimed', target: 3, reward: { wafers: 20 } }, + { metric: 'minigamesWon', target: 4, reward: { wafers: 20 } }, + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 55 } }, + { metric: 'wafersEarned', target: 500, reward: { wafers: 60 } }, + { metric: 'tapesEarned', target: 160, reward: { tapes: 45 } }, + { metric: 'blocksClaimed', target: 9, reward: { tapes: 60 } }, + { metric: 'minigamesWon', target: 12, reward: { wafers: 50, tapes: 30 } }, + ], +``` + +Frost Uptime (21 days, cold-storage themed): + +```js + ladder: [ + { metric: 'blocksClaimed', target: 20, reward: { wafers: 20 } }, + { metric: 'tapesEarned', target: 250, reward: { tapes: 20 } }, + { metric: 'wafersEarned', target: 600, reward: { wafers: 30 } }, + { metric: 'flopsEarned', target: 600, unit: 'secondsOfOutput', reward: { wafers: 25 } }, + { metric: 'minigamesWon', target: 15, reward: { wafers: 25 } }, + { metric: 'blocksClaimed', target: 60, reward: { tapes: 50 } }, + { metric: 'tapesEarned', target: 800, reward: { tapes: 60 } }, + { metric: 'wafersEarned', target: 2200, reward: { wafers: 80 } }, + { metric: 'flopsEarned', target: 1800, unit: 'secondsOfOutput', reward: { wafers: 70 } }, + { metric: 'minigamesWon', target: 45, reward: { wafers: 100, tapes: 80 } }, + ], +``` + +Note every `flops` **reward** has been converted to wafers/tapes. A literal FLOPS +reward has exactly the scaling problem the targets did — 8,000 FLOPS is +meaningless the moment output passes a few thousand per second. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx vitest run tests/events.test.js tests/db.events.test.js tests/api.events.test.js tests/eventService.test.js` +Expected: PASS. `tests/db.events.test.js` asserts every seeded ladder passes `validateLadder`. + +- [ ] **Step 9: Commit** + +```bash +git add shared/events.js shared/reducer.js server/eventService.js server/stateService.js server/data/seasonalEvents.js tests/events.test.js +git commit -m "v1.12 Task 11: event ladders scale with output" +``` + +--- + +## Task 12: The meltdown modal names the cause and the victim (§4.5) + +Finding 2.7 is as much a legibility failure as a math one — and the shipped copy is now actively wrong: it says *"the lane is frozen for a short cooldown - no nodes were lost"*, which describes pre-v1.11 behaviour. Since v1.11 an overheat downs a rack tier. + +**Files:** +- Modify: `shared/state.js` (`overheated` carries the downed tier) +- Modify: `client/src/RackStack.jsx` (three `setModal({ type: 'meltdown' })` sites) +- Modify: `client/src/game/components/modals/MessageModal.jsx` +- Test: `tests/state.test.js` + +**Interfaces:** +- Consumes: `overheatOutage(...)` returning the outage (Task 10). +- Produces: `state.server.overheated` is now `{ tierIndex: number } | true` — still truthy, so existing `if (server.overheated)` checks keep working. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/state.test.js`. It already imports `DEFAULT_CONFIG`, +`initialState` and `evaluate` — no new imports are needed. + +```js +describe('v1.12 overheat reports which tier went dark', () => { + it('carries the downed tier index on the one-shot signal', () => { + const s = initialState(); + s.run.tiers[0].owned = 10; + s.run.tiers[4].owned = 3; + s.run.overclock[0].owned = 500; // enough heat to cross the cap + s.run.heat = DEFAULT_CONFIG.heat.capacity - 1; + const now = Date.now(); + const { state: s2 } = evaluate(s, DEFAULT_CONFIG, now - 5000, now); + expect(s2.server.overheated).toEqual({ tierIndex: 4 }); + expect(s2.server.outages.some((o) => o.kind === 'overheat' && o.scope.index === 4)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/state.test.js` +Expected: FAIL — `overheated` is `true`, not `{ tierIndex: 4 }`. + +- [ ] **Step 3: Carry the tier index on the signal** + +In `shared/state.js`, in the online branch's overheat handling, replace: + +```js + s.run.heat = 0; + s.server.overheated = true; + // ... comment ... + if (!overheatOutage(s, config, now)) { + s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs; + } +``` + +with: + +```js + s.run.heat = 0; + // v1.12: carry WHICH tier went dark so the client can name it. Still + // truthy either way, so existing `if (server.overheated)` checks hold. + const downed = overheatOutage(s, config, now); + s.server.overheated = downed ? { tierIndex: downed.scope.index } : true; + if (!downed) { + s.run.heatCooldownUntil = now + config.heat.overheatCooldownMs; + } +``` + +- [ ] **Step 4: Pass it to the modal** + +In `client/src/RackStack.jsx`, at each of the three sites, replace +`setModal({ type: 'meltdown' })` with: + +```js +setModal({ type: 'meltdown', tierIndex: serverState.server.overheated?.tierIndex }); +``` + +(at line ~529 the variable is `initial`, and at ~569 it is `next` — use the matching name). + +- [ ] **Step 5: Rewrite the copy** + +In `client/src/game/components/modals/MessageModal.jsx`, the `meltdown` case must accept the tier. Add `TIER_DEFS` to the imports: + +```js +import { TIER_DEFS } from '@shared/gameData.js'; +``` + +and replace the case body with: + +```js + case 'meltdown': { + const downed = typeof modal.tierIndex === 'number' ? TIER_DEFS[modal.tierIndex] : null; + return ( + <> +

Overheated!

+

+ {downed + ? `Your Overclock Bay hit 100% heat and took your ${downed.name} offline while it cools. Overclocking is what multiplies your racks, so running it hot risks the very thing it amplifies.` + : 'Your Overclock Bay hit 100% heat and the lane is frozen while it cools.'} + {' '}Vent before the gauge fills, or invest in Thermal Regulators / Auto-Vent to raise the fleet you can run unattended. +

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