277 lines
9.6 KiB
JavaScript
277 lines
9.6 KiB
JavaScript
import { clamp } from './formulas.js';
|
|
|
|
// 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
|
|
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',
|
|
'repaired',
|
|
];
|
|
|
|
// 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,
|
|
repaired: false, // set when an old save had to be rebalanced, so the game can say so
|
|
};
|
|
}
|
|
|
|
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.
|
|
*/
|
|
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);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------- 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;
|
|
}
|