feat: Making the game harder to 'beat'
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Guards against the game being finished too quickly, or running away.
|
||||
// Background: the first legacy system multiplied output by 1.05 per point, so a
|
||||
// few thousand points gave 1e100 and a run took seconds (a player reported
|
||||
// 6e136 units/s two minutes into their fourth game).
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runCampaign } from '../scripts/campaign.js';
|
||||
import { derive } from '../public/js/engine/engine.js';
|
||||
import { legacyGain, pointsFor } from '../public/js/engine/legacy.js';
|
||||
import { modifiersFor, passiveBonus } from '../public/js/engine/modifiers.js';
|
||||
import { createState, sanitizeState, serializeState, STATE_VERSION } from '../public/js/engine/state.js';
|
||||
import { loadContext } from './helpers.js';
|
||||
|
||||
const ctx = loadContext();
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
// --------------------------------------------------------- the reported save
|
||||
|
||||
/** What the broken version could leave behind after a few restarts. */
|
||||
function runawaySave(version) {
|
||||
return {
|
||||
v: version,
|
||||
units: 1e130,
|
||||
funds: 1e135,
|
||||
material: 1e140,
|
||||
totalProduced: 1e137,
|
||||
totalSold: 1e136,
|
||||
price: 0.25,
|
||||
marketing: 120,
|
||||
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 40])),
|
||||
upgrades: ctx.upgrades.map((u) => u.id),
|
||||
endless: { e_global: 400 },
|
||||
lifetimeProduced: 1e139,
|
||||
research: ['r_prod_1', 'r_mkt_1'],
|
||||
labs: 7,
|
||||
rp: 123,
|
||||
achievements: ['a_prod_1', 'a_gen_1'],
|
||||
stats: { gambleWins: 3 },
|
||||
legacy: {
|
||||
points: 4000,
|
||||
earned: 5000,
|
||||
prestiges: 3,
|
||||
perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])),
|
||||
},
|
||||
savedAt: NOW - 1000,
|
||||
};
|
||||
}
|
||||
|
||||
test('a save corrupted by the old runaway bonus is repaired on load', () => {
|
||||
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||
assert.equal(s.repaired, true);
|
||||
assert.equal(s.v, STATE_VERSION);
|
||||
|
||||
// legacy is capped, research and achievements are kept
|
||||
assert.ok(s.legacy.earned <= 10);
|
||||
assert.equal(s.legacy.points, 0);
|
||||
assert.equal(s.legacy.prestiges, 3);
|
||||
assert.ok(Object.values(s.legacy.perks).every((level) => level <= 3));
|
||||
assert.deepEqual(s.research, ['r_prod_1', 'r_mkt_1']);
|
||||
assert.deepEqual(s.achievements, ['a_prod_1', 'a_gen_1']);
|
||||
assert.equal(s.labs, 7);
|
||||
assert.equal(s.rp, 123);
|
||||
assert.equal(s.stats.gambleWins, 3);
|
||||
|
||||
// the meaningless run starts over
|
||||
assert.equal(s.funds, 0);
|
||||
assert.equal(s.totalProduced, 0);
|
||||
assert.deepEqual(s.upgrades, []);
|
||||
assert.deepEqual(s.endless, {});
|
||||
assert.ok(Object.values(s.generators).every((n) => n === 0));
|
||||
|
||||
// the huge lifetime total cannot hand out a pile of points at the next restart
|
||||
assert.ok(legacyGain(s, ctx, modifiersFor(s, ctx)) <= 3);
|
||||
assert.ok(pointsFor(s.lifetimeProduced, ctx, modifiersFor(s, ctx)) <= s.legacy.earned + 3);
|
||||
});
|
||||
|
||||
test('a repaired save plays at a normal speed', () => {
|
||||
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||
s.generators.gen1 = 10;
|
||||
const rate = derive(s, ctx).rate;
|
||||
assert.ok(Number.isFinite(rate) && rate < 1000, `production is ${rate}/s`);
|
||||
assert.ok(modifiersFor(s, ctx).global < 100);
|
||||
});
|
||||
|
||||
test('the repair happens once: a repaired save loads unchanged', () => {
|
||||
const s = sanitizeState(runawaySave(2), ctx, NOW);
|
||||
const again = sanitizeState(JSON.parse(serializeState(s)), ctx, NOW);
|
||||
assert.equal(again.repaired, false);
|
||||
assert.deepEqual(again.legacy, s.legacy);
|
||||
// saves keep 10 significant digits
|
||||
assert.ok(Math.abs(again.lifetimeProduced / s.lifetimeProduced - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('an old but honest save keeps its legacy progress (only the lifetime total is capped)', () => {
|
||||
const save = { ...createState(ctx, NOW), v: 2 };
|
||||
save.legacy = { points: 2, earned: 3, prestiges: 3, perks: { lg_prod: 2 } };
|
||||
save.generators.gen1 = 5;
|
||||
save.totalProduced = 5e9;
|
||||
save.lifetimeProduced = 2e10;
|
||||
const s = sanitizeState(JSON.parse(serializeState(save)), ctx, NOW);
|
||||
assert.equal(s.repaired, false);
|
||||
assert.deepEqual(s.legacy, save.legacy);
|
||||
assert.equal(s.generators.gen1, 5, 'the run is untouched');
|
||||
assert.equal(s.lifetimeProduced, 2e10);
|
||||
});
|
||||
|
||||
test('a current save is never touched, however big', () => {
|
||||
const save = sanitizeState(runawaySave(STATE_VERSION), ctx, NOW);
|
||||
assert.equal(save.repaired, false);
|
||||
assert.equal(save.legacy.earned, 5000);
|
||||
});
|
||||
|
||||
test('an export from an older version is repaired too', () => {
|
||||
const s = sanitizeState(runawaySave(1), ctx, NOW);
|
||||
assert.equal(s.repaired, true);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- hard bounds
|
||||
|
||||
test('everything permanent, at its maximum, is a bounded boost', () => {
|
||||
const s = createState(ctx, NOW);
|
||||
s.research = ctx.research.map((r) => r.id);
|
||||
s.achievements = ctx.achievements.map((a) => a.id);
|
||||
s.legacy = {
|
||||
points: 0,
|
||||
earned: 1e6, // a million points: the number that used to give 1e100 at 5000
|
||||
prestiges: 50,
|
||||
perks: Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel])),
|
||||
};
|
||||
const m = modifiersFor(s, ctx);
|
||||
assert.ok(m.global < 1e5, `global x${m.global}`);
|
||||
assert.ok(m.demand < 1e5, `demand x${m.demand}`);
|
||||
assert.ok(passiveBonus(s, ctx) < 1000);
|
||||
});
|
||||
|
||||
test('points need ever more production: a trillion times more gives far fewer than a trillion times the points', () => {
|
||||
const m = modifiersFor(createState(ctx), ctx);
|
||||
const base = pointsFor(ctx.rules.legacy.threshold, ctx, m);
|
||||
const huge = pointsFor(ctx.rules.legacy.threshold * 1e12, ctx, m);
|
||||
assert.equal(base, 1);
|
||||
assert.ok(huge < 1e6, `${huge} points`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- the pacing
|
||||
|
||||
test('the game is not finished quickly: 40 hours of efficient play', () => {
|
||||
const { state, restarts, goals, probes } = runCampaign(ctx, { hours: 40, dt: 10 });
|
||||
|
||||
assert.ok(goals.get('first restart') >= 6, `first restart after ${goals.get('first restart')?.toFixed(1)} h`);
|
||||
assert.ok(restarts.length < 5, `${restarts.length} restarts in 40 h`);
|
||||
assert.ok(!goals.has('ALL research nodes'), 'all the research is not done in 40 h');
|
||||
assert.ok(!goals.has('ONE perk maxed'), 'no perk is maxed in 40 h');
|
||||
assert.ok(Math.max(...Object.values(state.legacy.perks), 0) <= 4, 'perks are still low');
|
||||
|
||||
// but it does move: this is not a wall either
|
||||
assert.ok(state.research.length >= 5, 'research progresses');
|
||||
assert.ok(restarts.length >= 1, 'a restart is reachable within 40 h');
|
||||
assert.ok(state.lifetimeProduced > 1e13);
|
||||
|
||||
// a new run after a restart is quicker, but never absurd (it once reached 6e136/s in 2 minutes)
|
||||
for (const probe of probes) {
|
||||
assert.ok(Number.isFinite(probe.rate), 'finite production');
|
||||
if (probe.minutes === 2) assert.ok(probe.rate < 1e6, `run ${probe.run}: ${probe.rate}/s after 2 min`);
|
||||
if (probe.minutes === 60) assert.ok(probe.rate < 1e12, `run ${probe.run}: ${probe.rate}/s after 1 h`);
|
||||
}
|
||||
for (const value of [state.funds, state.units, state.material, state.totalProduced]) {
|
||||
assert.ok(Number.isFinite(value));
|
||||
}
|
||||
});
|
||||
@@ -51,7 +51,7 @@ test('costs grow exponentially, level after level', () => {
|
||||
|
||||
test('buying several levels charges the exact sum', () => {
|
||||
const s = finished();
|
||||
s.funds = 1e13;
|
||||
s.funds = 1e15;
|
||||
const l = line('e_demand');
|
||||
const before = s.funds;
|
||||
assert.ok(buyEndless(s, ctx, 'e_demand', 10));
|
||||
@@ -128,7 +128,7 @@ test('even absurd levels never produce NaN or Infinity', () => {
|
||||
test('endless levels belong to the run: a legacy restart resets them', () => {
|
||||
const s = finished();
|
||||
s.endless.e_global = 12;
|
||||
s.totalProduced = 1e10;
|
||||
s.totalProduced = s.lifetimeProduced = 1e14;
|
||||
prestige(s, ctx);
|
||||
assert.deepEqual(s.endless, {});
|
||||
});
|
||||
|
||||
+66
-20
@@ -13,8 +13,8 @@ import {
|
||||
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 { buyPerk, legacyGain, nextPointAt, perkCost, pointsFor, prestige } from '../public/js/engine/legacy.js';
|
||||
import { modifiersFor, passiveBonus } 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';
|
||||
@@ -241,22 +241,45 @@ test('the price advisor research reveals the balanced price', () => {
|
||||
|
||||
// ---------------------------------------------------------------------- legacy
|
||||
|
||||
test('a legacy restart needs a big enough run, and rewards the square root of it', () => {
|
||||
test('legacy points come from LIFETIME production, with diminishing returns', () => {
|
||||
const { threshold, exponent } = ctx.rules.legacy;
|
||||
const points = (lifetime) => pointsFor(lifetime, ctx, mods(fresh()));
|
||||
assert.equal(points(threshold - 1), 0, 'nothing below the threshold');
|
||||
assert.equal(points(threshold), 1);
|
||||
|
||||
// To double the points, production must be multiplied by 2 ** (1 / exponent): far more than 2.
|
||||
const factor = 2 ** (1 / exponent);
|
||||
assert.ok(factor > 4);
|
||||
assert.equal(points(threshold * 4 ** (1 / exponent) * 1.001), 4);
|
||||
assert.ok(points(threshold * 100) < 100, 'a 100x bigger lifetime does not give 100x the points');
|
||||
|
||||
const s = fresh();
|
||||
s.totalProduced = ctx.rules.legacy.threshold - 1;
|
||||
s.legacy.earned = 3;
|
||||
assert.ok(Math.abs(nextPointAt(s, ctx) - threshold * 4 ** (1 / exponent)) < 1, 'the next point is at the 4th step');
|
||||
});
|
||||
|
||||
test('a restart gives only what was NOT already rewarded: restarting often cannot snowball', () => {
|
||||
const s = fresh();
|
||||
s.lifetimeProduced = s.totalProduced = 6e13;
|
||||
assert.equal(legacyGain(s, ctx, mods(s)), 2);
|
||||
assert.equal(prestige(s, ctx), 2);
|
||||
|
||||
// Straight away, with the same lifetime, there is nothing left to claim.
|
||||
assert.equal(legacyGain(s, ctx, mods(s)), 0);
|
||||
assert.equal(prestige(s, ctx), 0);
|
||||
|
||||
// A short new run changes little: points are about the whole lifetime, not about each run.
|
||||
s.lifetimeProduced += 1e13;
|
||||
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.totalProduced = s.lifetimeProduced = 6e13;
|
||||
s.funds = 1e9;
|
||||
s.generators.gen3 = 40;
|
||||
s.upgrades = ['click_1'];
|
||||
s.endless = { e_global: 4 };
|
||||
s.chips = 12;
|
||||
s.invest.holdings.inv_low_1 = 5;
|
||||
s.research = ['r_prod_1'];
|
||||
@@ -270,9 +293,10 @@ test('restarting resets the run but keeps the meta progression', () => {
|
||||
assert.equal(s.totalProduced, 0);
|
||||
assert.equal(s.generators.gen3, 0);
|
||||
assert.deepEqual(s.upgrades, []);
|
||||
assert.deepEqual(s.endless, {});
|
||||
assert.equal(s.chips, 0);
|
||||
assert.equal(s.invest.holdings.inv_low_1, 0);
|
||||
assert.equal(s.lifetimeProduced, 4e9);
|
||||
assert.equal(s.lifetimeProduced, 6e13);
|
||||
assert.deepEqual(s.research, ['r_prod_1']);
|
||||
assert.equal(s.labs, 2);
|
||||
assert.equal(s.rp, 33);
|
||||
@@ -283,41 +307,63 @@ test('restarting resets the run but keeps the meta progression', () => {
|
||||
|
||||
test('restarting is refused below the threshold', () => {
|
||||
const s = fresh();
|
||||
s.totalProduced = 1e6;
|
||||
s.totalProduced = s.lifetimeProduced = 1e6;
|
||||
assert.equal(prestige(s, ctx), 0);
|
||||
assert.equal(s.totalProduced, 1e6);
|
||||
});
|
||||
|
||||
test('the passive bonus grows slowly and can never run away (it once reached 1e100)', () => {
|
||||
const s = fresh();
|
||||
const at = (earned) => passiveBonus({ ...s, legacy: { ...s.legacy, earned } }, ctx);
|
||||
assert.equal(at(0), 1);
|
||||
assert.ok(at(1) > 1 && at(1) < 1.5);
|
||||
assert.ok(at(100) < 3);
|
||||
assert.ok(at(400) < 2 * at(100), 'four times the points, less than twice the bonus');
|
||||
assert.ok(at(1e6) < 1e3, 'a million points is nowhere near astronomical');
|
||||
assert.ok(at(5000) < 100, 'the number of points that used to give 1e100');
|
||||
});
|
||||
|
||||
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 bonus = passiveBonus(s, ctx);
|
||||
assert.ok(Math.abs(derive(s, ctx).rate / base - bonus) < 1e-9);
|
||||
assert.ok(bonus > 1 && bonus < 2, 'a modest bonus');
|
||||
|
||||
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);
|
||||
assert.ok(Math.abs(derive(s, ctx).rate / base - bonus * perk.effect.value) < 1e-9);
|
||||
|
||||
s.totalProduced = 1e9;
|
||||
prestige(s, ctx);
|
||||
s.lifetimeProduced = s.totalProduced = 1e16;
|
||||
assert.ok(prestige(s, ctx) > 0);
|
||||
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', () => {
|
||||
test('perks have levels, steeply growing costs and a cap; head start grants funds', () => {
|
||||
const s = fresh();
|
||||
s.legacy.points = 1e6;
|
||||
s.legacy.points = 1e9;
|
||||
const perk = ctx.perksById.get('lg_start');
|
||||
assert.ok(perkCost(perk, 3) > perkCost(perk, 0));
|
||||
assert.ok(perkCost(perk, 3) >= perkCost(perk, 0) * 6, 'each level costs about twice the last');
|
||||
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);
|
||||
s.lifetimeProduced = s.totalProduced = 1e13;
|
||||
assert.equal(prestige(s, ctx), 1);
|
||||
assert.equal(s.funds, perk.effect.value * perk.maxLevel);
|
||||
});
|
||||
|
||||
test('the legacy perks together are a bounded boost, not a runaway', () => {
|
||||
const s = fresh();
|
||||
s.generators.gen1 = 10;
|
||||
const base = derive(s, ctx).rate;
|
||||
s.legacy.perks = Object.fromEntries(ctx.legacy.map((p) => [p.id, p.maxLevel]));
|
||||
const boosted = derive(s, ctx).rate / base;
|
||||
assert.ok(boosted > 3 && boosted < 20, `all perks maxed multiply production by ${boosted.toFixed(1)}`);
|
||||
});
|
||||
|
||||
test('generator cost perks make generators cheaper', () => {
|
||||
const s = fresh();
|
||||
s.funds = 1e6;
|
||||
|
||||
Reference in New Issue
Block a user