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: [], 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 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), 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, ); }