Files
idle_clicker/public/js/engine/state.js
T

231 lines
7.6 KiB
JavaScript

import { clamp } from './formulas.js';
export const STATE_VERSION = 2;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300;
const MAX_LEVEL = 1000;
const STAT_KEYS = ['gambleWins', 'gambleLosses', 'contractsDone', 'eventsClaimed', 'investProfit'];
// Fields that only exist while the game runs and are never saved.
const TRANSIENT = [
'materialTimer',
'materialTrend',
'investTimer',
'contractTimer',
'eventTimer',
'gambleCooldown',
];
// What a legacy reset keeps (the "meta" progression); everything else is the run.
export const META_KEYS = [
'lifetimeProduced',
'stats',
'research',
'labs',
'rp',
'legacy',
'achievements',
];
function newInvest(ctx) {
const ids = ctx.investments.societies.map((s) => s.id);
return {
prices: Object.fromEntries(ctx.investments.societies.map((s) => [s.id, s.basePrice])),
holdings: Object.fromEntries(ids.map((id) => [id, 0])),
basis: Object.fromEntries(ids.map((id) => [id, 0])),
change: Object.fromEntries(ids.map((id) => [id, 0])),
};
}
export function createState(ctx, now = Date.now()) {
const { start, market, events, contracts } = ctx.rules;
return {
v: STATE_VERSION,
// --- the run (reset by a legacy restart)
units: start.units,
funds: start.funds,
material: start.material,
totalProduced: 0,
totalSold: 0,
price: start.price ?? market.referencePrice,
marketing: 0,
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
endless: {},
invest: newInvest(ctx),
chips: 0,
reliabilityLevel: 0,
contracts: { offers: [], active: null },
events: { current: null, buffs: [] },
// --- meta progression (survives a legacy restart)
lifetimeProduced: 0,
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, 0])),
research: [],
labs: 0,
rp: 0,
legacy: { points: 0, earned: 0, prestiges: 0, perks: {} },
achievements: [],
savedAt: now,
// --- transient
materialTimer: 0,
materialTrend: 0, // -1 falling, 0 steady, 1 rising
investTimer: 0,
contractTimer: contracts.offerEverySeconds,
eventTimer: events.minSeconds,
gambleCooldown: 0,
};
}
function num(value, fallback, min = 0, max = MAX_NUMBER) {
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
function int(value, max) {
return Math.floor(num(value, 0, 0, max));
}
function knownIds(list, known) {
return [...new Set(Array.isArray(list) ? list : [])].filter((id) => known.has(id));
}
function sanitizeInvest(raw, ctx, base) {
const inv = ctx.investments;
const out = newInvest(ctx);
for (const s of inv.societies) {
out.prices[s.id] = num(raw?.prices?.[s.id], base.prices[s.id], s.basePrice * inv.minRatio, s.basePrice * inv.maxRatio);
out.holdings[s.id] = num(raw?.holdings?.[s.id], 0);
out.basis[s.id] = out.holdings[s.id] > 0 ? num(raw?.basis?.[s.id], 0) : 0;
out.change[s.id] = num(raw?.change?.[s.id], 0, -1, 100);
}
return out;
}
function sanitizeEndless(raw, ctx) {
const levels = {};
for (const line of ctx.endless) {
const level = int(raw?.[line.id], line.maxLevel);
if (level > 0) levels[line.id] = level;
}
return levels;
}
function sanitizeContracts(raw, ctx) {
const templates = new Set(ctx.rules.contracts.templates.map((t) => t.id));
const rewardTypes = new Set(ctx.rules.contracts.rewards);
const offer = (o) => {
if (!o || !templates.has(o.template) || !rewardTypes.has(o.reward?.type)) return null;
return {
template: o.template,
seconds: num(o.seconds, 60, 1, 3600),
target: num(o.target, 1, 1),
reward: { type: o.reward.type, amount: num(o.reward.amount, 0) },
};
};
const offers = (Array.isArray(raw?.offers) ? raw.offers : []).map(offer).filter(Boolean);
const active = offer(raw?.active?.offer);
return {
offers: offers.slice(0, ctx.rules.contracts.maxOffers),
active: active
? {
offer: active,
progress: num(raw.active.progress, 0, 0, active.target),
remaining: num(raw.active.remaining, active.seconds, 0, active.seconds),
}
: null,
};
}
function sanitizeEvents(raw, ctx) {
const types = new Map(ctx.rules.events.types.map((t) => [t.id, t]));
const current = raw?.current;
const buffTypes = new Set(['demand_mult', 'global_mult', 'material_price_mult']);
return {
current:
current && types.has(current.type) && !types.get(current.type).auto
? { type: current.type, remaining: num(current.remaining, 0, 0, ctx.rules.events.claimSeconds) }
: null,
buffs: (Array.isArray(raw?.buffs) ? raw.buffs : [])
.filter((b) => b && buffTypes.has(b.type) && Number.isFinite(b.value) && Number.isFinite(b.remaining))
.slice(0, 20)
.map((b) => ({
type: b.type,
value: clamp(b.value, 0.01, 100),
remaining: clamp(b.remaining, 0, 3600),
})),
};
}
/**
* Turn untrusted data (cookie, imported file) into a valid state.
* 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()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket, gamble } = ctx.rules;
const base = createState(ctx, now);
const generators = Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
);
const totalProduced = num(raw.totalProduced, 0);
const perks = {};
for (const perk of ctx.legacy) {
const level = int(raw.legacy?.perks?.[perk.id], perk.maxLevel);
if (level > 0) perks[perk.id] = level;
}
return {
...base,
units: num(raw.units, base.units),
funds: num(raw.funds, base.funds),
material: num(raw.material, base.material),
totalProduced,
totalSold: num(raw.totalSold, 0),
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
marketing: int(raw.marketing, MAX_MARKETING),
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators,
upgrades: knownIds(raw.upgrades, ctx.upgradesById),
endless: sanitizeEndless(raw.endless, ctx),
invest: sanitizeInvest(raw.invest, ctx, base.invest),
chips: int(raw.chips, gamble.maxChips),
reliabilityLevel: int(raw.reliabilityLevel, gamble.reliability.maxLevel),
contracts: sanitizeContracts(raw.contracts, ctx),
events: sanitizeEvents(raw.events, ctx),
// Saves from before lifetime tracking: the current run is all we know.
lifetimeProduced: Math.max(num(raw.lifetimeProduced, 0), totalProduced),
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, num(raw.stats?.[k], 0)])),
research: knownIds(raw.research, ctx.researchById),
labs: int(raw.labs, ctx.rules.research.labMax),
rp: num(raw.rp, 0),
legacy: {
points: int(raw.legacy?.points, MAX_LEVEL * 100),
earned: int(raw.legacy?.earned, MAX_LEVEL * 100),
prestiges: int(raw.legacy?.prestiges, MAX_LEVEL),
perks,
},
achievements: knownIds(raw.achievements, ctx.achievementsById),
savedAt: num(raw.savedAt, now, 0, now),
};
}
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) {
const persisted = { ...state };
for (const key of TRANSIENT) delete persisted[key];
return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);
}