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 };
}
+59
View File
@@ -0,0 +1,59 @@
// Plays the whole game with a bot and prints a timeline: use it to tune the pacing.
//
// npm run campaign -- <hours> [seconds-per-step] e.g. npm run campaign -- 120 10
//
// Tuning knobs can be tried without editing the data files, from the environment:
// THRESH LEGACY_EXP PASSIVE_SCALE PASSIVE_POW legacy points and passive bonus
// PERK_GROWTH legacy perk cost growth
// RP_RATE LAB_GROWTH RESEARCH_COST research
// ENDLESS_COST ENDLESS_GROWTH_ADD endless upgrades
// SHOW=<n> how many restarts to list (default 8)
import { loadContext } from './context.js';
import { runCampaign } from './campaign.js';
const ctx = loadContext();
const env = (name, fallback) => (process.env[name] === undefined ? fallback : Number(process.env[name]));
ctx.rules.legacy.threshold = env('THRESH', ctx.rules.legacy.threshold);
ctx.rules.legacy.exponent = env('LEGACY_EXP', ctx.rules.legacy.exponent);
ctx.rules.legacy.passiveScale = env('PASSIVE_SCALE', ctx.rules.legacy.passiveScale);
ctx.rules.legacy.passivePower = env('PASSIVE_POW', ctx.rules.legacy.passivePower);
ctx.rules.research.rpPerLabPerSecond = env('RP_RATE', ctx.rules.research.rpPerLabPerSecond);
ctx.rules.research.labCostGrowth = env('LAB_GROWTH', ctx.rules.research.labCostGrowth);
for (const perk of ctx.legacy) perk.costGrowth = env('PERK_GROWTH', perk.costGrowth);
for (const node of ctx.research) node.cost *= env('RESEARCH_COST', 1);
for (const line of ctx.endless) {
line.baseCost *= env('ENDLESS_COST', 1);
line.costGrowth += env('ENDLESS_GROWTH_ADD', 0);
}
const hours = Number(process.argv[2] ?? 24);
const dt = Number(process.argv[3] ?? 5);
const show = Number(process.env.SHOW ?? 8);
const sci = (n) => (n >= 1e6 ? n.toExponential(2) : n.toFixed(1));
const { state, restarts, goals, probes } = runCampaign(ctx, { hours, dt });
console.log(`Simulated ${hours} h of play (one step = ${dt} s)\n`);
for (const r of restarts.slice(0, show)) {
console.log(
` hour ${r.hour.toFixed(1).padStart(6)} restart #${String(restarts.indexOf(r) + 1).padEnd(3)} after ${String(Math.round(r.minutes)).padStart(5)} min: ${sci(r.units)} units (${sci(r.rate)}/s), +${r.points} points (total ${r.earned})`,
);
}
if (restarts.length > show) console.log(` ... ${restarts.length - show} more`);
console.log('\nHow a run starts (production per second, X minutes after a restart):');
for (const run of [...new Set(probes.map((p) => p.run))].slice(0, 6)) {
const row = probes.filter((p) => p.run === run).map((p) => `${String(p.minutes).padStart(2)} min: ${sci(p.rate)}/s`);
console.log(` run ${String(run).padEnd(3)} ${row.join(' ')}`);
}
console.log('\nGoals (hour of play when reached):');
for (const [name, hour] of goals) console.log(` ${name.padEnd(26)} ${hour.toFixed(1)} h`);
const missing = ['ALL research nodes', 'ONE perk maxed', 'ALL perks maxed'].filter((n) => !goals.has(n));
if (missing.length) console.log(` not reached in ${hours} h: ${missing.join(', ')}`);
console.log(
`\nAt the end: ${restarts.length} restarts, ${state.legacy.earned} points, ${state.research.length}/${ctx.research.length} research, ${state.labs} labs, ${state.achievements.length} achievements`,
);