Spaces:
Paused
Paused
File size: 70,275 Bytes
7e764b0 dfed415 7e2a606 dfed415 5330c74 865aea3 b383918 b6109dc dfed415 0974cb1 7e2a606 0974cb1 e738400 fdea120 6325132 2b50b54 84220dc dfed415 7e2a606 dfed415 755b556 7e764b0 755b556 a4bbde9 0974cb1 2b50b54 0974cb1 2991d58 15f3a03 2991d58 15f3a03 2991d58 15f3a03 2991d58 5d60e36 0974cb1 a4bbde9 fdea120 0974cb1 fdea120 6325132 7a847ac b383918 db667bc 79c7af7 fdea120 53d4247 c1ba8ce d1cc6d6 6648f62 f0783f6 8f89fac 832f494 18d5122 832f494 18d5122 832f494 785c4de 4b95948 fdea120 4b95948 fdea120 785c4de a4bbde9 832f494 18d5122 832f494 18d5122 3620efb e738400 a4bbde9 dfed415 7e2a606 dfed415 8338353 5bffdf0 dadc0b2 8338353 dfed415 5bffdf0 b2772b8 8583c67 6648f62 06de929 6648f62 32d2559 5330c74 3620efb 5330c74 3620efb b4afb1c 865aea3 dfed415 f1048a5 be1f1f9 53ce1fb be1f1f9 f1048a5 a4bbde9 f1048a5 189f332 f1048a5 2991d58 15f3a03 2991d58 5d60e36 24f7ec5 f1048a5 dfed415 916c3ad 2991d58 15f3a03 2991d58 5d60e36 916c3ad 7e2a606 dfed415 7e2a606 e738400 8338353 5bffdf0 dadc0b2 7e2a606 8338353 7e2a606 dfed415 5330c74 3620efb dfed415 7e2a606 dfed415 a4bbde9 b2772b8 e738400 b2772b8 5330c74 3620efb 865aea3 3620efb dfed415 a4bbde9 5bffdf0 dadc0b2 7e2a606 dfed415 b2772b8 a4bbde9 dfed415 b2772b8 f1048a5 0974cb1 916c3ad 0974cb1 dfed415 5330c74 3620efb dfed415 b2772b8 dfed415 7e2a606 b6d76f7 7e764b0 b6d76f7 7e764b0 b6d76f7 7e2a606 7e764b0 7e2a606 755b556 7e2a606 a4bbde9 7e2a606 b6d76f7 7e2a606 a4bbde9 947d9db 0974cb1 ad2fc1c bd7958c 84220dc 7e2a606 ed7688d 7e2a606 b6d76f7 7e2a606 b6d76f7 7e764b0 b6d76f7 7e764b0 b6d76f7 7e2a606 7e764b0 7e2a606 755b556 7e2a606 a4bbde9 e692ca2 7e2a606 a4bbde9 2b50b54 a4bbde9 7e2a606 2b50b54 0974cb1 ad2fc1c 2b50b54 bd7958c 84220dc 7e764b0 7e2a606 a4bbde9 7e2a606 a4bbde9 7e2a606 96c6d29 0974cb1 051d7f7 e4f4a6b 7e2a606 51531fb 7e2a606 a4bbde9 7e2a606 a4bbde9 7e2a606 a4bbde9 84220dc 7e764b0 0974cb1 84220dc 0974cb1 84220dc 0974cb1 84220dc 0974cb1 84220dc 0974cb1 84220dc 0974cb1 fdea120 a4bbde9 7e2a606 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 | // @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;
}
|