350 lines
12 KiB
JavaScript
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);
|
|
});
|