SpaceCities / engine /persist.js
Claude
Fix the three engine defects the 3D client reported (#92, #93, #94)
96c6d29 unverified
Raw History Blame
70.3 kB
// @ts-check
/* ============================================================
Save / load. Because the sim is deterministic and seed-driven (see
engine/rng.js), a save doesn't need the whole world β€” the map (terrain,
node positions) regenerates from the seed, so we persist only the seed +
the DYNAMIC state: entities, economies, fog memory, and the AI's bookkeeping.
The per-planet payload (serPlanet / rehydratePlanet) is shared by two savers:
a single skirmish (serializeGame) and a whole Odyssey galaxy of N planets
(serializeGalaxy), which additionally carries each world's market pressure,
diplomacy stance, and colony flag, plus the galaxy meta (credits, active
world, schedule counter). Round-trip and continue-identically are covered by
test/persist.test.js and test/odyssey.test.js.
============================================================ */
"use strict";
import { generateMap } from "./map.js";
import { mulberry32 } from "./rng.js";
import { createFog, updateFog } from "./fog.js";
import { archetypeFor } from "./aiArchetypes.js";
import { CRATER_NODE_AMOUNT } from "./bomb.js";
import { WRECK_SPAWN_DELAY } from "./wreckage.js";
import { LOGI_PRIORITIES } from "./haul.js"; // the enum a building's logiPriority (below) is coerced against
// peekEntityId/restoreEntityId (called at ~4 sites below: gamePayload, deserializeGame,
// galaxyPayload, deserializeGalaxy) read and overwrite `nextEntityId` in engine/state.js β€” a
// SINGLE module-global counter, not per-state. Reading it (peek, on save) is safe any time, but
// writing it (restore, on load) stomps whatever value newId()/createGameState was mid-using, so a
// restoreEntityId() must never race or interleave with another id-minting call (newId, or another
// save/load) β€” e.g. don't deserialize a save while a tick that mints ids is in flight, and don't
// load two saves concurrently. Single-threaded JS makes this safe as long as callers don't
// interleave async id-minting work around these calls; see the "module-global" note on
// nextEntityId in engine/state.js and test/save-hardening.test.js's freshSkirmishSave helper.
import { peekEntityId, restoreEntityId } from "./state.js";
import { createMarket, PRESSURE_FLOOR, PRESSURE_CEIL, GLUT_CEIL } from "./market.js";
import { createDiplomacy } from "./diplomacy.js";
import { STRATEGIES } from "./aiStrategy.js";
import { DIFFICULTY_OPTIONS } from "./aiDifficulty.js";
import { UNITS, BUILDINGS, UPGRADES, storeCapOf, inputCapOf } from "./entities.js";
import { TECHS } from "./techtree.js"; // known research nodes β€” to sanitise a Datacenter's untrusted researchQueue on load
import { COM } from "../data.js";
import { ODYSSEY_WORLDS, isMilestoneId } from "./galaxy.js";
import { sanitizePolicy } from "./colonyPolicy.js";
export const SAVE_VERSION = 1;
export const GALAXY_SAVE_VERSION = 1;
// --- load-time sanitization -------------------------------------------------
// A save loaded from a file or localStorage is UNTRUSTED input (a user can hand-edit a file, and
// localStorage is reachable by any script/devtools). The sim never eval()s save data, so there's
// no classic code-injection vector β€” but a hostile or corrupt payload could still (a) smuggle a
// prototype-polluting key (__proto__/constructor/prototype β€” JSON.parse makes these OWN props that
// can poison later object ops) or (b) be a node/string/depth "bomb" that hangs the load. So before
// a parsed save reaches the deserializer we walk it and reject anything that isn't plain, bounded
// JSON data. Correctness (version, shape) is still the deserializer's job; this is purely the
// safety gate. Throws with a clear reason; returns the input so it can be chained.
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_SAVE_NODES = 600000; // total values β€” a whole galaxy is well under this; a bomb isn't
const MAX_SAVE_DEPTH = 200;
const MAX_STRING_LEN = 4096;
/** Structural gate over untrusted save data. @param {*} input @returns {Object} */
export function sanitizeSave(input) {
if (input === null || typeof input !== "object" || Array.isArray(input))
throw new Error("save is not a valid object");
let nodes = 0;
const walk = (v, depth) => {
if (depth > MAX_SAVE_DEPTH) throw new Error("save is too deeply nested");
if (++nodes > MAX_SAVE_NODES) throw new Error("save is too large");
if (v === null || typeof v === "number" || typeof v === "boolean") return;
if (typeof v === "string") { if (v.length > MAX_STRING_LEN) throw new Error("save has an oversized string"); return; }
if (Array.isArray(v)) { for (const el of v) walk(el, depth + 1); return; }
if (typeof v === "object") {
for (const k of Object.getOwnPropertyNames(v)) { // getOwnPropertyNames also catches a non-enumerable __proto__
if (FORBIDDEN_KEYS.has(k)) throw new Error(`save contains a forbidden key: ${k}`);
walk(v[k], depth + 1);
}
return;
}
throw new Error("save contains an unsupported value"); // functions/symbols can't come from JSON, but be explicit
};
walk(input, 0);
return input;
}
// --- load-time value validation --------------------------------------------
// sanitizeSave() guarantees the payload is plain, bounded JSON β€” but NOT that its
// values make sim sense. A version-valid save (hand-edited, or merely corrupted in
// storage) can still carry a string where a number belongs, a NaN coordinate, or an
// entity `type` that isn't a real unit/building. Those don't throw at load β€” they slip
// in and detonate on the FIRST tick, inside the rAF loop, AFTER load's try/catch has
// already returned success: the game boots, then silently freezes (updateUnit reads
// UNITS[type].role on an undefined def) or drifts (state.time += "100" concatenates).
// So we coerce/clamp the known numeric fields and drop entities of unknown type here,
// on the way in. Deterministic and DOM-free: a VALID save's fields are already finite
// numbers of real types, so every coercion below is the identity on them and the
// byte-identical replay guarantee (test/determinism.test.js) is untouched.
function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
// A saved price book is untrusted like everything else here. Overlay ONLY real commodity keys, and
// only inside the band applySlippage keeps them in at runtime β€” the load path used to Object.assign
// the saved object straight over the fresh one, so a hand-edited (or bit-rotted) pressure of 1e6 was
// a 5,000,005-credit sale, a negative one made selling lose credits, and a string turned
// galaxy.credits permanently NaN. Identity for a valid save: real pressures are already in band.
function coerceBand(dst, src, lo, hi) {
if (!src || typeof src !== "object") return;
for (const com in dst) {
if (!(com in src)) continue;
dst[com] = Math.max(lo, Math.min(num(src[com], 0), hi));
}
}
// The per-world diplomacy block used to be restored as `{ ...createDiplomacy(), ...P.diplomacy }` β€”
// the untrusted object winning every key, including ones no field list mentions. The damage isn't
// hypothetical: engine/diplomacy.js's clamp() is the identity on a non-number, so the drift line
// string-CONCATENATES a non-numeric stance ~3 chars per diplomacy tick. Within ~160 sim-seconds it
// exceeds sanitizeSave's own MAX_STRING_LEN, and since autosave rotates BOTH generations, the game's
// own saves become permanently unloadable and the campaign is gone. Whitelist + coerce instead, the
// same shape cleanPlayer already uses. `request` keeps its existing deeper validation at the call site.
// The galaxy's own settings are untrusted too, and they are the one block that flows into WORLD
// GENERATION: addPlanet passes sizeMult/resourceMult straight to createGameState, so a NaN or a
// string there builds a NaN-sized map on the next jump β€” which then defeats every coordinate clamp
// cleanEntity performs, since those clamp against map.width/height. A missing block used to throw
// mid-jump on s.difficulty. Same shape rehydratePlanet already uses for its own multipliers.
function cleanGalaxySettings(src) {
const s = (src && typeof src === "object") ? src : {};
const difficulty = DIFFICULTY_OPTIONS.some(o => o.mult === s.difficulty) ? s.difficulty : "medium";
const strategy = KNOWN_STRATEGIES.has(s.aiStrategy) ? s.aiStrategy : undefined;
return {
difficulty,
sizeMult: Math.max(0.1, num(s.sizeMult, 1)),
resourceMult: Math.max(0.1, num(s.resourceMult, 1)),
playerFaction: typeof s.playerFaction === "string" ? s.playerFaction : "frontier",
aiApm: s.aiApm == null ? undefined : num(s.aiApm, 0),
aiMicro: s.aiMicro == null ? undefined : !!s.aiMicro,
aiStrategy: strategy,
startId: ODYSSEY_WORLDS.includes(s.startId) ? s.startId : undefined,
popCap: s.popCap == null ? null : Math.max(0, num(s.popCap, 0)),
};
}
function cleanDiplomacy(src) {
const dip = createDiplomacy();
if (!src || typeof src !== "object") return dip;
dip.stance = Math.max(-1, Math.min(num(src.stance, dip.stance), 1));
dip.depletion = Math.max(0, Math.min(num(src.depletion, 0), 1));
dip.tributes = Math.max(0, Math.floor(num(src.tributes, 0)));
dip.goodwill = num(src.goodwill, 0);
dip.lastFavorBucket = Math.floor(num(src.lastFavorBucket, -1));
dip.provokedAt = src.provokedAt == null ? null : num(src.provokedAt, 0);
dip.request = (src.request && typeof src.request === "object") ? src.request : null;
if (src.lastAiUnits !== undefined) dip.lastAiUnits = Math.max(0, num(src.lastAiUnits, 0));
if (src.appeaseUntil !== undefined) dip.appeaseUntil = num(src.appeaseUntil, 0);
if (src.factionEchoUntil !== undefined) dip.factionEchoUntil = num(src.factionEchoUntil, 0);
if (src.factionWarmth !== undefined) dip.factionWarmth = Math.max(0, num(src.factionWarmth, 0));
// Only ADOPT a flag the save actually carries. Unconditionally stamping `pacified: false` would
// add a key to the payload that wasn't there before, which the NET round-trip tests correctly
// reject: a hardening pass must be the identity on a valid save, byte for byte.
if (src.pacified !== undefined) dip.pacified = !!src.pacified;
if (src.warAnnounced !== undefined) dip.warAnnounced = !!src.warAnnounced;
return dip;
}
// Both AI controllers (state.ai, state.playerAi) restore through this ONE function, so the promise
// two comments below β€” that the two blocks can never structurally drift β€” is enforced by
// construction rather than by hand-typing fifteen fields twice. Every value is coerced: the block
// used to be copied verbatim, and `nextAttackAt: "later"` makes `state.time >= nextAttackAt` false
// forever, permanently disabling the AI's attack timeout with no crash and no test.
const KNOWN_STRATEGIES = new Set(Object.keys(STRATEGIES));
const KNOWN_DIFFICULTIES = new Set(DIFFICULTY_OPTIONS.map(o => o.mult));
function cleanController(src, prefix, planetId) {
const g = key => src ? src[prefix + key] : undefined;
const strategy = g("Strategy");
const difficulty = g("Difficulty");
const target = g("ColonyTarget");
return {
think: num(g("Think"), 0),
scoutId: typeof g("ScoutId") === "string" ? g("ScoutId") : null,
colonyTarget: (target && typeof target === "object" && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y)))
? { x: num(target.x, 0), y: num(target.y, 0) } : null,
apm: g("Apm") == null ? null : num(g("Apm"), 0),
micro: !!g("Micro"),
strategy: KNOWN_STRATEGIES.has(strategy) ? strategy : "default",
difficulty: KNOWN_DIFFICULTIES.has(difficulty) ? difficulty : "medium",
lastThreatAt: g("LastThreatAt") == null ? null : num(g("LastThreatAt"), 0),
actionBudget: num(g("ActionBudget"), 0),
attackForce: Math.max(0, num(g("AttackForce"), 0)),
attackDesperate: !!g("AttackDesperate"),
nextAttackAt: g("NextAttackAt") == null ? null : num(g("NextAttackAt"), 0),
unitsBuilt: Math.max(0, Math.floor(num(g("UnitsBuilt"), 0))),
waveCount: Math.max(0, Math.floor(num(g("WaveCount"), 0))),
nextWaveAt: g("NextWaveAt") == null ? undefined : num(g("NextWaveAt"), 0),
// Opponent belief (engine/aiIntel.js). Additive and defaulted the same conservative way
// LastThreatAt is: an old save without these loads as "never seen anything", so a reloaded AI
// starts honestly blind and goes scouting rather than acting on intel it cannot justify.
// Clamped at 0 because a negative asset value is meaningless and would invert posture.
// intelMil/intelEco are PEAKS; each carries its own stamp. A save written before the
// per-channel stamps existed has neither, and loads with both null β€” aiIntel.js's channelValue
// falls back to the shared IntelAt in exactly that case, which is the closest true statement
// the old data supports. Still purely additive, so no SAVE_VERSION bump (CONTRIBUTING Β§3).
intelMil: Math.max(0, num(g("IntelMil"), 0)),
intelMilAt: g("IntelMilAt") == null ? null : num(g("IntelMilAt"), 0),
intelEco: Math.max(0, num(g("IntelEco"), 0)),
intelEcoAt: g("IntelEcoAt") == null ? null : num(g("IntelEcoAt"), 0),
intelAt: g("IntelAt") == null ? null : num(g("IntelAt"), 0),
// Clamped to the 0..1 the stance is defined on, so a corrupt save cannot push the AI into a
// defence multiplier outside its designed swing.
adaptMode: g("AdaptMode") == null ? null : Math.min(1, Math.max(0, num(g("AdaptMode"), 0.5))),
archetype: archetypeFor(planetId),
};
}
// Coerce one entity's numeric fields to finite values, defaulting hp/maxHp from its
// def and clamping coordinates into the map so a bad value can never poison the spatial
// hash (NaN,NaN buckets) or the hp accounting (an un-killable NaN-hp ghost).
function cleanEntity(e, def, map) {
const maxHp = num(e.maxHp, def.hp);
e.maxHp = maxHp > 0 ? maxHp : def.hp;
e.hp = Math.max(0, Math.min(num(e.hp, e.maxHp), e.maxHp));
e.x = Math.max(0, Math.min(num(e.x, 0), map.width));
e.y = Math.max(0, Math.min(num(e.y, 0), map.height));
// An order (and each queued waypoint) can carry target x/y coords that a tampered save could set to
// NaN/huge/non-numeric garbage β€” which then flow straight into stepToward as the move destination
// and NaN the unit's position (engine/sim.js, engine/combat.js), permanently poisoning the spatial
// hash. Clamp any x/y an order carries the SAME way as the unit's own x/y; orders without coords
// (gather/build/scout/attack-by-id) and a null/absent order are left untouched β€” identity for valid.
const clampOrderCoords = o => {
if (!o || typeof o !== "object") return;
if (o.x !== undefined) o.x = Math.max(0, Math.min(num(o.x, 0), map.width));
if (o.y !== undefined) o.y = Math.max(0, Math.min(num(o.y, 0), map.height));
};
clampOrderCoords(e.order);
if (Array.isArray(e.orderQueue)) for (const o of e.orderQueue) clampOrderCoords(o);
// A building's `rally` is the one coordinate pair this function used to skip, and it flows to
// exactly the same place the order coords do: engine/production.js spawns every produced unit with
// `order = { type:"move", x: rally.x, y: rally.y }`. movement.js rejects NaN but not a large FINITE
// value, so a tampered 1e9 rally sent every unit the building made walking off the map forever,
// outside every fog and grid bound. Same clamp, plus dropping a non-string nodeId before it reaches
// the rally-to-resource node lookup.
if (e.rally && typeof e.rally === "object") {
clampOrderCoords(e.rally);
if (e.rally.nodeId !== undefined && typeof e.rally.nodeId !== "string") e.rally.nodeId = null;
}
// A worker's `cargo` hold ({com, qty}) is untrusted too: coerce its qty to a finite value >= 0 (a
// NaN/negative haul would poison the gather/haul/bank math on the first tick), and DROP the whole
// cargo (β†’ null) when it isn't an object or names a bogus commodity β€” a `com` outside COM would
// become a phantom treasury key the moment it's banked. An EMPTY hold (com:null) is the valid
// resting state of every worker, so it's kept; a non-worker's cargo is already null and is left as
// is. Identity for valid data.
if (e.cargo !== undefined && e.cargo !== null) {
if (typeof e.cargo !== "object" || (e.cargo.com != null && !COM[e.cargo.com])) e.cargo = null;
else e.cargo.qty = Math.max(0, num(e.cargo.qty, 0));
}
// A freighter's `freight` hold is untrusted save data (a hand-edited file could smuggle in a
// negative, NaN, or over-capacity haul, or a bogus commodity): keep only real commodities with a
// positive finite qty, and clamp the total to the ship's cargoHold. A non-freighter can't carry
// freight at all, so strip any that a tampered save bolted on.
if (def.cargoHold) {
const clean = {};
let used = 0;
if (e.freight && typeof e.freight === "object") {
for (const com of Object.keys(e.freight)) {
if (!COM[com] || used >= def.cargoHold) continue;
const q = num(e.freight[com], 0);
if (q > 0) { const take = Math.min(q, def.cargoHold - used); clean[com] = take; used += take; }
}
}
e.freight = clean;
} else if (e.freight !== undefined) {
delete e.freight;
}
// A collection-point freighter's `anchor` ({x,y}, engine/commands.js issueSetCollectPoint) is
// untrusted the same way β€” a NaN/huge value would flow straight into stepToward on the shuttle
// run's return leg and poison the unit's position. Clamp it like the entity's own x/y, or drop it
// entirely if it isn't a real {x,y} pair (a shuttle run just re-anchors on its own next tick, see
// assignShuttle's fallback).
if (e.anchor !== undefined) {
if (!e.anchor || typeof e.anchor !== "object") delete e.anchor;
else {
e.anchor.x = Math.max(0, Math.min(num(e.anchor.x, 0), map.width));
e.anchor.y = Math.max(0, Math.min(num(e.anchor.y, 0), map.height));
}
}
// A building's per-building logistics priority (engine/commands.js issueSetLogiPriority,
// engine/haul.js LOGI_PRIORITIES/priorityWeight) is untrusted too: keep it only if it's a real
// enum value ("high"/"normal"/"low"), else drop the field entirely β€” the same "reads as the
// default" treatment `anchor` above gets, since priorityWeight already falls back to weight 1
// (normal) for a missing field. Additive: an old save simply predates the field.
if (e.logiPriority !== undefined && !LOGI_PRIORITIES.includes(e.logiPriority)) delete e.logiPriority;
// A Plasma Rig's dig state is untrusted too: clamp digProgress into a sane band (a tampered huge
// value would otherwise drive the dig loop to mint resources) and floor the dig counter.
if (def.rig) {
e.digProgress = Math.max(0, Math.min(num(e.digProgress, 0), 2));
e.digCount = Math.max(0, Math.floor(num(e.digCount, 0)));
}
// A recycling entity's `recycling` ({progress, time}, engine/recycle.js) is untrusted too β€” a
// tampered save could set progress negative/huge/NaN (skipping or stalling the countdown) or
// time to zero/negative (dividing by it goes non-finite the moment the next tick advances it).
// Clamp progress into [0,1] and time to a sane positive floor; drop the field entirely if it
// isn't a real object, so a bad value just reads as "not recycling" β€” the identity for valid data.
if (e.recycling !== undefined) {
if (!e.recycling || typeof e.recycling !== "object") delete e.recycling;
else {
e.recycling.time = Math.max(1, num(e.recycling.time, 1));
e.recycling.progress = Math.max(0, Math.min(num(e.recycling.progress, 0), 1));
}
}
// A wonder's `charge` (a 0..1 float, engine/wonder.js) is untrusted: a hand-edited save could set it
// huge, negative, NaN, or non-numeric β€” and an out-of-band charge trips an instant standalone-endless
// win (engine/victory.js checkEndlessWin fires at charge >= 1) or corrupts the charge/HUD math. Clamp
// it into [0,1] on load. Only touch a wonder that actually carries the field (an uncharged Gate has
// none yet), and a legitimately-saved charge is already in [0,1] β€” so this is the identity.
if (def.wonder && e.charge !== undefined) e.charge = Math.max(0, Math.min(num(e.charge, 0), 1));
// A Helium Bomb's `armed` flag is untrusted too β€” coerce to a real boolean so a tampered
// save can't smuggle in a truthy non-boolean that reads oddly elsewhere. Only a role:"bomb"
// unit carries the field at all; harmless to any other def, but only worth writing there.
if (def.role === "bomb") e.armed = !!e.armed;
// A Helium Bomb's `fuseUntil` (the state.time its lit fuse detonates at, engine/bomb.js) is
// untrusted too: a tampered save could set it to a non-numeric or non-finite value, which
// would then poison the `state.time >= bomb.fuseUntil` comparison in updateBombFuse (NaN
// compares false forever, so it'd sit fused but never actually go off). Drop the field
// entirely when it isn't a finite number, so a bad value reads as "no fuse lit" β€” a real
// saved fuse is already a plain finite number, so this is the identity for valid data.
if (e.fuseUntil !== undefined) { const f = num(e.fuseUntil, NaN); if (Number.isFinite(f)) e.fuseUntil = f; else delete e.fuseUntil; }
// A Spaceport's `lastLanding` (the galaxy-time it last received a jump β€” engine/galaxy.js's
// landingZone) is untrusted too: clamp to a finite, non-negative number so a tampered huge/NaN
// value can't win the "most recently used pad" tie-break it's only ever compared with. Only
// present once a jump has actually landed there, so this only fires when the field exists.
if (e.type === "spaceport" && e.lastLanding !== undefined) e.lastLanding = Math.max(0, num(e.lastLanding, 0));
// A unit's player-set `facing` (engine/commands.js applyFacing) is untrusted too β€” a bogus
// non-numeric value would flow straight into Math.cos/sin (renderShared.js) as NaN, silently
// mis-drawing (never crashing) the sprite. Coerce it to a finite number, or drop the field
// entirely so the unit just falls back to its normal movement-inferred facing.
if (e.facing !== undefined) { const f = num(e.facing, NaN); if (Number.isFinite(f)) e.facing = f; else delete e.facing; }
// A unit's `lastHitAt` (the state.time it last took damage β€” engine/combat.js performAttack/
// applySplash, read by the Bulwark doctrine's out-of-combat regen, engine/repair.js
// updateBulwarkRegen) is untrusted too: a non-numeric/non-finite value would flow straight into
// `state.time - unit.lastHitAt` there as NaN, which always compares false β€” so a bad value
// would just silently disable that unit's regen forever rather than crash, but drop it entirely
// so it reads as "never hit" instead (the same graceful-default treatment fuseUntil/facing get
// above, not a value worth preserving). A real saved value is already a finite number, so this
// is the identity for valid data.
if (e.lastHitAt !== undefined) { const t = num(e.lastHitAt, NaN); if (Number.isFinite(t)) e.lastHitAt = t; else delete e.lastHitAt; }
// A unit's `kills` (engine/combat.js performAttack's target-died branch, entities.js
// rankMults) is untrusted too: a tampered save could set it negative/fractional/non-numeric,
// which would still compare fine against rankMults' `kills >= threshold` checks (worst case a
// free/missing rank) but isn't a value a real game ever produces. Clamp to a non-negative
// integer rather than drop it entirely β€” 0 is itself a valid, common value (a fresh unit), so
// "missing" and "explicitly zero" should read the same way, unlike lastHitAt's drop-if-bad
// treatment above (where "never hit" and "hit at time 0" are genuinely different states).
if (e.kills !== undefined) e.kills = Math.max(0, Math.floor(num(e.kills, 0)));
// A producer's output buffer (building.store) and a factory's input buffer (building.input) are
// untrusted save data β€” a hand-edited file could smuggle in a bogus commodity, a negative/NaN qty,
// or an over-capacity buffer. Keep only real commodities with a positive qty, clamped to capacity β€”
// `store` as one shared pooled total (storeCapOf), `input` per-commodity independently (inputCapOf
// is now a per-commodity slice, coerceInputBuffer clamps each on its own, see its doc comment); a
// building with no such buffer can't hold one, so strip any a tampered save bolted on.
e.store = coerceBuffer(e.store, storeCapOf(e.type));
e.input = coerceInputBuffer(e.input, inputCapOf(e.type));
if (storeCapOf(e.type) <= 0) delete e.store;
if (inputCapOf(e.type) <= 0) delete e.input;
// A building's production queue is untrusted: a bogus/unknown unitType would deref undefined and
// brick the game on the first tick (see engine/production.js). For buildings only (units carry no
// queue), rebuild it from known-good jobs β€” real UNIT types, progress clamped to [0,1], the
// paid-with-alt flag preserved, everything else dropped β€” and empty it for a building that can't
// produce or whose queue isn't even an array. For a valid save this is the identity, so the
// byte-identical round-trip is untouched.
if (BUILDINGS[e.type]) {
e.queue = (BUILDINGS[e.type].produces && Array.isArray(e.queue))
? e.queue.filter(j => j && UNITS[j.unitType]).map(j => ({
unitType: j.unitType,
progress: Math.max(0, Math.min(num(j.progress, 0), 1)),
...(j.alt ? { alt: true } : {}),
}))
: [];
// A Datacenter's OR Refinery's research queue is untrusted exactly like the production queue
// above: a non-array `researchQueue` (e.g. the number 5) throws .length/.shift and bricks the
// game on the first research tick (engine/techtree.js updateResearch, which now resolves EITHER
// TECHS or UPGRADES by building.type β€” doctrine research develops over time too, not just the
// Datacenter's tech tree), and a bogus techId derefs an undefined def either way. When the field
// is present, rebuild it from known-good jobs β€” a real TECHS OR UPGRADES id (whichever table
// this building's own type actually resolves β€” the two id spaces only ever overlap on the
// deliberately-shared "recycling" id, entities.js, which is harmless here since updateResearch
// itself always resolves that id against the one table this building's type maps to), progress
// clamped to [0,1] β€” or [] for a non-array. A building WITHOUT the field (any building that
// never queued research) is left untouched, so it's the identity for a valid save.
if (e.researchQueue !== undefined) {
e.researchQueue = Array.isArray(e.researchQueue)
? e.researchQueue.filter(j => j && (TECHS[j.techId] || UPGRADES[j.techId])).map(j => ({
techId: j.techId,
progress: Math.max(0, Math.min(num(j.progress, 0), 1)),
}))
: [];
}
}
return e;
}
// Sanitize an untrusted commodity→qty buffer: real commodities only, positive finite qty,
// TOTAL (summed across every commodity) clamped to `cap` β€” a genuinely shared pool, which is
// what a producer's output buffer (building.store) actually is. Returns {} for a zero/undefined cap.
function coerceBuffer(buf, cap) {
const clean = {};
if (cap <= 0 || !buf || typeof buf !== "object") return clean;
let used = 0;
for (const com of Object.keys(buf)) {
if (!COM[com] || used >= cap) continue;
const q = num(buf[com], 0);
if (q > 0) { const take = Math.min(q, cap - used); clean[com] = take; used += take; }
}
return clean;
}
// Same sanitizing as coerceBuffer, but for a factory's INPUT larder (building.input), where `cap`
// (entities.js inputCapOf) is now a PER-COMMODITY slice, not one shared pool (see its own doc
// comment: an oversupplied input must never crowd out room for another the recipe still needs) β€”
// so each commodity is clamped to `cap` INDEPENDENTLY, not against a shared running total.
function coerceInputBuffer(buf, cap) {
const clean = {};
if (cap <= 0 || !buf || typeof buf !== "object") return clean;
for (const com of Object.keys(buf)) {
if (!COM[com]) continue;
const q = num(buf[com], 0);
if (q > 0) clean[com] = Math.min(q, cap);
}
return clean;
}
// Sanitize an untrusted commodity->qty bag with no shared cap to clamp against β€” a pending
// wreck site's `goods` (engine/wreckage.js) isn't bounded by a building's storeCap the way
// coerceBuffer's inputs are, so this just keeps real commodities with a positive finite qty.
function sanitizeGoods(g) {
const clean = {};
if (!g || typeof g !== "object") return clean;
for (const com of Object.keys(g)) {
if (!COM[com]) continue;
const q = num(g[com], 0);
if (q > 0) clean[com] = q;
}
return clean;
}
// A player's economy is untrusted save data exactly like an entity's buffers above: `resources`
// (commodity→qty) and `upgrades` (tech/upgrade id→true) both flow straight into gameplay math the
// moment the game starts ticking β€” a bogus commodity key would mint a phantom treasury entry the
// first time it's spent/produced, a string/NaN/negative qty would poison the market and production
// math, and a bogus tech/upgrade id sitting in `upgrades` is a landmine for the multiplier scans in
// techtree.js/entities.js (`for (const id in upgrades) if (upgrades[id] && TECHS[id]/UPGRADES[id]...`)
// the moment a real id with that name is ever added. Keep only real COM keys with a finite qty >= 0
// (identity for a valid save β€” its quantities are already finite and non-negative, no cap to clamp
// against since a player's bank is unbounded, unlike a building's buffer). For `upgrades`, keep only
// keys that are a real TECHS or UPGRADES id AND already truthy, normalised to the boolean `true`
// every grant in the codebase uses (techtree.js: `player.upgrades[job.techId] = true`) β€” upgrades are
// a pure presence-flag, so an explicit `false` (or any other falsy value) on a tampered save is
// dropped rather than preserved, matching how a valid save represents "not researched" (the key is
// simply absent, never `false`). Mutates and returns `p` so it drops straight into rehydratePlanet.
function cleanPlayer(p) {
const resources = {};
if (p.resources && typeof p.resources === "object") {
for (const com of Object.keys(p.resources)) {
if (!COM[com]) continue;
const q = num(p.resources[com], NaN);
if (Number.isFinite(q) && q >= 0) resources[com] = q;
}
}
p.resources = resources;
const upgrades = {};
if (p.upgrades && typeof p.upgrades === "object") {
for (const id of Object.keys(p.upgrades))
if (p.upgrades[id] && (TECHS[id] || UPGRADES[id])) upgrades[id] = true;
}
p.upgrades = upgrades;
return p;
}
// Largest numeric suffix among the state's OWN-minted ids ("u12"/"b7" β€” the ids newId
// mints from the global counter; "g"-scheme galaxy ids come from a separate counter and
// are ignored here). Used to guarantee the restored counter can never mint an id that
// collides with a loaded entity, even if the save's nextEntityId is missing/low/garbage.
function maxOwnEntityId(state) {
let m = 0;
const scan = id => { const s = /^[ub](\d+)$/.exec(String(id)); if (s) { const n = +s[1]; if (n > m) m = n; } };
for (const id of state.units.keys()) scan(id);
for (const id of state.buildings.keys()) scan(id);
return m;
}
function serPlayer(p) {
return { id: p.id, faction: p.faction, isAI: p.isAI, color: p.color,
resources: { ...p.resources }, upgrades: { ...p.upgrades } };
}
// The DYNAMIC per-planet payload β€” everything the seed can't regenerate. Shared by
// the skirmish save and every planet of a galaxy save. visible fog is NOT stored
// (recomputed on load); only `explored` (permanent scouted memory) persists. The
// global entity-id counter is NOT here β€” it's saved once by the caller.
function serPlanet(state) {
return {
seed: state.seed, planetId: state.planetId,
sizeMult: state.sizeMult, resourceMult: state.resourceMult,
swapAsym: !!state.swapAsym, // additive: which side plays which half of an asym world's matchup (engine/map.js) β€” default false, no version bump
// setup.js's Match length row override (engine/victory.js DEFAULT_MATCH_TIME_LIMIT) β€” additive,
// defaults null (no override, same as every pre-existing save) so no SAVE_VERSION bump per
// CONTRIBUTING's additive-field rule.
matchTimeLimit: state.matchTimeLimit ?? null,
// setup.js's Population cap row (engine/supply.js's popCap clamp) β€” additive, defaults null
// (Max β€” uncapped, same as every pre-existing save) so no SAVE_VERSION bump either.
popCap: state.popCap ?? null,
endless: !!state.endless,
time: state.time, tick: state.tick, over: state.over, winner: state.winner,
// additive: why the match ended (engine/victory.js finish) β€” default null, same as an
// in-progress or pre-this-feature save; no SAVE_VERSION bump per CONTRIBUTING.
winReason: state.winReason ?? null,
// One entry per side, in state.owners order β€” for the player-vs-ai pair this
// serialises as { player, ai }, byte-identical to the old literal. The save
// shape stays two-keyed (fog/fogAI below likewise): a save FORMAT built for N
// sides is separate, deferred work.
players: Object.fromEntries((state.owners || Object.keys(state.players)).map(id => [id, serPlayer(state.players[id])])),
// `_gi` is the grid broad-phase index β€” a transient stamped fresh onto every unit each tick
// by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
// per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
// `squadLeader`/`squadFollowers` (engine/commands.js) are live UNIT OBJECT REFERENCES, not
// ids β€” session-only, transient bookkeeping like the rest of this denylist, but they ALSO
// can't survive JSON.stringify at all: a follower points at its leader, the leader's own
// squadFollowers list points right back, and JSON.stringify throws on a circular structure
// rather than silently truncating it β€” a live squad would crash every save. Strip both
// fields, and null out a live `follow-leader` order too (it embeds the same leader
// reference) β€” a reload simply drops squad membership, the same "recomputed/reset, never
// persisted" treatment repairTargetId already gets.
// `ferriers` (a freighter's per-tick ferry-worker tally, engine/haul.js countLogistics) and
// `repairers` (a unit can now be a worker repair-job TARGET too, engine/repair.js
// countRepairJobs) are transient too, same reasoning as `haulers`/`servers` below β€” stripped
// here since they can live on a UNIT, not just a building. `homeCC` (a player-assigned home
// base, engine/commands.js issueSetHomeBase) is real persisted intent, kept as-is β€” a stale
// reference to a destroyed CC is harmless, zoneFirst just falls back to the nearest-CC guess.
units: [...state.units.values()].map(({ _gi, repairTargetId, squadLeader, squadFollowers, ferriers, repairers, ...u }) =>
u.order && u.order.type === "follow-leader" ? { ...u, order: null } : u),
// `haulers`/`servers` (logistics tallies, engine/haul.js), `repairers` (worker repair-job
// tally, engine/repair.js countRepairJobs), `powered`/`fuel` (Generator fuel state, engine/
// industry.js), `menderClaims` (auto-repair Mender tally, engine/sim.js) and `lastYield`/
// `lastTier` (a Plasma Rig's last-strike HUD readout, engine/rig.js β€” regenerated on the next
// dig) are all transient β€” stamped fresh each tick like a unit's `_gi` grid index β€” so strip
// them from saves. (digProgress/digCount are the rig's REAL persisted dig state, kept.)
buildings: [...state.buildings.values()].map(({ haulers, servers, repairers, powered, fuel, menderClaims, lastYield, lastTier, ...b }) => b),
// A map-generated node is regenerated fresh from the seed on load (generateMap), so only
// its `amount` (the one thing that changes) needs saving. A crater node (engine/bomb.js)
// or a wreck node (engine/wreckage.js) does NOT exist in that regeneration at all β€” it
// needs its whole shape saved, and rehydratePlanet below re-adds it after the seed
// regenerates everything else.
nodes: state.map.nodes.map(n => (n.crater || n.wreck)
? { id: n.id, com: n.com, amount: n.amount, max: n.max, x: n.x, y: n.y, ...(n.crater ? { crater: true } : { wreck: true }) }
: { id: n.id, amount: n.amount }),
// Pending Helium Bomb craters not yet matured β€” lost without this (a save/load right
// after a detonation would silently cancel the future deposit).
craters: (state.craters || []).map(c => ({ id: c.id, x: c.x, y: c.y, owner: c.owner, spawnAt: c.spawnAt })),
// Pending battle-wreckage sites (engine/wreckage.js) not yet matured β€” same reasoning
// as craters above. No `owner`: wreckage is neutral, unlike a crater. `value` is the
// running battle-intensity total (engine/wreckage.js applyBattleBonus) β€” lost without
// this, a save/load mid-battle would silently reset a site's bonus-material progress.
// `createdAt` anchors how far spawnAt can be extended by a later contribution
// (mergeIntoWreckSite) β€” lost without it, a reloaded raging battle could re-extend
// past WRECK_MAX_DELAY.
wrecks: (state.wrecks || []).map(w => ({
id: w.id, x: w.x, y: w.y, n: w.n, value: w.value, createdAt: w.createdAt,
goods: { ...w.goods }, spawnAt: w.spawnAt,
})),
fog: [...state.fog.explored],
fogAI: [...state.fogAI.explored],
ai: {
aiThink: state.ai.think ?? 0, aiScoutId: state.ai.scoutId ?? null,
aiApm: state.ai.apm ?? null, aiMicro: !!state.ai.micro,
// The player-picked AI strategy (engine/aiStrategy.js) β€” persisted the same redundant-per-planet
// way aiApm/aiMicro already are (not re-derived like the archetype, since it's not a function of
// planetId), so an old save without the field defaults to "default" (byte-identical to today).
aiStrategy: state.ai.strategy || "default",
// The splash-screen Easy/Medium/Hard pick (engine/aiDifficulty.js) β€” persisted the same
// redundant-per-planet way aiApm/aiMicro/aiStrategy already are, so an old save without the
// field defaults to "medium" (the same fallback difficultyFor(state) itself applies).
aiDifficulty: state.ai.difficulty || "medium",
// Sim-time of the last seen threat near home (drives Economic's war-footing window,
// engine/ai.js). Additive + nullish-defaulted, so an old save without it loads as "no recent
// threat" β€” the safe, conservative default (a freshly reloaded game reads as being at peace).
aiLastThreatAt: state.ai.lastThreatAt ?? null,
aiActionBudget: state.ai.actionBudget ?? 0,
aiAttackForce: state.ai.attackForce ?? 0, aiAttackDesperate: !!state.ai.attackDesperate,
aiNextAttackAt: state.ai.nextAttackAt ?? null, aiUnitsBuilt: state.ai.unitsBuilt ?? 0,
// Committed-wave counter (engine/ai.js): drives the economy-raid cadence
// (aiWaveCount % RAID_EVERY). Omitting it reset the counter to 0 on every reload,
// shifting all subsequent raid-vs-base decisions β€” the same continue-identically
// break the aiNextWaveAt note below warns about. Additive + `|| 0`-defaulted in
// ai.js, so old saves without it load fine.
aiWaveCount: state.ai.waveCount ?? 0,
// Odyssey offense cadence (engine/ai.js) β€” a scheduled future time. Must be
// persisted or a reloaded hostile world fires its next probe a full cadence
// early (undefined ?? 0 β‡’ immediately wave-ready), breaking continue-identically.
aiNextWaveAt: state.ai.nextWaveAt ?? null,
// Opponent belief (engine/aiIntel.js): a fading high-water mark of what this controller has
// SEEN of the enemy. Persisted for the same continue-identically reason aiWaveCount is β€”
// dropping it would make a reloaded AI forget the army it scouted and re-derive its whole
// posture read from an empty picture, changing every downstream decision.
aiIntelMil: state.ai.intelMil ?? 0, aiIntelEco: state.ai.intelEco ?? 0,
aiIntelMilAt: state.ai.intelMilAt ?? null, aiIntelEcoAt: state.ai.intelEcoAt ?? null,
aiIntelAt: state.ai.intelAt ?? null,
aiAdaptMode: state.ai.adaptMode ?? null,
// Odyssey colony-ship expansion target (engine/ai.js) β€” the committed deploy spot
// of an in-flight ship. Persisted so a reload doesn't recompute a different target.
aiColonyTarget: state.ai.colonyTarget ?? null,
},
// Tier 1 self-play's SECOND, parallel AI controller (owner "player" β€” see engine/state.js's
// createAiController, engine/ai.js's runAI(state, dt, owner)). Purely additive: every save
// that predates this feature (and every non-self-play save made after it) has state.playerAi
// === null, so this serializes to `null` and rehydratePlanet's `P.playerAi ? … : null` below
// reads that back as null too β€” a byte-identical round trip with NO SAVE_VERSION bump. Field
// names mirror the `ai` block above one-for-one (pa-prefixed instead of ai-prefixed) so the
// two controllers can never structurally drift.
playerAi: state.playerAi ? {
paThink: state.playerAi.think ?? 0, paScoutId: state.playerAi.scoutId ?? null,
paApm: state.playerAi.apm ?? null, paMicro: !!state.playerAi.micro,
paStrategy: state.playerAi.strategy || "default",
paDifficulty: state.playerAi.difficulty || "medium",
paLastThreatAt: state.playerAi.lastThreatAt ?? null,
paActionBudget: state.playerAi.actionBudget ?? 0,
paAttackForce: state.playerAi.attackForce ?? 0, paAttackDesperate: !!state.playerAi.attackDesperate,
paNextAttackAt: state.playerAi.nextAttackAt ?? null, paUnitsBuilt: state.playerAi.unitsBuilt ?? 0,
paWaveCount: state.playerAi.waveCount ?? 0,
paNextWaveAt: state.playerAi.nextWaveAt ?? null,
// Opponent belief (engine/aiIntel.js): a fading high-water mark of what this controller has
// SEEN of the enemy. Persisted for the same continue-identically reason aiWaveCount is β€”
// dropping it would make a reloaded AI forget the army it scouted and re-derive its whole
// posture read from an empty picture, changing every downstream decision.
paIntelMil: state.playerAi.intelMil ?? 0, paIntelEco: state.playerAi.intelEco ?? 0,
paIntelMilAt: state.playerAi.intelMilAt ?? null, paIntelEcoAt: state.playerAi.intelEcoAt ?? null,
paIntelAt: state.playerAi.intelAt ?? null,
paAdaptMode: state.playerAi.adaptMode ?? null,
paColonyTarget: state.playerAi.colonyTarget ?? null,
} : null,
};
}
// Rebuild a single engine state from a per-planet payload: regenerate the
// deterministic map from the seed, overlay saved node amounts by id, restore
// entities/economies/AI bookkeeping, and recompute current visibility. Does NOT
// touch the global entity-id counter (the caller restores it once, last).
function rehydratePlanet(P) {
// `sizeMult`/`resourceMult` are untrusted save data that feed straight into generateMap's
// Math.min/Math.max sizing math (engine/map.js), which only guards FALSY values (`opts.sizeMult ||
// 1`) β€” a NaN, a string, or a non-finite number propagates through unguarded and produces a
// NaN-sized map, which then defeats every coordinate clamp cleanEntity performs below (they clamp
// against map.width/map.height, themselves NaN). Sanitize to a finite, positive number, defaulting
// to 1 (the game's own default β€” see engine/state.js's `opts.sizeMult || 1` and setup.js) on
// anything else, and floor it at 0.1 so a legitimate-but-tiny value can't zero out the map. Compute
// ONCE and reuse for both the map generation below AND the state.sizeMult/resourceMult assignment
// further down, so the map's real dimensions and the state's reported multiplier can never disagree.
const sizeMult = Math.max(0.1, num(P.sizeMult, 1));
const resourceMult = Math.max(0.1, num(P.resourceMult, 1));
// Additive, coerced to a real boolean like every other untrusted save flag: a save from before
// this field existed (or a tampered non-boolean) reads as unswapped, not throwing/undefined.
const swapAsym = !!P.swapAsym;
// setup.js's Match length override (engine/victory.js DEFAULT_MATCH_TIME_LIMIT): additive and
// untrusted like every other save field. `null`/missing means "no override" (the common case β€”
// must NOT sanitize through num()'s Number(null)===0 coercion, which would silently turn "no
// override" into "end the match at time 0"). A non-null value is only trusted if it coerces to a
// genuinely POSITIVE finite number; anything else (0, negative, NaN, a string) falls back to the
// same safe "no override" null rather than a broken instant/never timeout.
const matchTimeLimit = P.matchTimeLimit == null ? null
: (Number.isFinite(Number(P.matchTimeLimit)) && Number(P.matchTimeLimit) > 0 ? Number(P.matchTimeLimit) : null);
// setup.js's Population cap row (engine/supply.js's popCap clamp): additive and untrusted like
// matchTimeLimit just above, same reasoning/shape. null/missing means Max (no ceiling, the
// common case, and every pre-existing save); a non-null value is only trusted if it coerces to
// a genuinely POSITIVE finite number, else falls back to the same safe "no ceiling" null.
const popCap = P.popCap == null ? null
: (Number.isFinite(Number(P.popCap)) && Number(P.popCap) > 0 ? Number(P.popCap) : null);
const map = generateMap(P.planetId, mulberry32((P.seed ?? 0) >>> 0),
{ sizeMult, resourceMult, swapAsym });
const amounts = new Map(P.nodes.map(n => [n.id, n.amount]));
for (const n of map.nodes) if (amounts.has(n.id)) n.amount = amounts.get(n.id);
// A crater node (engine/bomb.js) never comes back from the seed regeneration above β€” it
// isn't part of the deterministic map at all β€” so re-add it here from its own saved shape,
// sanitized the same way a tampered entity field would be: a real commodity, finite
// amount/max clamped sane, position clamped onto the map. Only entries actually marked
// `crater: true` are considered, so this can't be used to smuggle an arbitrary extra node
// onto a map-generated id.
for (const n of P.nodes) {
if (!n || !n.crater || map.nodesById.has(n.id) || !COM[n.com]) continue;
const max = Math.max(1, num(n.max, CRATER_NODE_AMOUNT));
const node = {
id: String(n.id), com: n.com, crater: true,
max, amount: Math.max(0, Math.min(num(n.amount, max), max)),
x: Math.max(0, Math.min(num(n.x, 0), map.width)),
y: Math.max(0, Math.min(num(n.y, 0), map.height)),
};
map.nodes.push(node);
map.nodesById.set(node.id, node);
}
// A wreck node (engine/wreckage.js) never comes back from the seed regeneration either β€”
// same reasoning and same sanitization as the crater block above, just keyed off `wreck:
// true` instead, and with no fixed default amount to fall back to (a wreck's size always
// comes from what died there, so 1 is just a safe floor for a corrupt/missing value).
for (const n of P.nodes) {
if (!n || !n.wreck || map.nodesById.has(n.id) || !COM[n.com]) continue;
const max = Math.max(1, num(n.max, 1));
const node = {
id: String(n.id), com: n.com, wreck: true,
max, amount: Math.max(0, Math.min(num(n.amount, max), max)),
x: Math.max(0, Math.min(num(n.x, 0), map.width)),
y: Math.max(0, Math.min(num(n.y, 0), map.height)),
};
map.nodes.push(node);
map.nodesById.set(node.id, node);
}
const fog = createFog(map); fog.explored = Uint8Array.from(P.fog);
const fogAI = createFog(map); fogAI.explored = Uint8Array.from(P.fogAI);
// Keep only entities of a REAL type, coercing their numeric fields β€” an unknown
// `type` makes UNITS[type]/BUILDINGS[type] undefined and throws on the first tick;
// a NaN coord/hp silently corrupts the sim. Drop the former, clean the latter.
const units = new Map();
for (const u of P.units) { const def = UNITS[u.type]; if (def) units.set(u.id, cleanEntity(u, def, map)); }
const buildings = new Map();
for (const b of P.buildings) { const def = BUILDINGS[b.type]; if (def) buildings.set(b.id, cleanEntity(b, def, map)); }
// Rebuild the owner-generic scaffold from the (two-keyed) save: state.owners is
// the side list, state.fogs maps each to its fog, and state.fog/state.fogAI stay
// as aliases so the many fog consumers keep working. This mirrors createGameState.
// Each player's resources/upgrades are untrusted save data too β€” sanitized by cleanPlayer
// (see its comment) before they reach live gameplay math.
const players = { player: cleanPlayer(P.players.player), ai: cleanPlayer(P.players.ai) };
const owners = Object.keys(players);
const fogs = { player: fog, ai: fogAI };
// Pending Helium Bomb craters (engine/bomb.js) not yet matured β€” additive, so an older
// save without the field just has none. A bogus/missing owner (not a real side) or a
// malformed entry is dropped rather than coerced to a guess: a crater with the wrong
// owner is a lost gameplay attribution, not a value worth defaulting.
const craters = Array.isArray(P.craters) ? P.craters
.filter(c => c && typeof c.id === "string" && owners.includes(c.owner))
.map(c => ({
id: c.id, owner: c.owner, spawnAt: num(c.spawnAt, 0),
x: Math.max(0, Math.min(num(c.x, 0), map.width)),
y: Math.max(0, Math.min(num(c.y, 0), map.height)),
})) : [];
// Pending battle-wreckage sites (engine/wreckage.js) not yet matured β€” additive, so an
// older save without the field just has none. No owner to validate (wreckage is neutral);
// a malformed entry, or one left with no valid commodities after sanitizing, is dropped
// rather than kept as empty garbage that would mature into nothing.
const wrecks = (Array.isArray(P.wrecks) ? P.wrecks
.filter(w => w && typeof w.id === "string")
.map(w => {
const spawnAt = num(w.spawnAt, 0);
return {
id: w.id, spawnAt, n: Math.max(1, Math.floor(num(w.n, 1))),
value: Math.max(0, num(w.value, 0)),
// A save from before Phase 4 (or any tampered entry) predates `createdAt` β€” fall
// back to "one base delay before its current spawnAt", the same reconstruction a
// site that had never yet been extended would already satisfy, so an old save
// resumes with a full, correct extension budget rather than none at all. Clamped
// to at most `spawnAt` either way β€” createdAt can never sanely be later than its
// own site's spawn time, so a tampered future value can't grant runaway extension.
createdAt: Math.max(0, Math.min(Number.isFinite(w.createdAt) ? num(w.createdAt, 0) : spawnAt - WRECK_SPAWN_DELAY, spawnAt)),
x: Math.max(0, Math.min(num(w.x, 0), map.width)),
y: Math.max(0, Math.min(num(w.y, 0), map.height)),
goods: sanitizeGoods(w.goods),
};
}) : []).filter(w => Object.keys(w.goods).length > 0);
const state = {
time: num(P.time, 0), tick: num(P.tick, 0), over: P.over, winner: P.winner,
winReason: P.winReason ?? null, // additive β€” why the match ended (engine/victory.js finish); null before/absent
seed: P.seed, planetId: P.planetId, sizeMult, resourceMult, swapAsym, matchTimeLimit, popCap,
endless: !!P.endless,
map,
owners,
players,
units,
buildings,
selection: [],
fogs, fog, fogAI,
// Restore the AI controller's bookkeeping into the grouped `state.ai` (see engine/state.js).
// Wire keys stay `aiThink`/`aiScoutId`/… under the save's `ai:` object for backward compat;
// only the live shape is nested. The archetype is re-derived from the planet id, not persisted.
ai: cleanController(P.ai, "ai", P.planetId),
// Restore Tier 1 self-play's second controller (see the matching comment in serPlanet above)
// β€” null for every save that predates it or never activated self-play, exactly like state.ai
// itself would be if createGameState were ever called without seeding it (it never is; this
// is playerAi's only construction path outside tools/selfplay.js's own direct assignment).
playerAi: P.playerAi ? cleanController(P.playerAi, "pa", P.planetId) : null,
events: [],
craters,
wrecks,
};
for (const id of owners) updateFog(state, state.fogs[id], id);
return state;
}
/* ---------- single skirmish ---------- */
// The plain, JSON-safe save payload (serPlanet already returns detached plain data). The two
// public paths differ only in the last step: serializeGame() returns a DETACHED OBJECT (a
// stringify/parse copy, so ticking the live game on after a save can't mutate it under a caller
// that keeps it β€” tests, importSave's shape sniff); serializeGameString() returns the JSON
// STRING directly. autoSave (the 12 s hot path) uses the string, so it stringifies ONCE instead
// of stringify→parse→stringify — the fog arrays are large, and the two extra passes were waste.
function gamePayload(state) { return { v: SAVE_VERSION, nextEntityId: peekEntityId(), ...serPlanet(state) }; }
/** @param {State} state @returns {Object} */
export function serializeGame(state) { return JSON.parse(JSON.stringify(gamePayload(state))); }
/** @param {State} state @returns {string} */
export function serializeGameString(state) { return JSON.stringify(gamePayload(state)); }
/** @param {*} input @returns {State} */
export function deserializeGame(input) {
sanitizeSave(input); // reject unsafe/oversized payloads before anything else
const save = JSON.parse(JSON.stringify(input)); // detach + normalise
if (save.v !== SAVE_VERSION) throw new Error(`unsupported save version ${save.v}`);
const state = rehydratePlanet(save);
// Never trust the saved counter as ground truth: mint from beyond BOTH the saved value
// AND every loaded id, so a missing/low/garbage nextEntityId can't produce an id that
// overwrites a live entity. For a valid save this is exactly save.nextEntityId (peeked
// after all mints, so already > every suffix) β€” identity, determinism preserved.
restoreEntityId(Math.max(num(save.nextEntityId, 0), maxOwnEntityId(state) + 1));
return state;
}
/* ---------- whole Odyssey galaxy ---------- */
function galaxyPayload(galaxy) {
return {
v: GALAXY_SAVE_VERSION,
seed: galaxy.seed, credits: galaxy.credits, activeId: galaxy.activeId, worlds: galaxy.worlds,
settings: galaxy.settings,
entitySeq: galaxy.entitySeq ?? 0, galaxyTick: galaxy.tick ?? 0,
galaxyTime: galaxy.time ?? 0, // galaxy-wide clock β€” keys the relief cooldown (engine/galaxy.js)
lastReliefTime: galaxy.lastReliefTime ?? null, // when the last relief ship dropped, on that same clock
pacified: [...(galaxy.pacified || [])], wonBy: galaxy.wonBy ?? null, // conquest progress (additive; old saves default to none)
reached: [...(galaxy.reached || [])], // progress milestones already celebrated β€” so a reload doesn't replay their fireworks
// THE RIVAL GATE's idempotency latch (engine/galaxy.js checkRivalGate). Additive, so no
// GALAXY_SAVE_VERSION bump: an older save simply loads with none, which is today's behaviour.
// Without it a reload silently undid an ascension β€” the "permanent" stance ceiling could never
// be re-applied once the Gate was razed (checkRivalGate can only re-latch by FINDING a completed
// Gate), and while it still stood the ascension event re-fired on every single Continue.
rivalAscended: [...(galaxy.rivalAscended || [])],
discovered: [...(galaxy.discovered || [])], // living galaxy: worlds the player has REACHED (starmap "explored" + free return-jump)
claims: [...(galaxy.claims || [])], // faction spread: [worldId, faction] pairs (checkExpansion) β€” the galactic politics on the starmap
// Colony standing orders (engine/colonyPolicy.js): [worldId, {autoSell, workerTarget}] pairs β€”
// additive (a save that predates this feature simply has none, and every colony defaults off).
colonyPolicies: [...(galaxy.colonyPolicies || [])],
// Freight Lanes (engine/galaxy.js runLanes): standing shipping routes between held worlds β€”
// additive (default []).
lanes: (galaxy.lanes || []).map(l => ({ id: l.id, from: l.from, to: l.to, commodities: [...l.commodities], shipIds: [...l.shipIds] })),
laneSeq: galaxy.laneSeq ?? 0,
nextEntityId: peekEntityId(), // the ONE global entity counter, saved once
planets: [...galaxy.planets.values()].map(state => ({
...serPlanet(state),
background: !!state.background,
market: { pressure: { ...state.market.pressure }, glut: { ...(state.market.glut || {}) } },
diplomacy: { ...state.diplomacy }, // stance, depletion, lastAiUnits
})),
};
}
// Detached object (serializeGalaxy) vs the JSON string (serializeGalaxyString) β€” see the
// serializeGame note; autoSave uses the string to stringify the fog-heavy galaxy just once.
/** @param {Galaxy} galaxy @returns {Object} */
export function serializeGalaxy(galaxy) { return JSON.parse(JSON.stringify(galaxyPayload(galaxy))); }
/** @param {Galaxy} galaxy @returns {string} */
export function serializeGalaxyString(galaxy) { return JSON.stringify(galaxyPayload(galaxy)); }
/** @param {*} input @returns {Galaxy} */
export function deserializeGalaxy(input) {
sanitizeSave(input); // reject unsafe/oversized payloads before anything else
const save = JSON.parse(JSON.stringify(input));
if (save.v !== GALAXY_SAVE_VERSION) throw new Error(`unsupported galaxy save version ${save.v}`);
// Reject structural nonsense HERE, while the running session is still intact and the
// caller's try/catch can keep the current game. Without these, a save whose activeId
// has no matching planet (or that carries no planets at all) parses "successfully",
// the caller tears down the live game, and boot crashes reading a non-existent active
// world β€” losing the running game to a bad file.
if (!Array.isArray(save.worlds) || !save.worlds.length) throw new Error("galaxy save has no worlds");
if (!Array.isArray(save.planets) || !save.planets.length) throw new Error("galaxy save has no planets");
// The world roster and active id are save-derived strings that flow into the starmap
// UI (planetName β†’ node text). Constrain them to REAL world ids so a hand-crafted save
// can never smuggle an arbitrary string through the roster (defence-in-depth behind the
// starmap's own escaping); an unknown activeId then fails the has-planet check below.
const known = new Set(ODYSSEY_WORLDS);
const worlds = save.worlds.filter(id => known.has(id));
if (!worlds.length) throw new Error("galaxy save has no recognised worlds");
// Append any roster worlds the save predates (the roster is append-only, so this is
// index-stable): a pre-Phase-4 save froze `worlds` at nine, permanently hiding the newer
// worlds from the starmap AND β€” because the BG scheduler keys on worlds.indexOf(id) β€” any
// planet missing from the roster would get index -1 and never tick again.
for (const id of ODYSSEY_WORLDS) if (!worlds.includes(id)) worlds.push(id);
const galaxy = {
seed: save.seed, credits: num(save.credits, 0), activeId: save.activeId, worlds,
planets: new Map(), settings: cleanGalaxySettings(save.settings),
tick: num(save.galaxyTick, 0), time: num(save.galaxyTime, 0), entitySeq: num(save.entitySeq, 0),
lastReliefTime: Number.isFinite(save.lastReliefTime) ? save.lastReliefTime : undefined,
colonyNotes: new Map(), // transient UI bookkeeping β€” re-derived, never persisted
// Filtered like `claims` and `worlds` three lines below, which were already careful. Unfiltered,
// a junk id counted toward DOMINATION_TARGET (a raw `.size` check) and PACIFIED_INCOME, and
// inflated the "worlds visited" readout galaxyStatus puts on the starmap.
pacified: new Set((Array.isArray(save.pacified) ? save.pacified : []).filter(id => ODYSSEY_WORLDS.includes(id))),
pacifyNotes: [],
wonBy: typeof save.wonBy === "string" ? save.wonBy : null,
// `reached` holds MILESTONE ids, not world ids β€” filtering it against the world roster would
// silently drop every genuine milestone and replay its fireworks on the next load.
reached: new Set((Array.isArray(save.reached) ? save.reached : []).filter(isMilestoneId)),
rivalAscended: new Set((Array.isArray(save.rivalAscended) ? save.rivalAscended : []).filter(id => ODYSSEY_WORLDS.includes(id))), milestones: [], // celebrated milestones persist; the firework queue is transient
// Worlds the player has reached. An OLD save predates the field AND the living galaxy, so it only
// instantiated worlds the player had actually visited β€” recover `discovered` as exactly that set
// (the planet ids present in the save), plus the active world. A NEW save carries the real set.
discovered: new Set((Array.isArray(save.discovered) ? save.discovered
: [save.activeId, ...save.planets.map(P => P.planetId)]).filter(id => ODYSSEY_WORLDS.includes(id))),
// Faction spread (checkExpansion). Restored as a Map of [worldId, faction]; keep only real world
// ids (defence-in-depth behind the roster filter). Old saves predate it β†’ empty (no claims yet).
claims: new Map((Array.isArray(save.claims) ? save.claims : [])
.filter(e => Array.isArray(e) && ODYSSEY_WORLDS.includes(e[0]) && typeof e[1] === "string")),
expansionNotes: [], // transient UI queue β€” re-derived, never persisted
// Colony standing orders (engine/colonyPolicy.js). Additive β€” an old save simply has none, and
// every colony reads back as the fully-off default (sanitizePolicy(null)) via getColonyPolicy.
// Every stored policy is run through the SAME sanitizePolicy a live edit uses, so a hand-edited
// or corrupt entry (an unknown commodity, a negative floor, a garbage workerTarget) can't slip
// untrusted values into the sim.
colonyPolicies: new Map((Array.isArray(save.colonyPolicies) ? save.colonyPolicies : [])
.filter(e => Array.isArray(e) && known.has(e[0]))
.map(([id, p]) => [id, sanitizePolicy(p)])),
laneSeq: num(save.laneSeq, 0),
// Declared here, next to laneSeq, so this literal has the same shape createGalaxy's does.
// It's REFILLED further down (lanes reference units, so they can only be rebuilt once every
// planet is loaded) β€” but starting from the literal means a Galaxy is never momentarily
// missing a field its own typedef requires.
lanes: [],
};
let maxId = 0;
for (const P of save.planets) {
if (!known.has(P.planetId)) continue; // skip a planet payload with an unrecognised id
const state = rehydratePlanet(P);
// ...base recomputed from the SEED-generated nodes. `rehydratePlanet` above has already
// re-added this world's wreck/crater nodes, which did NOT exist when the live book was
// built at creation β€” createMarket skips exactly those (see its own comment), so this
// reproduces the creation-time book rather than deriving a drifted one from a bigger
// node set. That is why `base` needs no save slot of its own.
state.market = createMarket(state);
coerceBand(state.market.pressure, P.market.pressure, PRESSURE_FLOOR, PRESSURE_CEIL); // ...then overlay the saved running pressure...
if (P.market.glut) coerceBand(state.market.glut, P.market.glut, 0, GLUT_CEIL); // ...and the slow produced-goods glut
state.diplomacy = cleanDiplomacy(P.diplomacy);
// DOMINATION WITH TEETH (engine/galaxy.js checkDomination): an old save's `galaxy.pacified`
// (rebuilt above) can already list this world from before the diplomacy-side floor flag
// existed. Re-stamp it here so updateDiplomacy's floor applies to every already-conquered
// world on load, not just ones conquered after this feature shipped. Additive; no
// GALAXY_SAVE_VERSION bump β€” a save that already carries the flag (P.diplomacy.pacified) is
// unaffected, this only fills the gap for one that predates it.
if (galaxy.pacified.has(P.planetId)) state.diplomacy.pacified = true;
// dip.request (engine/diplomacy.js) carries a commodity id and three numbers straight off
// untrusted save data β€” validate the shape (the same rule cargo/resources get elsewhere in
// this file: `if (!COM[com]) …`) rather than trust it verbatim. An invalid/corrupt request is
// simply dropped, same as a malformed cargo entry is nulled above β€” the world just rolls a
// fresh one on its own schedule rather than the game trusting a hand-edited favor forever.
const req = state.diplomacy.request;
if (req && (typeof req !== "object" || !COM[req.com] || !(req.qty > 0) || !Number.isFinite(req.qty)
|| !(req.reward >= 0) || !Number.isFinite(req.reward) || !Number.isFinite(req.until)))
state.diplomacy.request = null;
state.background = !!P.background;
state.inGalaxy = true; // galaxy member β†’ galaxy-wide defeat (engine/galaxy.js)
galaxy.planets.set(P.planetId, state);
maxId = Math.max(maxId, maxOwnEntityId(state));
}
if (!galaxy.planets.has(galaxy.activeId)) throw new Error("galaxy save has no active planet");
const active = galaxy.planets.get(galaxy.activeId);
active.background = false; // the seat is never a background world
// Freight Lanes (engine/galaxy.js runLanes). Reconstructed AFTER every planet is loaded (a
// lane's shipIds reference units on its `from` world) β€” kept only when the lane's worlds are
// both real AND recognised, its commodity filter is real commodities, and each shipId is still
// a live player freighter physically on the source world. Anything else (a hand-edited save, a
// ship that got dropped by load-time entity coercion) is silently trimmed, exactly like
// runLanes' own per-cycle validation does at runtime β€” this is just that same check run once at
// load instead of waiting for the next scheduled lane tick.
galaxy.lanes.length = 0;
const takenLaneIds = new Set();
let maxLaneNum = 0;
for (const rl of (Array.isArray(save.lanes) ? save.lanes : [])) {
if (!rl || typeof rl !== "object" || !known.has(rl.from) || !known.has(rl.to)) continue;
const from = galaxy.planets.get(rl.from);
if (!from) continue;
const commodities = Array.isArray(rl.commodities) ? rl.commodities.filter(c => COM[c]) : [];
// De-duplicate. assignShipToLane guards this at runtime (`if (!lane.shipIds.includes(unitId))`)
// and runLanes sums capacity over shipIds WITHOUT deduping, so a repeated id multiplied the
// lane's throughput β€” measured, one 250-hold hauler listed three times moved 750 ore per cycle.
const seenShips = new Set();
const shipIds = Array.isArray(rl.shipIds) ? rl.shipIds.filter(id => {
if (seenShips.has(id)) return false;
const u = from.units.get(id);
if (!u || u.owner !== "player" || !(UNITS[u.type] && UNITS[u.type].cargoHold)) return false;
seenShips.add(id);
return true;
}) : [];
// A lane id must be unique: deleteLane/assignShipToLane/unassignShipFromLane all resolve by
// find/findIndex on the id, so a second lane sharing one is permanently unreachable from the UI.
let id = typeof rl.id === "string" && !takenLaneIds.has(rl.id) ? rl.id : "lane" + (++galaxy.laneSeq);
while (takenLaneIds.has(id)) id = "lane" + (++galaxy.laneSeq);
takenLaneIds.add(id);
const idNum = /^lane(\d+)$/.exec(id);
if (idNum) maxLaneNum = Math.max(maxLaneNum, +idNum[1]);
const lane = { id, from: rl.from, to: rl.to, commodities, shipIds };
for (const id2 of shipIds) from.units.get(id2).laneId = lane.id;
galaxy.lanes.push(lane);
}
// Lift the counter past every id actually in use, exactly like maxOwnEntityId and maxGId above do
// for their counters. Without it a save carrying "lane1" with laneSeq:0 minted "lane1" again on
// the very next createLane, colliding with a live lane.
galaxy.laneSeq = Math.max(galaxy.laneSeq, maxLaneNum);
// The galaxy `entitySeq` is the SEPARATE "g"-id counter β€” ids minted as "g"+entitySeq in
// engine/galaxy.js for entities that cross worlds (jump riders, relief + colony ships). Unlike the
// u/b counter (hardened above via maxOwnEntityId), it was restored as num(save.entitySeq,0) with NO
// recompute β€” so a save with a low/missing entitySeq but a LIVE "g" entity (an in-flight colony
// ship / freighter / relief ship) would later mint a COLLIDING "g"-id and clobber it. Scan every
// unit/building id across ALL worlds for the g-scheme and lift entitySeq past the highest, mirroring
// how maxOwnEntityId hardens the u/b counter. Identity for a valid save β€” its entitySeq already
// exceeds every g-id, so the max is a no-op. The next mint pre-increments (galaxy.js), so we lift to
// the max itself (not +1): entitySeq === g5 β‡’ next id is "g6".
let maxGId = 0;
const scanG = id => { const s = /^g(\d+)$/.exec(String(id)); if (s) { const n = +s[1]; if (n > maxGId) maxGId = n; } };
for (const state of galaxy.planets.values()) {
for (const id of state.units.keys()) scanG(id);
for (const id of state.buildings.keys()) scanG(id);
}
galaxy.entitySeq = Math.max(num(save.entitySeq, 0), maxGId);
// Mint beyond both the saved counter and every loaded id across ALL worlds (see the
// skirmish note) β€” identity for a valid save, collision-proof for a corrupt one.
restoreEntityId(Math.max(num(save.nextEntityId, 0), maxId + 1));
return galaxy;
}