Files

350 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
advance,
buyGenerator,
buyMarketing,
buyMaterial,
buyUpgrade,
click,
derive,
generatorQuote,
materialPurchase,
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('a partial lot can always be bought: material is never out of reach', () => {
const s = fresh();
s.upgrades = ['stack_1', 'stack_2', 'stack_3', 'stack_4', 'stack_5'];
s.endless = { e_stack: 30 }; // a lot far larger than any funds
s.generators.gen1 = 5;
s.material = 0;
s.funds = 50;
const d = derive(s, ctx);
assert.ok(d.batchCost > 1e6, 'the whole lot is out of reach');
const purchase = materialPurchase(s, d);
assert.equal(purchase.cost, 50, 'spends what it can');
assert.ok(Math.abs(purchase.amount / d.batchSize - 50 / d.batchCost) < 1e-12, 'proportional to the lot price');
assert.ok(buyMaterial(s, ctx));
assert.equal(s.funds, 0);
assert.ok(s.material > 0);
assert.equal(buyMaterial(s, ctx), false, 'nothing left to spend');
});
test('a whole lot is bought when affordable', () => {
const s = fresh();
s.funds = 1e6;
const d = derive(s, ctx);
assert.deepEqual(materialPurchase(s, d), { amount: d.batchSize, cost: d.batchCost });
});
test('auto-buy still restocks when the lot is out of reach (urgent partial buy)', () => {
const s = fresh();
s.upgrades = ['auto_buy'];
s.endless = { e_stack: 30 };
s.funds = 100;
s.material = 0;
advance(s, ctx, 0.1, () => 0.5);
assert.ok(s.material > 0);
});