feat: New features (research, invest, gambling, new game+, trophies)
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
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.ok(totals.notices.some((n) => n.kind === 'event_opportunity'));
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user