import assert from 'node:assert/strict'; import { test } from 'node:test'; import { advance, buyGenerator, buyMarketing, buyMaterial, buyUpgrade, click, derive, generatorQuote, setPrice, simulateOffline, upgradeVisible, } from '../public/js/engine/engine.js'; import { balancedPrice, bulkCost, demandPerSecond, maxAffordable } from '../public/js/engine/formulas.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); test('bulkCost matches buying one at a time', () => { let owned = 3; let total = 0; for (let i = 0; i < 10; i++) total += Math.ceil(10 * 1.15 ** owned++); assert.ok(Math.abs(bulkCost(10, 1.15, 3, 10) - total) <= 10); }); test('maxAffordable never overspends and is maximal', () => { for (const budget of [0, 4, 5, 100, 12345, 9e9]) { const n = maxAffordable(5, 1.15, 7, budget); assert.ok(bulkCost(5, 1.15, 7, n) <= budget); assert.ok(bulkCost(5, 1.15, 7, n + 1) > budget); } }); test('lower prices sell more, higher prices sell less, with no ceiling at the reference price', () => { const { market } = ctx.rules; const at = (price, mult = 1) => demandPerSecond(market, price, mult); const ref = at(market.referencePrice); assert.equal(ref, market.baseDemand); assert.ok(at(market.referencePrice / 2) > ref * 2, 'half price sells more than double'); assert.ok(at(market.minPrice) > at(market.referencePrice / 2)); assert.ok(at(market.referencePrice * 2) < ref / 2); assert.equal(at(market.referencePrice, 3), ref * 3, 'bonuses scale demand'); }); test('the balanced price moves with output and demand bonuses', () => { const { market } = ctx.rules; const balanced = (supply, mult) => balancedPrice(market, supply, mult); const p = balanced(10, 1); assert.ok(Math.abs(demandPerSecond(market, p, 1) - 10) < 1e-9, 'demand equals supply there'); assert.ok(balanced(100, 1) < p, 'more output -> lower balanced price'); assert.ok(balanced(10, 4) > p, 'more demand -> higher balanced price'); }); test('click makes a unit from material', () => { const s = fresh(); assert.ok(click(s, ctx)); assert.equal(s.units, 1); assert.equal(s.material, ctx.rules.start.material - 1); assert.equal(s.totalProduced, 1); }); test('click does nothing without material', () => { const s = fresh(); s.material = 0; assert.equal(click(s, ctx), false); assert.equal(s.units, 0); }); test('selling turns stock into funds at the set price', () => { const s = fresh(); s.units = 100; setPrice(s, ctx, 0.25); const { sold, revenue } = advance(s, ctx, 1); assert.ok(sold > 0); assert.ok(Math.abs(revenue - sold * 0.25) < 1e-9); assert.ok(Math.abs(s.funds - revenue) < 1e-9); }); test('generators produce, consuming material', () => { const s = fresh(); s.funds = 1000; assert.ok(buyGenerator(s, ctx, 'gen1', 3)); const before = s.material; advance(s, ctx, 10); assert.ok(s.totalProduced > 0); assert.ok(s.material < before); }); test('production stops when material runs out', () => { const s = fresh(); s.generators.gen1 = 10; s.material = 5; advance(s, ctx, 60); assert.ok(s.totalProduced <= 5 + 1e-9); assert.ok(s.material >= 0); }); test('buying is refused when funds are short, and charges when it works', () => { const s = fresh(); assert.equal(buyGenerator(s, ctx, 'gen1', 1), false); assert.equal(buyMaterial(s, ctx), false); assert.equal(buyMarketing(s, ctx), false); s.funds = 100; const quote = generatorQuote(s, ctx, 'gen1', 5); assert.ok(buyGenerator(s, ctx, 'gen1', 5)); assert.equal(s.generators.gen1, 5); assert.equal(s.funds, 100 - quote.cost); }); test('"max" buys as many as affordable', () => { const s = fresh(); s.funds = 1000; buyGenerator(s, ctx, 'gen1', 'max'); assert.ok(s.generators.gen1 > 10); assert.ok(s.funds >= 0); }); test('upgrades need their unlock condition and requirement', () => { const s = fresh(); s.funds = 1e9; assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // not unlocked yet s.totalProduced = 25; assert.ok(buyUpgrade(s, ctx, 'click_1')); assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // already owned s.totalProduced = 1e6; assert.equal(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2')), false); // requires mat_eff_1 buyUpgrade(s, ctx, 'mat_eff_1'); assert.ok(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2'))); }); test('upgrade effects reach the derived values', () => { const s = fresh(); s.upgrades = ['click_1', 'demand_1', 'mat_eff_1', 'auto_buy']; const d = derive(s, ctx); assert.equal(d.clickYield, 2); assert.equal(d.demandMultiplier, 1.5); assert.equal(d.materialPerUnit, 1 / 1.25); assert.equal(d.autoBuyMaterial, true); }); test('auto-buy restocks material when funds allow', () => { const s = fresh(); s.upgrades = ['auto_buy']; s.material = 10; s.funds = 100; advance(s, ctx, 1); assert.ok(s.material > 50); }); test('material is costly at first and cheaper relative to the sale price as production grows', () => { const { referencePrice } = ctx.rules.market; const costShare = (totalProduced) => { const s = fresh(); s.totalProduced = totalProduced; const d = derive(s, ctx); return d.batchCost / d.batchSize / referencePrice; }; assert.ok(costShare(0) > 0.25, 'expensive at the start'); assert.ok(costShare(1e4) < costShare(0)); assert.ok(costShare(1e6) < costShare(1e4)); assert.ok(costShare(1e9) < 0.1, 'a small share of revenue late in the game'); }); test('material lots follow current output, in doublings', () => { const sizeWith = (gen1) => { const s = fresh(); s.generators.gen1 = gen1; return derive(s, ctx).batchSize; }; const { minBatch } = ctx.rules.materialMarket; assert.equal(sizeWith(0), minBatch); assert.ok(sizeWith(500) > sizeWith(5)); assert.equal(Math.log2(sizeWith(500) / minBatch) % 1, 0); }); test('stack upgrades multiply the lot, and its price grows in proportion', () => { const lot = (upgrades) => { const s = fresh(); s.upgrades = upgrades; s.materialFactor = 1; return derive(s, ctx); }; const base = lot([]); const doubled = lot(['stack_1']); const x12 = lot(['stack_1', 'stack_2', 'stack_3']); assert.equal(doubled.batchSize, base.batchSize * 2); assert.equal(x12.batchSize, base.batchSize * 12); assert.ok(Math.abs(doubled.batchCost - base.batchCost * 2) <= 0.01, 'twice the wire costs twice as much'); assert.ok(Math.abs(x12.batchCost - base.batchCost * 12) <= 0.06); }); test('the price PER UNIT of material is unchanged by stack size and keeps falling with scale', () => { const unitPrice = (totalProduced, upgrades) => { const s = fresh(); s.totalProduced = totalProduced; s.upgrades = upgrades; s.materialFactor = 1; const d = derive(s, ctx); return d.batchCost / d.batchSize; }; const early = unitPrice(0, []); assert.ok(Math.abs(unitPrice(0, ['stack_1', 'stack_2']) - early) < early * 0.01); assert.ok(unitPrice(1e6, ['stack_1', 'stack_2']) < unitPrice(1e4, ['stack_1', 'stack_2'])); assert.ok(unitPrice(1e9, []) < early / 5, 'much cheaper per unit late in the game'); }); test('stack upgrades form a chain', () => { const s = fresh(); s.totalProduced = 1e9; assert.ok(upgradeVisible(s, ctx.upgradesById.get('stack_1'))); assert.equal(upgradeVisible(s, ctx.upgradesById.get('stack_2')), false); s.upgrades = ['stack_1']; assert.ok(upgradeVisible(s, ctx.upgradesById.get('stack_2'))); }); test('the material market is erratic but stays within its bounds', () => { const m = ctx.rules.materialMarket; const s = fresh(); let seed = 7; const rng = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const seen = []; for (let i = 0; i < 2000; i++) { advance(s, ctx, m.updateEverySeconds, rng); seen.push(s.materialFactor); } assert.ok(Math.min(...seen) >= m.minFactor && Math.max(...seen) <= m.maxFactor); assert.ok(Math.max(...seen) > 1.6 && Math.min(...seen) < 0.6, 'swings widely'); assert.ok(new Set(seen.map((f) => f.toFixed(2))).size > 100, 'does not sit still'); }); test('material prices have cents, not just whole numbers', () => { const s = fresh(); s.materialFactor = 0.8137; const { batchCost } = derive(s, ctx); assert.notEqual(batchCost, Math.round(batchCost)); assert.equal(batchCost, Math.round(batchCost * 100) / 100); }); test('the material price changes every 10 seconds, not before', () => { const m = ctx.rules.materialMarket; assert.equal(m.updateEverySeconds, 10); const s = fresh(); advance(s, ctx, 9.5, () => 0.9); assert.equal(s.materialFactor, 1); advance(s, ctx, 1, () => 0.9); assert.notEqual(s.materialFactor, 1); }); test('auto-buy waits for a fair price unless stock is nearly gone', () => { const s = fresh(); s.upgrades = ['auto_buy']; s.funds = 1e6; s.materialFactor = 2.5; // expensive s.material = 40; // low but not desperate (< lot/2, > lot/10) advance(s, ctx, 0.1, () => 0.5); assert.equal(s.material, 40, 'did not buy at a high price'); s.material = 5; // desperate advance(s, ctx, 0.1, () => 0.5); assert.ok(s.material > 50, 'buys when nearly out'); }); test('offline progress is capped, discounted, and ignored when short', () => { const s = fresh(); s.generators.gen1 = 5; s.material = 1e6; assert.equal(simulateOffline(s, ctx, 3), null); const report = simulateOffline(s, ctx, 10 * 24 * 3600); assert.equal(report.seconds, ctx.rules.offline.maxSeconds); assert.ok(report.produced > 0); }); test('sanitizeState rejects junk and clamps values', () => { assert.equal(sanitizeState('nope', ctx), null); assert.equal(sanitizeState([], ctx), null); const s = sanitizeState( { units: -5, funds: 'lots', price: 9999, marketing: 1e9, generators: { gen1: 3.7, ghost: 5, gen2: -2 }, upgrades: ['click_1', 'click_1', 'nonsense'], savedAt: Number.POSITIVE_INFINITY, }, ctx, 5_000, ); assert.equal(s.units, 0); assert.equal(s.funds, 0); assert.equal(s.price, ctx.rules.market.maxPrice); assert.ok(s.marketing <= 300); assert.deepEqual(s.generators, { gen1: 3, gen2: 0, gen3: 0, gen4: 0, gen5: 0, gen6: 0 }); assert.deepEqual(s.upgrades, ['click_1']); assert.equal(s.savedAt, 5_000); }); test('state survives a serialize / sanitize round trip', () => { const s = fresh(); s.funds = 123.456; s.generators.gen2 = 4; s.upgrades = ['click_1', 'g1_a']; s.marketing = 3; const back = sanitizeState(JSON.parse(serializeState(s)), ctx); assert.equal(back.funds, 123.456); assert.deepEqual(back.generators, s.generators); assert.deepEqual(back.upgrades, s.upgrades); assert.equal(back.marketing, 3); }); test('the full save of a late game state is small enough for 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); assert.ok(serializeState(s).length < 1500); });