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
+20 -4
View File
@@ -5,11 +5,27 @@
import { modifiersFor } from './modifiers.js';
import { createState, META_KEYS } from './state.js';
/** Points a restart would grant right now (0 until the run is big enough). */
/**
* How many points a lifetime production deserves in total. Diminishing returns
* (a power below 1) on EVERYTHING ever produced, not on a single run: each
* further point needs far more production than the last, so restarting more and
* more often cannot snowball.
*/
export function pointsFor(lifetimeProduced, ctx, mods) {
const { threshold, exponent } = ctx.rules.legacy;
if (!(lifetimeProduced >= threshold)) return 0;
return Math.floor((lifetimeProduced / threshold) ** exponent * mods.legacyGain);
}
/** Lifetime production at which the next point is earned (ignoring bonuses to point gains). */
export function nextPointAt(state, ctx) {
const { threshold, exponent } = ctx.rules.legacy;
return threshold * (state.legacy.earned + 1) ** (1 / exponent);
}
/** Points a restart would grant right now (0 until the next point is within reach). */
export function legacyGain(state, ctx, mods) {
const { threshold } = ctx.rules.legacy;
if (state.totalProduced < threshold) return 0;
return Math.floor(Math.sqrt(state.totalProduced / threshold) * mods.legacyGain);
return Math.max(0, pointsFor(state.lifetimeProduced, ctx, mods) - state.legacy.earned);
}
export function perkLevel(state, perk) {
+10 -4
View File
@@ -3,6 +3,12 @@
import { foldEffects } from './effects.js';
import { chipDemandBonus } from './formulas.js';
/** The multiplier given by all legacy points earned so far: diminishing returns (a power below 1). */
export function passiveBonus(state, ctx) {
const { passiveScale, passivePower } = ctx.rules.legacy;
return 1 + passiveScale * state.legacy.earned ** passivePower;
}
/** All `{ effect, times }` entries that apply to `state`. */
export function collectEffects(state, ctx) {
const entries = [];
@@ -18,10 +24,10 @@ export function collectEffects(state, ctx) {
entries.push({ effect: ctx.perksById.get(id)?.effect, times: level });
}
// Every legacy point ever earned gives a small passive bonus.
const passive = 1 + ctx.rules.legacy.passivePerPoint;
entries.push({ effect: { type: 'global_mult', value: passive }, times: state.legacy.earned });
entries.push({ effect: { type: 'demand_mult', value: passive }, times: state.legacy.earned });
// Every legacy point ever earned adds a flat bonus. It has diminishing returns and does
// not compound: stacking x1.05 per point once reached 1e100 within a few runs.
entries.push({ effect: { type: 'global_mult', value: passiveBonus(state, ctx) } });
entries.push({ effect: { type: 'demand_mult', value: passiveBonus(state, ctx) } });
// Chips make the brand more attractive, with diminishing returns.
entries.push({
+48 -2
View File
@@ -1,6 +1,7 @@
import { clamp } from './formulas.js';
export const STATE_VERSION = 2;
// 3: legacy points come from lifetime production and the passive bonus no longer compounds.
export const STATE_VERSION = 3;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
@@ -17,6 +18,7 @@ const TRANSIENT = [
'contractTimer',
'eventTimer',
'gambleCooldown',
'repaired',
];
// What a legacy reset keeps (the "meta" progression); everything else is the run.
@@ -81,6 +83,7 @@ export function createState(ctx, now = Date.now()) {
contractTimer: contracts.offerEverySeconds,
eventTimer: events.minSeconds,
gambleCooldown: 0,
repaired: false, // set when an old save had to be rebalanced, so the game can say so
};
}
@@ -168,7 +171,7 @@ function sanitizeEvents(raw, ctx) {
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
* Saves from older versions are upgraded: missing fields take their defaults.
*/
export function sanitizeState(raw, ctx, now = Date.now()) {
function parseState(raw, ctx, now) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket, gamble } = ctx.rules;
const base = createState(ctx, now);
@@ -228,3 +231,46 @@ export function serializeState(state) {
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);
}
// ------------------------------------------------------- repairing old saves
const MAX_LEGIT_POINTS = 25; // more than this in a save older than v3 can only come from the old runaway bonus
const RUNAWAY_PRODUCTION = 1e40;
const REPAIRED_POINTS = 10; // what a runaway save is brought back to
const REPAIRED_PERK_LEVEL = 3;
/**
* Before version 3 every legacy point multiplied output by 1.05 on top of the
* others, so a few thousand points gave 1e100 and runs were over in seconds.
* Such a save keeps its research and achievements but gets its legacy points
* and perks capped and its (meaningless) run restarted. Any older save also has
* its lifetime production capped, so an inflated total cannot hand out a pile
* of points at the next restart.
*/
function repairOlderSave(state, ctx, now) {
const runaway = state.legacy.earned > MAX_LEGIT_POINTS || state.lifetimeProduced > RUNAWAY_PRODUCTION;
if (runaway) {
state.legacy.earned = Math.min(state.legacy.earned, REPAIRED_POINTS);
state.legacy.points = 0;
for (const id of Object.keys(state.legacy.perks)) {
state.legacy.perks[id] = Math.min(state.legacy.perks[id], REPAIRED_PERK_LEVEL);
}
const fresh = createState(ctx, now);
for (const key of Object.keys(fresh)) {
if (!META_KEYS.includes(key) && key !== 'v' && key !== 'savedAt') state[key] = fresh[key];
}
state.repaired = true;
}
const { threshold, exponent } = ctx.rules.legacy;
state.lifetimeProduced = Math.min(state.lifetimeProduced, threshold * (state.legacy.earned + 3) ** (1 / exponent));
return state;
}
/** Untrusted data (cookie, imported file) in, valid and up-to-date state out; null if unusable. */
export function sanitizeState(raw, ctx, now = Date.now()) {
const state = parseState(raw, ctx, now);
if (!state) return null;
return (Number(raw.v) || 1) < STATE_VERSION ? repairOlderSave(state, ctx, now) : state;
}
+9
View File
@@ -189,6 +189,13 @@ function catchUp(elapsedSeconds) {
);
}
/** Tell the player when an older save had to be rebalanced (see repairOlderSave in state.js). */
function reportRepair() {
if (!state.repaired) return;
state.repaired = false;
showToast(t('toast.repaired'), { sticky: true });
}
let lastFrame = Date.now();
function frame() {
@@ -279,6 +286,7 @@ createSettings({
}),
importText(text) {
state = parseImport(text, ctx); // throws before anything changes
reportRepair();
income = 0;
persist();
render();
@@ -322,6 +330,7 @@ async function boot() {
await setTheme(initialTheme.id);
state = loadSavedState() ?? createState(ctx);
reportRepair();
catchUp((Date.now() - state.savedAt) / 1000);
persist();
render();
+9 -7
View File
@@ -1,6 +1,7 @@
// Legacy tab: restart the run for permanent points, spend them on perks.
import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js';
import { legacyGain, nextPointAt, perkCost, perkLevel } from '../engine/legacy.js';
import { passiveBonus } from '../engine/modifiers.js';
import { t } from '../services/i18n.js';
import { h, icon, makeBuyable, setBuyable } from './dom.js';
import { bump, purchased } from './fx.js';
@@ -58,7 +59,7 @@ export function createLegacyView(root, actions) {
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('tab.legacy') }),
h('p', { class: 'hint', text: t('legacy.intro', { passive: fmt.number(ctx.rules.legacy.passivePerPoint * 100) }) }),
h('p', { class: 'hint', text: t('legacy.intro') }),
h('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)),
h(
'dl',
@@ -84,21 +85,22 @@ export function createLegacyView(root, actions) {
}
function update(state, d) {
const { threshold, passivePerPoint } = ctx.rules.legacy;
setText(els.points, fmt.number(state.legacy.points));
setText(els.earned, fmt.number(state.legacy.earned));
setText(els.prestiges, fmt.number(state.legacy.prestiges));
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number((1 + passivePerPoint) ** state.legacy.earned) }));
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number(passiveBonus(state, ctx)) }));
const gain = legacyGain(state, ctx, d.mods);
setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) }));
els.progress.max = threshold;
els.progress.value = Math.min(state.totalProduced, threshold);
// Points are earned from everything ever produced, so the bar shows the lifetime total.
const target = nextPointAt(state, ctx);
els.progress.max = target;
els.progress.value = Math.min(state.lifetimeProduced, target);
setText(
els.need,
gain >= 1
? t('legacy.ready')
: t('legacy.need', { have: fmt.number(Math.floor(state.totalProduced)), need: fmt.number(threshold) }),
: t('legacy.need', { have: fmt.number(Math.floor(state.lifetimeProduced)), need: fmt.number(target) }),
);
setText(els.restart, t('legacy.restart'));
els.restart.disabled = gain < 1;
+17 -4
View File
@@ -3,10 +3,23 @@ import { $, h } from './dom.js';
const DURATION_MS = 6000;
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
export function showToast(message, { error = false } = {}) {
/**
* Show a message for a few seconds.
* `sticky` messages are important: they stay until clicked, and routine messages never push them away.
*/
export function showToast(message, { error = false, sticky = false } = {}) {
const container = $('#toasts');
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
const classes = ['toast', error && 'toast--error', sticky && 'toast--sticky'].filter(Boolean);
const toast = h('p', { class: classes.join(' '), text: message });
if (sticky) {
toast.setAttribute('role', 'alert');
toast.addEventListener('click', () => toast.remove());
} else {
setTimeout(() => toast.remove(), DURATION_MS);
}
container.append(toast);
while (container.children.length > MAX_VISIBLE) container.firstElementChild.remove();
setTimeout(() => toast.remove(), DURATION_MS);
const routine = [...container.children].filter((el) => !el.classList.contains('toast--sticky'));
for (const old of routine.slice(0, Math.max(0, routine.length - MAX_VISIBLE))) old.remove();
}