import assert from 'node:assert/strict'; import { test } from 'node:test'; import { checkAchievements } from '../public/js/engine/achievements.js'; import { abandonContract, acceptContract, claimEvent, stepEvents } from '../public/js/engine/contracts.js'; import { advance, derive, simulateOffline } from '../public/js/engine/engine.js'; import { chipDemandBonus } from '../public/js/engine/formulas.js'; import { buyChip, buyReliability, chipCost, gamble, odds, reliabilityCost, } from '../public/js/engine/gamble.js'; import { buyShares, holdingValue, portfolio, sellShares } from '../public/js/engine/investments.js'; import { buyPerk, legacyGain, perkCost, prestige } from '../public/js/engine/legacy.js'; import { modifiersFor } from '../public/js/engine/modifiers.js'; import { buyLab, buyResearch, labCost, researchStatus } from '../public/js/engine/research.js'; import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js'; import { loadContext } from './helpers.js'; const ctx = loadContext(); const fresh = () => createState(ctx, 1_000_000); const mods = (s) => modifiersFor(s, ctx); const seeded = (seed) => () => (seed = (seed * 16807) % 2147483647) / 2147483647; const constant = (v) => () => v; // ------------------------------------------------------------------ investments test('investments reprice every 60 s, within the tier swing, and pay dividends', () => { const s = fresh(); s.invest.holdings.inv_low_1 = 10; s.invest.basis.inv_low_1 = 1000; const funds = s.funds; advance(s, ctx, 59, constant(1)); assert.equal(s.invest.prices.inv_low_1, 100, 'nothing before the interval'); advance(s, ctx, 1, constant(1)); const swing = ctx.investments.tiers.low.swing; const reversion = ctx.investments.tiers.low.reversion; const expected = 100 * (1 + swing) + (100 - 100 * (1 + swing)) * reversion; assert.ok(Math.abs(s.invest.prices.inv_low_1 - expected) < 1e-9, 'top of the ±5 % swing, pulled back a little'); assert.ok(s.funds > funds, 'dividend paid'); }); test('each tier swings as widely as advertised (±5 %, ±15 %, ±50 %)', () => { const widest = {}; const s = fresh(); const rng = seeded(11); for (let i = 0; i < 3000; i++) { for (const soc of ctx.investments.societies) s.invest.prices[soc.id] = soc.basePrice; advance(s, ctx, 60, rng); for (const soc of ctx.investments.societies) { widest[soc.tier] = Math.max(widest[soc.tier] ?? 0, Math.abs(s.invest.change[soc.id])); } } for (const [tier, { swing }] of Object.entries(ctx.investments.tiers)) { assert.ok(widest[tier] <= swing + 1e-9, `${tier} never exceeds its swing`); assert.ok(widest[tier] > swing * 0.9, `${tier} does use its range`); } }); test('prices stay inside their bounds over a long time', () => { const s = fresh(); advance(s, ctx, 60 * 3000, seeded(5)); for (const soc of ctx.investments.societies) { const price = s.invest.prices[soc.id]; assert.ok(price >= soc.basePrice * ctx.investments.minRatio - 1e-9); assert.ok(price <= soc.basePrice * ctx.investments.maxRatio + 1e-9); } }); test('buying and selling shares costs the fee and tracks profit', () => { const s = fresh(); s.funds = 1000; assert.ok(buyShares(s, ctx, 'inv_mid_1', 0.5)); assert.equal(s.funds, 500); const fee = ctx.investments.fee; assert.ok(Math.abs(holdingValue(s, 'inv_mid_1') - 500 * (1 - fee)) < 1e-9); assert.ok(sellShares(s, ctx, 'inv_mid_1', 1)); assert.ok(Math.abs(s.funds - (500 + 500 * (1 - fee) ** 2)) < 1e-9, 'round trip costs about 1 %'); assert.ok(s.stats.investProfit < 0, 'the fees show as a realized loss'); assert.equal(s.invest.holdings.inv_mid_1, 0); assert.equal(portfolio(s, ctx).value, 0); }); test('investing needs funds and a real society', () => { const s = fresh(); assert.equal(buyShares(s, ctx, 'inv_low_1', 1), false); assert.equal(buyShares(s, ctx, 'nope', 1), false); assert.equal(sellShares(s, ctx, 'inv_low_1', 1), false); }); test('a price rise makes a profit once sold', () => { const s = fresh(); s.funds = 1000; buyShares(s, ctx, 'inv_high_1', 1); s.invest.prices.inv_high_1 *= 2; sellShares(s, ctx, 'inv_high_1', 1); assert.ok(s.funds > 1900 && s.stats.investProfit > 900); }); // --------------------------------------------------------------------- gamble test('odds always sum to 1 and improve with reliability', () => { const s = fresh(); const base = odds(s, ctx, mods(s)); assert.ok(Math.abs(base.crash + base.jackpot + base.win + base.lose - 1) < 1e-12); s.reliabilityLevel = 3; assert.ok(odds(s, ctx, mods(s)).win > base.win); s.reliabilityLevel = 6; s.research = ['r_fin_1', 'r_fin_2']; const best = odds(s, ctx, mods(s)); assert.ok(best.win <= ctx.rules.gamble.maxWin - best.crash - best.jackpot + 1e-12); assert.ok(best.lose >= 0); }); test('chips are expensive and each one costs more', () => { const s = fresh(); const first = chipCost(s, ctx); s.chips = 5; assert.ok(chipCost(s, ctx) > first * 3); s.funds = first - 1; s.chips = 0; assert.equal(buyChip(s, ctx), false); s.funds = first; assert.ok(buyChip(s, ctx)); assert.equal(s.chips, 1); assert.equal(s.funds, 0); }); test('reliability upgrades are costly, capped, and paid in funds', () => { const s = fresh(); s.funds = 1e30; const costs = []; for (let i = 0; i < 10; i++) { costs.push(reliabilityCost(s, ctx)); buyReliability(s, ctx); } assert.equal(s.reliabilityLevel, ctx.rules.gamble.reliability.maxLevel); assert.ok(costs[1] / costs[0] >= 5); }); test('every outcome of a cycle', () => { const play = (roll, chips = 10, stake = 4) => { const s = fresh(); s.chips = chips; const result = gamble(s, ctx, stake, mods(s), constant(roll)); return { s, result }; }; assert.equal(play(0.0).result.outcome, 'crash'); assert.equal(play(0.0).s.chips, 10 - 8, 'a crash loses double the stake'); assert.equal(play(0.04).result.outcome, 'jackpot'); assert.equal(play(0.04).s.chips, 10 + 4 * ctx.rules.gamble.jackpotMultiplier); assert.equal(play(0.2).result.outcome, 'win'); assert.equal(play(0.2).s.chips, 14); assert.equal(play(0.99).result.outcome, 'lose'); assert.equal(play(0.99).s.chips, 6); assert.equal(play(0.0, 5, 5).s.chips, 0, 'a crash cannot go below zero'); }); test('a cycle needs chips and respects the cooldown', () => { const s = fresh(); assert.equal(gamble(s, ctx, 1, mods(s), constant(0.2)), null, 'no chips'); s.chips = 10; assert.ok(gamble(s, ctx, 1, mods(s), constant(0.2))); assert.equal(gamble(s, ctx, 1, mods(s), constant(0.2)), null, 'cooling down'); advance(s, ctx, ctx.rules.gamble.cooldownSeconds + 0.1); assert.ok(gamble(s, ctx, 1, mods(s), constant(0.2))); assert.equal(gamble(s, ctx, 1000, mods(s), constant(0.2)), null, 'cooling down again'); }); test('the gamble is a losing bet at first and a winning one once reliable', () => { const average = (level) => { const s = fresh(); s.reliabilityLevel = level; const rng = seeded(42); let total = 0; for (let i = 0; i < 40000; i++) { s.chips = 1000; s.gambleCooldown = 0; total += gamble(s, ctx, 10, mods(s), rng).delta; } return total / 40000; }; assert.ok(average(0) < -0.5, 'house edge at the start'); assert.ok(average(6) > 0.5, 'player edge once fully reliable'); }); test('chips boost demand with diminishing returns', () => { const g = ctx.rules.gamble; assert.equal(chipDemandBonus(0, g), 1); assert.ok(chipDemandBonus(10, g) > chipDemandBonus(1, g)); assert.ok(chipDemandBonus(1010, g) - chipDemandBonus(1000, g) < chipDemandBonus(20, g) - chipDemandBonus(10, g), 'each extra chip is worth less'); const s = fresh(); const before = derive(s, ctx).demandMultiplier; s.chips = 30; assert.ok(derive(s, ctx).demandMultiplier > before * 1.5); }); // -------------------------------------------------------------------- research test('labs produce research points over time', () => { const s = fresh(); advance(s, ctx, 100); assert.equal(s.rp, 0, 'no labs, no research'); s.funds = 1e12; const cost = labCost(s, ctx); assert.ok(buyLab(s, ctx)); assert.ok(labCost(s, ctx) > cost * 2); advance(s, ctx, 100); assert.ok(Math.abs(s.rp - 100 * ctx.rules.research.rpPerLabPerSecond) < 1e-6); }); test('research follows its tree and applies its effect', () => { const s = fresh(); s.rp = 1e6; assert.equal(buyResearch(s, ctx, 'r_prod_2'), false, 'locked behind r_prod_1'); assert.equal(researchStatus(s, ctx.researchById.get('r_prod_2')), 'locked'); const before = derive(s, ctx); s.generators.gen1 = 10; const rateBefore = derive(s, ctx).rate; assert.ok(buyResearch(s, ctx, 'r_prod_1')); assert.equal(derive(s, ctx).rate, rateBefore * 1.5); assert.equal(researchStatus(s, ctx.researchById.get('r_prod_2')), 'available'); assert.equal(buyResearch(s, ctx, 'r_prod_1'), false, 'already owned'); assert.equal(before.advisedPrice, null); }); test('the price advisor research reveals the balanced price', () => { const s = fresh(); s.generators.gen1 = 50; assert.equal(derive(s, ctx).advisedPrice, null); s.research = ['r_ins_1', 'r_ins_2', 'r_mkt_1', 'r_mkt_2', 'r_ins_3']; const d = derive(s, ctx); assert.ok(d.advisedPrice > 0); // At the advised price, demand matches output. s.price = d.advisedPrice; assert.ok(Math.abs(derive(s, ctx).demand / d.rate - 1) < 0.1); }); // ---------------------------------------------------------------------- legacy test('a legacy restart needs a big enough run, and rewards the square root of it', () => { const s = fresh(); s.totalProduced = ctx.rules.legacy.threshold - 1; assert.equal(legacyGain(s, ctx, mods(s)), 0); s.totalProduced = ctx.rules.legacy.threshold; assert.equal(legacyGain(s, ctx, mods(s)), 1); s.totalProduced = ctx.rules.legacy.threshold * 100; assert.equal(legacyGain(s, ctx, mods(s)), 10); }); test('restarting resets the run but keeps the meta progression', () => { const s = fresh(); s.totalProduced = s.lifetimeProduced = 4e9; s.funds = 1e9; s.generators.gen3 = 40; s.upgrades = ['click_1']; s.chips = 12; s.invest.holdings.inv_low_1 = 5; s.research = ['r_prod_1']; s.labs = 2; s.rp = 33; s.achievements = ['a_prod_1']; s.stats.gambleWins = 7; assert.equal(prestige(s, ctx), 2); assert.equal(s.funds, 0); assert.equal(s.totalProduced, 0); assert.equal(s.generators.gen3, 0); assert.deepEqual(s.upgrades, []); assert.equal(s.chips, 0); assert.equal(s.invest.holdings.inv_low_1, 0); assert.equal(s.lifetimeProduced, 4e9); assert.deepEqual(s.research, ['r_prod_1']); assert.equal(s.labs, 2); assert.equal(s.rp, 33); assert.deepEqual(s.achievements, ['a_prod_1']); assert.equal(s.stats.gambleWins, 7); assert.deepEqual(s.legacy, { points: 2, earned: 2, prestiges: 1, perks: {} }); }); test('restarting is refused below the threshold', () => { const s = fresh(); s.totalProduced = 1e6; assert.equal(prestige(s, ctx), 0); assert.equal(s.totalProduced, 1e6); }); test('legacy points give a passive bonus and buy perks that stay after a restart', () => { const s = fresh(); s.legacy = { points: 10, earned: 10, prestiges: 1, perks: {} }; s.generators.gen1 = 10; const base = derive({ ...s, legacy: { ...s.legacy, earned: 0 } }, ctx).rate; assert.ok(Math.abs(derive(s, ctx).rate / base - 1.05 ** 10) < 1e-9, 'passive +5 % per point earned'); const perk = ctx.perksById.get('lg_prod'); const cost = perkCost(perk, 0); assert.ok(buyPerk(s, ctx, 'lg_prod')); assert.equal(s.legacy.points, 10 - cost); assert.ok(Math.abs(derive(s, ctx).rate / base - 1.05 ** 10 * 1.3) < 1e-9); s.totalProduced = 1e9; prestige(s, ctx); assert.equal(s.legacy.perks.lg_prod, 1, 'perk survives the restart'); }); test('perks have levels, growing costs and a cap; head start grants funds', () => { const s = fresh(); s.legacy.points = 1e6; const perk = ctx.perksById.get('lg_start'); assert.ok(perkCost(perk, 3) > perkCost(perk, 0)); for (let i = 0; i < 20; i++) buyPerk(s, ctx, 'lg_start'); assert.equal(s.legacy.perks.lg_start, perk.maxLevel); s.totalProduced = 1e9; prestige(s, ctx); assert.equal(s.funds, perk.effect.value * perk.maxLevel); }); test('generator cost perks make generators cheaper', () => { const s = fresh(); s.funds = 1e6; s.legacy.points = 100; const before = derive(s, ctx); buyPerk(s, ctx, 'lg_cost'); assert.ok(mods(s).generatorCost < 1 && before.mods.generatorCost === 1); }); // ------------------------------------------------------------ contracts & events const advanceLive = (s, seconds, rng = constant(0.5)) => advance(s, ctx, seconds, rng); test('offers appear on the board, up to the maximum', () => { const s = fresh(); s.generators.gen1 = 20; advanceLive(s, 1); assert.equal(s.contracts.offers.length, 1); advanceLive(s, ctx.rules.contracts.offerEverySeconds * 10); assert.equal(s.contracts.offers.length, ctx.rules.contracts.maxOffers); for (const o of s.contracts.offers) { assert.ok(o.target > 0 && o.seconds > 0 && o.reward.amount > 0); } }); test('a contract is completed by selling enough, then pays its reward', () => { const s = fresh(); s.generators.gen1 = 100; s.material = 1e9; s.contracts.offers = [{ template: 'sprint', seconds: 60, target: 5, reward: { type: 'funds', amount: 777 } }]; assert.ok(acceptContract(s, ctx, 0)); assert.equal(s.contracts.offers.length, 0); const funds = s.funds; const totals = advanceLive(s, 5); assert.equal(s.contracts.active, null); assert.ok(s.funds >= funds + 777); assert.equal(s.stats.contractsDone, 1); assert.ok(totals.notices.some((n) => n.kind === 'contract_done')); }); test('each kind of reward is granted', () => { const grant = (reward) => { const s = fresh(); s.generators.gen1 = 100; s.material = 1e9; s.contracts.offers = [{ template: 'sprint', seconds: 60, target: 1, reward }]; acceptContract(s, ctx, 0); advanceLive(s, 2); return s; }; assert.ok(grant({ type: 'research', amount: 5 }).rp >= 5); assert.equal(grant({ type: 'chips', amount: 3 }).chips, 3); const boosted = grant({ type: 'boost', amount: 2 }); assert.ok(boosted.events.buffs.some((b) => b.type === 'global_mult' && b.value === 2)); }); test('a contract fails when time runs out, and can be abandoned', () => { const s = fresh(); s.contracts.offers = [{ template: 'sprint', seconds: 10, target: 1e12, reward: { type: 'funds', amount: 1 } }]; acceptContract(s, ctx, 0); assert.equal(acceptContract(s, ctx, 0), false, 'one at a time'); const totals = advanceLive(s, 11); assert.equal(s.contracts.active, null); assert.ok(totals.notices.some((n) => n.kind === 'contract_failed')); s.contracts.offers = [{ template: 'sprint', seconds: 10, target: 1e12, reward: { type: 'funds', amount: 1 } }]; acceptContract(s, ctx, 0); assert.ok(abandonContract(s)); assert.equal(abandonContract(s), false); }); test('events: an opportunity appears, can be claimed once, and its buff expires', () => { const s = fresh(); s.generators.gen1 = 50; s.eventTimer = 0; const totals = { notices: [] }; const d = derive(s, ctx); stepEvents(s, ctx, 1, d, constant(0), false, totals); // first weighted type: surge assert.equal(s.events.current.type, 'surge'); assert.equal(totals.notices.length, 0, 'an opportunity is shown on its card and tab, not as a pop-up'); const demandBefore = derive(s, ctx).demandMultiplier; assert.ok(claimEvent(s, ctx, derive(s, ctx))); assert.equal(claimEvent(s, ctx, derive(s, ctx)), false); assert.equal(s.stats.eventsClaimed, 1); assert.equal(derive(s, ctx).demandMultiplier, demandBefore * 2); advanceLive(s, 61); assert.equal(s.events.buffs.length, 0); assert.equal(derive(s, ctx).demandMultiplier, demandBefore); }); test('events: unclaimed opportunities vanish, windfalls pay, incidents apply on their own', () => { const s = fresh(); s.generators.gen1 = 10; s.events.current = { type: 'surge', remaining: ctx.rules.events.claimSeconds }; advanceLive(s, ctx.rules.events.claimSeconds + 1); assert.equal(s.events.current, null); s.events.current = { type: 'windfall', remaining: 10 }; const funds = s.funds; claimEvent(s, ctx, derive(s, ctx)); assert.ok(s.funds > funds); s.eventTimer = 0; const totals = { notices: [] }; // rng 0.99 -> the last type in the list: the automatic incident stepEvents(s, ctx, 1, derive(s, ctx), constant(0.99), false, totals); assert.ok(totals.notices.some((n) => n.kind === 'event_incident')); assert.ok(s.events.buffs.some((b) => b.type === 'material_price_mult')); assert.ok(derive(s, ctx).batchCost > 0); }); test('offline time skips contracts and events but still earns and pays dividends', () => { const s = fresh(); s.generators.gen1 = 20; s.material = 1e9; s.invest.holdings.inv_low_1 = 100; s.contractTimer = 0; s.eventTimer = 1; const report = simulateOffline(s, ctx, 3600); assert.ok(report.produced > 0); assert.equal(s.contracts.offers.length, 0); assert.equal(s.events.current, null); assert.ok(s.stats.investProfit === 0 && s.funds > 0); }); test('research and perks raise the offline efficiency', () => { const run = (setup) => { const s = fresh(); s.generators.gen1 = 20; s.material = 1e12; setup(s); return simulateOffline(s, ctx, 3600).produced; }; const plain = run(() => {}); assert.ok(run((s) => (s.research = ['r_ins_1', 'r_ins_2'])) > plain * 1.4); }); // ---------------------------------------------------------------- achievements test('achievements are granted once, and their bonus applies', () => { const s = fresh(); s.generators.gen1 = 10; s.generators.gen2 = 10; assert.deepEqual(checkAchievements(s, ctx), ['a_gen_1']); assert.deepEqual(checkAchievements(s, ctx), []); s.lifetimeProduced = 2e6; const granted = checkAchievements(s, ctx); assert.deepEqual(granted.sort(), ['a_prod_1', 'a_prod_2']); const m = mods(s); assert.ok(Math.abs(m.global - 1.02 ** 3) < 1e-12); }); test('achievements are announced by the simulation', () => { const s = fresh(); s.lifetimeProduced = 5000; const totals = advanceLive(s, 1); assert.ok(totals.notices.some((n) => n.kind === 'achievement' && n.id === 'a_prod_1')); }); // ------------------------------------------------------------------- state (v2) test('a save from the first version still loads, with the new systems at their defaults', () => { const old = { v: 1, units: 12, funds: 345, material: 99, totalProduced: 5e6, totalSold: 1e6, price: 0.3, marketing: 4, materialFactor: 1.1, generators: { gen1: 5, gen2: 2 }, upgrades: ['click_1', 'ghost'], savedAt: 5, }; const s = sanitizeState(old, ctx, 1_000_000); assert.equal(s.funds, 345); assert.deepEqual(s.upgrades, ['click_1']); assert.equal(s.lifetimeProduced, 5e6, 'lifetime falls back to the run total'); assert.equal(s.chips, 0); assert.deepEqual(s.research, []); assert.deepEqual(s.legacy, { points: 0, earned: 0, prestiges: 0, perks: {} }); assert.equal(s.invest.prices.inv_low_1, 100); assert.deepEqual(s.contracts, { offers: [], active: null }); }); test('junk in the new fields is cleaned up', () => { const s = sanitizeState( { chips: -4, reliabilityLevel: 99, labs: 1e9, rp: 'lots', research: ['r_prod_1', 'zzz', 'r_prod_1'], achievements: ['a_prod_1', 7, null], legacy: { points: -1, earned: 'x', prestiges: 2.9, perks: { lg_prod: 9999, nope: 3 } }, invest: { prices: { inv_low_1: 1e12, inv_low_2: -5 }, holdings: { inv_low_1: 'a', inv_low_2: 3 }, basis: { inv_low_2: 10 } }, contracts: { offers: [{ template: 'sprint', seconds: 60, target: 10, reward: { type: 'funds', amount: 5 } }, { template: 'bogus' }, null], active: { offer: { template: 'x' } }, }, events: { current: { type: 'crash', remaining: 5 }, buffs: [{ type: 'demand_mult', value: 2, remaining: 10 }, { type: 'hack', value: 9, remaining: 1 }], }, }, ctx, ); assert.equal(s.chips, 0); assert.equal(s.reliabilityLevel, ctx.rules.gamble.reliability.maxLevel); assert.equal(s.labs, ctx.rules.research.labMax); assert.equal(s.rp, 0); assert.deepEqual(s.research, ['r_prod_1']); assert.deepEqual(s.achievements, ['a_prod_1']); assert.equal(s.legacy.points, 0); assert.equal(s.legacy.prestiges, 2); assert.deepEqual(s.legacy.perks, { lg_prod: ctx.perksById.get('lg_prod').maxLevel }); assert.equal(s.invest.prices.inv_low_1, 100 * ctx.investments.maxRatio); assert.equal(s.invest.prices.inv_low_2, 45 * ctx.investments.minRatio); assert.equal(s.invest.holdings.inv_low_1, 0); assert.equal(s.invest.holdings.inv_low_2, 3); assert.equal(s.contracts.offers.length, 1); assert.equal(s.contracts.active, null); assert.equal(s.events.current, null, 'automatic incidents are never "claimable"'); assert.deepEqual(s.events.buffs.map((b) => b.type), ['demand_mult']); }); test('transient fields are never saved, and a full game still fits in cookies', () => { const s = fresh(); s.funds = 123456789.123456; for (const g of ctx.generators) s.generators[g.id] = 2500; s.upgrades = ctx.upgrades.map((u) => u.id); s.research = ctx.research.map((r) => r.id); s.achievements = ctx.achievements.map((a) => a.id); s.legacy = { points: 12, earned: 40, prestiges: 9, perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])) }; for (const soc of ctx.investments.societies) { s.invest.holdings[soc.id] = 12345.678901; s.invest.basis[soc.id] = 98765.4321; s.invest.prices[soc.id] = soc.basePrice * 1.23456789; } s.contracts.offers = ctx.rules.contracts.templates.map((t) => ({ template: t.id, seconds: t.seconds, target: 123456789, reward: { type: 'funds', amount: 987654321.123 }, })); s.events.buffs = [{ type: 'demand_mult', value: 2, remaining: 40.123 }]; const text = serializeState(s); for (const key of ['materialTimer', 'materialTrend', 'investTimer', 'contractTimer', 'eventTimer', 'gambleCooldown']) { assert.ok(!text.includes(`"${key}"`), key); } assert.ok(text.length < 4000, `save is ${text.length} characters`); const back = sanitizeState(JSON.parse(text), ctx); assert.deepEqual(back.research, s.research); assert.deepEqual(back.legacy, s.legacy); assert.equal(back.contracts.offers.length, 3); });