feat: Making the game harder to 'beat'

This commit is contained in:
gauvainboiche
2026-10-08 17:17:21 +02:00
parent 2f7c307f0e
commit 8b15c76b14
20 changed files with 637 additions and 91 deletions
+149
View File
@@ -0,0 +1,149 @@
// A whole-game bot, used to tune the pacing and to guard it in the tests.
//
// It plays like a careful, efficient player: sets the best price, restocks,
// buys everything worth buying (automation, upgrades, endless levels, labs,
// research) and restarts for legacy points when that is worth it. It never
// gambles, invests or does contracts, so it is a slightly conservative player.
// `npm run campaign -- <hours>` prints a timeline; see campaign.mjs.
import { buyEndless, endlessQuote, lineAvailable } from '../public/js/engine/endless.js';
import * as E from '../public/js/engine/engine.js';
import { balancedPrice } from '../public/js/engine/formulas.js';
import { buyPerk, legacyGain, perkCost, perkLevel, 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 } from '../public/js/engine/state.js';
/**
* @param ctx game context (scripts/context.js)
* @param options.hours simulated hours to play
* @param options.dt seconds per simulation step (bigger = faster, coarser)
* @param options.restartRatio restart when the points on offer reach this share of those already earned
*/
export function runCampaign(ctx, { hours, dt = 5, restartRatio = 0.5, seed = 3 } = {}) {
const state = createState(ctx);
let rngSeed = seed;
const rng = () => {
rngSeed = (rngSeed * 16807) % 2147483647;
return rngSeed / 2147483647;
};
const market = ctx.rules.market;
let t = 0;
let runStart = 0;
const restarts = []; // { hour, minutes, points, earned, units, rate }
const goals = new Map(); // name -> hour
const probes = []; // { run, minutes, rate } production some minutes into every run
const probed = new Set();
const mark = (name, done) => {
if (done && !goals.has(name)) goals.set(name, t / 3600);
};
function track() {
mark('first restart', state.legacy.prestiges >= 1);
mark('5 restarts', state.legacy.prestiges >= 5);
mark('5 research nodes', state.research.length >= 5);
mark('10 research nodes', state.research.length >= 10);
mark('ALL research nodes', state.research.length >= ctx.research.length);
mark('20 labs', state.labs >= 20);
mark('every perk at level 3+', ctx.legacy.every((p) => perkLevel(state, p) >= 3));
mark('ONE perk maxed', ctx.legacy.some((p) => perkLevel(state, p) >= p.maxLevel));
mark('ALL perks maxed', ctx.legacy.every((p) => perkLevel(state, p) >= p.maxLevel));
}
function produce(mods) {
const d = E.derive(state, ctx, mods);
E.setPrice(state, ctx, balancedPrice(market, Math.max(d.rate, 1), d.demandMultiplier));
if (!d.autoBuyMaterial && state.material < 1 + d.rate * 8) E.buyMaterial(state, ctx);
if (t - runStart < 300) for (let i = 0; i < 5 * dt; i++) E.click(state, ctx);
const upgrades = ctx.upgrades.filter((u) => E.upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
for (const u of upgrades) E.buyUpgrade(state, ctx, u.id);
if (d.rate < market.baseDemand * d.demandMultiplier * 2) {
const best = ctx.generators
.filter((g) => E.isUnlocked(state, g.unlock))
.map((g) => ({ g, q: E.generatorQuote(state, ctx, g.id, 1, mods), r: d.perGenerator[g.id] }))
.filter((c) => c.q.cost <= state.funds)
.sort((a, b) => a.q.cost / a.r - b.q.cost / b.r)[0];
if (best) E.buyGenerator(state, ctx, best.g.id, 1);
} else {
E.buyMarketing(state, ctx);
}
}
function buyEndlessLevels() {
for (let k = 0; k < 40; k++) {
let best = null;
for (const line of ctx.endless) {
if (!lineAvailable(state, line)) continue;
const { cost } = endlessQuote(state, ctx, line.id, 1);
if (cost > state.funds * 0.5) continue; // keep a reserve
const score = Math.abs(Math.log(line.effect.value)) / cost;
if (!best || score > best.score) best = { line, score };
}
if (!best) return;
buyEndless(state, ctx, best.line.id, 1);
}
}
function research() {
if (state.funds > labCost(state, ctx) * 4) buyLab(state, ctx);
const next = ctx.research
.filter((n) => researchStatus(state, n) === 'available')
.sort((a, b) => a.cost - b.cost)[0];
if (next && state.rp >= next.cost) buyResearch(state, ctx, next.id);
}
function maybeRestart() {
const gain = legacyGain(state, ctx, modifiersFor(state, ctx));
if (gain < 1 || gain < Math.max(1, state.legacy.earned * restartRatio)) return;
const rate = E.derive(state, ctx).rate;
const units = state.totalProduced;
const points = prestige(state, ctx);
restarts.push({
hour: t / 3600,
minutes: (t - runStart) / 60,
points,
earned: state.legacy.earned,
units,
rate,
});
runStart = t;
// spend the points on the cheapest perks
for (;;) {
const affordable = ctx.legacy
.map((perk) => ({ perk, cost: perkCost(perk, perkLevel(state, perk)) }))
.filter(({ perk, cost }) => perkLevel(state, perk) < perk.maxLevel && cost <= state.legacy.points)
.sort((a, b) => a.cost - b.cost);
if (!affordable.length) break;
buyPerk(state, ctx, affordable[0].perk.id);
}
}
while (t < hours * 3600) {
const mods = modifiersFor(state, ctx);
produce(mods);
buyEndlessLevels();
research();
E.advance(state, ctx, dt, rng);
t += dt;
maybeRestart();
track();
const run = restarts.length + 1;
const minutes = (t - runStart) / 60;
for (const mark of [2, 10, 60]) {
const key = `${run}:${mark}`;
if (minutes >= mark && !probed.has(key)) {
probed.add(key);
probes.push({ run, minutes: mark, rate: E.derive(state, ctx).rate });
}
}
}
return { state, restarts, goals, probes, hours };
}