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;
}