// Guards against the game being finished too quickly, or running away. // Background: the first legacy system multiplied output by 1.05 per point, so a // few thousand points gave 1e100 and a run took seconds (a player reported // 6e136 units/s two minutes into their fourth game). import assert from 'node:assert/strict'; import { test } from 'node:test'; import { runCampaign } from '../scripts/campaign.js'; import { derive } from '../public/js/engine/engine.js'; import { legacyGain, pointsFor } from '../public/js/engine/legacy.js'; import { modifiersFor, passiveBonus } from '../public/js/engine/modifiers.js'; import { createState, sanitizeState, serializeState, STATE_VERSION } from '../public/js/engine/state.js'; import { loadContext } from './helpers.js'; const ctx = loadContext(); const NOW = 1_800_000_000_000; // --------------------------------------------------------- the reported save /** What the broken version could leave behind after a few restarts. */ function runawaySave(version) { return { v: version, units: 1e130, funds: 1e135, material: 1e140, totalProduced: 1e137, totalSold: 1e136, price: 0.25, marketing: 120, generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 40])), upgrades: ctx.upgrades.map((u) => u.id), endless: { e_global: 400 }, lifetimeProduced: 1e139, research: ['r_prod_1', 'r_mkt_1'], labs: 7, rp: 123, achievements: ['a_prod_1', 'a_gen_1'], stats: { gambleWins: 3 }, legacy: { points: 4000, earned: 5000, prestiges: 3, perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])), }, savedAt: NOW - 1000, }; } test('a save corrupted by the old runaway bonus is repaired on load', () => { const s = sanitizeState(runawaySave(2), ctx, NOW); assert.equal(s.repaired, true); assert.equal(s.v, STATE_VERSION); // legacy is capped, research and achievements are kept assert.ok(s.legacy.earned <= 10); assert.equal(s.legacy.points, 0); assert.equal(s.legacy.prestiges, 3); assert.ok(Object.values(s.legacy.perks).every((level) => level <= 3)); assert.deepEqual(s.research, ['r_prod_1', 'r_mkt_1']); assert.deepEqual(s.achievements, ['a_prod_1', 'a_gen_1']); assert.equal(s.labs, 7); assert.equal(s.rp, 123); assert.equal(s.stats.gambleWins, 3); // the meaningless run starts over assert.equal(s.funds, 0); assert.equal(s.totalProduced, 0); assert.deepEqual(s.upgrades, []); assert.deepEqual(s.endless, {}); assert.ok(Object.values(s.generators).every((n) => n === 0)); // the huge lifetime total cannot hand out a pile of points at the next restart assert.ok(legacyGain(s, ctx, modifiersFor(s, ctx)) <= 3); assert.ok(pointsFor(s.lifetimeProduced, ctx, modifiersFor(s, ctx)) <= s.legacy.earned + 3); }); test('a repaired save plays at a normal speed', () => { const s = sanitizeState(runawaySave(2), ctx, NOW); s.generators.gen1 = 10; const rate = derive(s, ctx).rate; assert.ok(Number.isFinite(rate) && rate < 1000, `production is ${rate}/s`); assert.ok(modifiersFor(s, ctx).global < 100); }); test('the repair happens once: a repaired save loads unchanged', () => { const s = sanitizeState(runawaySave(2), ctx, NOW); const again = sanitizeState(JSON.parse(serializeState(s)), ctx, NOW); assert.equal(again.repaired, false); assert.deepEqual(again.legacy, s.legacy); // saves keep 10 significant digits assert.ok(Math.abs(again.lifetimeProduced / s.lifetimeProduced - 1) < 1e-9); }); test('an old but honest save keeps its legacy progress (only the lifetime total is capped)', () => { const save = { ...createState(ctx, NOW), v: 2 }; save.legacy = { points: 2, earned: 3, prestiges: 3, perks: { lg_prod: 2 } }; save.generators.gen1 = 5; save.totalProduced = 5e9; save.lifetimeProduced = 2e10; const s = sanitizeState(JSON.parse(serializeState(save)), ctx, NOW); assert.equal(s.repaired, false); assert.deepEqual(s.legacy, save.legacy); assert.equal(s.generators.gen1, 5, 'the run is untouched'); assert.equal(s.lifetimeProduced, 2e10); }); test('a current save is never touched, however big', () => { const save = sanitizeState(runawaySave(STATE_VERSION), ctx, NOW); assert.equal(save.repaired, false); assert.equal(save.legacy.earned, 5000); }); test('an export from an older version is repaired too', () => { const s = sanitizeState(runawaySave(1), ctx, NOW); assert.equal(s.repaired, true); }); // ------------------------------------------------------------- hard bounds test('everything permanent, at its maximum, is a bounded boost', () => { const s = createState(ctx, NOW); s.research = ctx.research.map((r) => r.id); s.achievements = ctx.achievements.map((a) => a.id); s.legacy = { points: 0, earned: 1e6, // a million points: the number that used to give 1e100 at 5000 prestiges: 50, perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])), }; const m = modifiersFor(s, ctx); assert.ok(m.global < 1e5, `global x${m.global}`); assert.ok(m.demand < 1e5, `demand x${m.demand}`); assert.ok(passiveBonus(s, ctx) < 1000); }); test('points need ever more production: a trillion times more gives far fewer than a trillion times the points', () => { const m = modifiersFor(createState(ctx), ctx); const base = pointsFor(ctx.rules.legacy.threshold, ctx, m); const huge = pointsFor(ctx.rules.legacy.threshold * 1e12, ctx, m); assert.equal(base, 1); assert.ok(huge < 1e6, `${huge} points`); }); // --------------------------------------------------------------- the pacing test('the game is not finished quickly: 40 hours of efficient play', () => { const { state, restarts, goals, probes } = runCampaign(ctx, { hours: 40, dt: 10 }); assert.ok(goals.get('first restart') >= 6, `first restart after ${goals.get('first restart')?.toFixed(1)} h`); assert.ok(restarts.length < 5, `${restarts.length} restarts in 40 h`); assert.ok(!goals.has('ALL research nodes'), 'all the research is not done in 40 h'); assert.ok(!goals.has('ONE perk maxed'), 'no perk is maxed in 40 h'); assert.ok(Math.max(...Object.values(state.legacy.perks), 0) <= 4, 'perks are still low'); // but it does move: this is not a wall either assert.ok(state.research.length >= 5, 'research progresses'); assert.ok(restarts.length >= 1, 'a restart is reachable within 40 h'); assert.ok(state.lifetimeProduced > 1e13); // a new run after a restart is quicker, but never absurd (it once reached 6e136/s in 2 minutes) for (const probe of probes) { assert.ok(Number.isFinite(probe.rate), 'finite production'); if (probe.minutes === 2) assert.ok(probe.rate < 1e6, `run ${probe.run}: ${probe.rate}/s after 2 min`); if (probe.minutes === 60) assert.ok(probe.rate < 1e12, `run ${probe.run}: ${probe.rate}/s after 1 h`); } for (const value of [state.funds, state.units, state.material, state.totalProduced]) { assert.ok(Number.isFinite(value)); } });