File size: 72,857 Bytes
ef7537c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
# 02 β€” Command & Wire Protocol

**Status:** analysis complete, decisive. Primary input to `docs/adr/` (proposed
ADR-0002 *"Wire commands are id-based; `engine/commands.js` is wrapped, not
rewritten"*).

**Sources read:** `/home/user/alma92350/spaceexploration-rts` (read-only clone).
All `file.js:line` citations below refer to that tree unless prefixed with
`SpaceCities/`.

---

## 0. Decisions up front

| # | Decision | Where it lands |
|---|---|---|
| **D1** | **Wrap, do not rewrite.** `engine/commands.js` keeps its object-ref signatures. A new `net/commandCodec.js` owns id→object resolution, ownership, fog and rate limits. | §4 |
| **D2** | Exactly **one** signature change to the engine: `issueSetRally(building, …)` β†’ `issueSetRally(state, buildingId, …)`. 3 call sites. Everything else is untouched. | Β§4.4 |
| **D3** | The wire envelope is **versioned, id-based, owner-stamped by the server (never by the client), and tick-scheduled**. The client's `ownerId` and `tick` fields are advisory/telemetry only. | Β§3.1 |
| **D4** | **Selection id arrays are ORDER-SIGNIFICANT and must never be sorted.** `ids[0]` is the formation leader (`engine/commands.js:145`, `:195`) and `issueEscort` derives ring slots from array index (`engine/commands.js:363`). | Β§3.3 |
| **D5** | Application order is `(applyTick, ownerIndex, clientSeq)` β€” `ownerIndex = state.owners.indexOf(owner)`, `engine/state.js:227`. Commands apply **immediately before `tick(state, dt)`**, never inside it. | Β§5 |
| **D6** | `state.selection` is UI-only. It moves to the client session. The field stays on `State` as a permanently-empty array (zero churn β€” `removeEntity` writes it, `engine/state.js:347`), guarded by a test that no server module *reads* it. | Β§6 |
| **D7** | A match is replayable from `{engineCommit, createGameStateOpts, dt, aiSeatConfigs, orderedCommandLog}` β€” **provided B1 below is fixed**. | Β§7 |
| **D8** | **Five engine defects block multiplayer** and must be fixed before the codec ships. The worst is a module-global entity-id counter that makes two concurrent matches in one Node process non-replayable. | Β§8 |

**Scope correction (important).** The brief states `engine/commands.js` is the
entire player-intent surface. It is the entire *unit-order* surface. It is **not**
the entire player-intent surface: `hudSelection.js:20-35` imports and calls
~20 further cost-bearing engine mutators directly (production, research, market,
diplomacy, colony, galaxy). See Β§1.6. The wire protocol must cover both, and the
second group is where the money is.

---

## 1. Signature audit β€” every export of `engine/commands.js`

22 exports, `engine/commands.js:231`–`:533`.

Legend for **Params**: `OBJ` = live object reference (unserialisable), `ID` =
string id, `SC` = scalar/plain-JSON, `STATE` = the whole `State`.

### 1.1 The table

| # | Function | Line | Exact signature | Param kinds | Owner check? | Afford check? | Mutates `players[x].resources`? | State written |
|---|---|---|---|---|---|---|---|---|
| 1 | `issueMove` | 231 | `(units, x, y, queue = false, formation)` | `units:OBJ[]`, `x,y,queue:SC`, `formation:SC` | **NO** | n/a | no | `u.order`, `u.orderQueue`, `u.hold`, `u.squadLeader`, `leader.squadFollowers`, `u.facing`, `order.speedCap` |
| 2 | `issueGather` | 237 | `(units, nodeId, queue = false)` | `units:OBJ[]`, `nodeId:ID`, `queue:SC` | **NO** | n/a | no | `u.order={type:"gather",nodeId}` (filtered by `canGatherType`) |
| 3 | `issueServiceBuilding` | 244 | `(units, buildingId, queue = false)` | `units:OBJ[]`, `buildingId:ID` | **NO** (neither unit nor building) | n/a | no | `u.order={type:"service",buildingId,phase:"plan",manual:true}` |
| 4 | `issueFerryFreighter` | 255 | `(units, freighterId, queue = false)` | `units:OBJ[]`, `freighterId:ID` | **NO** | n/a | no | `u.order={type:"ferry",…}` |
| 5 | `issueRepair` | 267 | `(units, targetId, queue = false)` | `units:OBJ[]`, `targetId:ID` | **NO** | n/a | no | `u.order={type:"repair",targetId,phase:"toSite",manual:true}` |
| 6 | `issueSetHomeBase` | 281 | `(units, ccId)` | `units:OBJ[]`, `ccId:ID` | **NO** | n/a | no | `u.homeCC` β€” `ccId` never validated as existing, as a building, as a CC, or as owned |
| 7 | `issueSetAILogistics` | 295 | `(units, on, state)` | `units:OBJ[]`, `on:SC`, `state:STATE` | **partial** β€” reads `state.players[u.owner].upgrades` (`:298`), so it is *correctly owner-scoped for the tech gate*, but does not check the caller owns the unit | n/a | no (upkeep is charged later, `haul.js payAIUpkeep`) | `u.aiLogistics`, `u.cargo` |
| 8 | `issueSetCollectPoint` | 310 | `(units, on)` | `units:OBJ[]`, `on:SC` | **NO** | n/a | no | `u.collectPoint`, `u.anchor` |
| 9 | `issueSetLogiPriority` | 330 | `(state, buildingId, priority)` | `state:STATE`, `buildingId:ID`, `priority:SC` | **NO** | n/a | no | `b.logiPriority` on **any** building in the world |
| 10 | `issueAttack` | 343 | `(units, targetId, queue = false)` | `units:OBJ[]`, `targetId:ID` | **NO** β€” and no hostility check either | n/a | no | `u.order={type:"attack",targetId}` (filtered to `def.attack \|\| role==="support"`) |
| 11 | `issueAttackMove` | 350 | `(units, x, y, queue = false, formation)` | as `issueMove` | **NO** | n/a | no | as `issueMove`, order type `attack-move` |
| 12 | `issueEscort` | 361 | `(units, targetId, queue = false)` | `units:OBJ[]`, `targetId:ID` | **NO** | n/a | no | `u.order={type:"escort",targetId,slot:i,slots:n}` β€” **no role filter at all** |
| 13 | `issueHoldFormation` | 375 | `(units, shape = "grid", leaderPos = "front")` | `units:OBJ[]`, `shape,leaderPos:SC` | **NO** | n/a | no | `hold-formation` orders + `u.hold` for `role==="combat"`; anchor is the **live centroid** of `units` |
| 14 | `issueBuild` | 390 | `(state, workerId, buildingType, x, y)` | `state:STATE`, `workerId:ID`, `buildingType,x,y:SC` | **NO** β€” derives `player` from `worker.owner` (`:393`) | **YES** `canAfford` `:404` | **YES β€” `payCost` `:407`** | mints a `constructing` building into `state.buildings`, sets `worker.order`; **returns the new building id** |
| 15 | `issueAssistBuild` | 420 | `(units, buildingId, buildingType, queue = false)` | `units:OBJ[]`, `buildingId,buildingType:ID/SC` | **NO** | n/a | no | `u.order={type:"build",buildingId}`; **`buildingType` is trusted from the caller** and only used to resolve the eligibility category (`:421`) |
| 16 | `issueStop` | 434 | `(units)` | `units:OBJ[]` | **NO** | n/a | no | clears `order`, `orderQueue`, `hold`, `recycling`, `squadLeader` |
| 17 | `issueRecycle` | 444 | `(entities)` | `entities:OBJ[]` (mixed `Unit`\|`Building`) | **NO** | n/a | **indirectly YES** β€” `beginRecycle` starts a timer that `recycle.js:136-138` later banks into `state.players[entity.owner].resources`, and `removeEntity`s the entity | `e.recycling`, `e.order`, `e.hold` |
| 18 | `issueCancelRecycle` | 454 | `(entities)` | `entities:OBJ[]` | **NO** | n/a | no | clears `e.recycling` |
| 19 | `issueHold` | 462 | `(units)` | `units:OBJ[]` | **NO** | n/a | no | `u.hold = true`, clears orders (combat role only) |
| 20 | `issuePatrol` | 480 | `(units, points)` | `units:OBJ[]`, `points:SC[]` | **NO** | n/a | no | a looping `attack-move … patrol:true` chain; **`points` length is unbounded** |
| 21 | `issueScout` | 509 | `(units)` | `units:OBJ[]` | **NO** | n/a | no | `u.order={type:"scout",speedCap?}` (scout role only) |
| 22 | `issueSetRally` | 531 | `(building, x, y, nodeId = null)` | `building:OBJ`, `x,y,nodeId:SC` | **NO** | n/a | no | `building.rally = {x,y,nodeId}` β€” **zero validation of anything** |

### 1.2 The central problem, confirmed

**20 of 22 take live object references.** Only `issueBuild` (`:390`) and
`issueSetLogiPriority` (`:330`) are fully id-based; `issueSetRally` (`:531`) is
the extreme case, taking a bare `Building` object with no `state` at all.

The object refs are not incidental β€” they are structural. `dispatchFormation`
(`:144`) *stores* references: `leader.squadFollowers = newFollowers`
(`:195`) and `dispatch(units[i], {type:"follow-leader", leader, …})` (`:204-207`)
puts a **live `Unit` object inside an order**. `setSquadLeader` (`:46`) maintains
a bidirectional object graph. That graph is explicitly non-serialisable and
`persist.js` already deals with it by *dropping* it β€” see the comment at
`engine/commands.js:43-45`: *"Transient, session-only state (never persisted β€” see
persist.js's serPlanet, which strips both fields and drops a live follow-leader
order entirely rather than trying to serialize the object reference it carries)."*

**Consequence for netcode:** the object graph is fine *inside* the authoritative
server sim (it never crosses a wire). What crosses the wire is only the *intent*.
So the id→object boundary belongs in an adapter, not in the engine. This is the
single most important architectural fact in this document, and it is what makes
D1 correct.

### 1.3 Hidden inputs that are not parameters

Three commands read state the wire schema must therefore also carry or recompute:

- `issueHoldFormation` (`:377-379`) computes the anchor from the **live centroid**
  of the passed units. The anchor is therefore a function of *when* the command
  applies. Two identical commands applied at different ticks produce different
  worlds. Scheduling must be authoritative and logged (Β§5).
- `issueMove`/`issueAttackMove` cap group speed from `UNITS[u.type].speed` of the
  live set (`groupSpeedCap`, `:66-69`) and re-derive `leader.squadFollowers`.
- `issueScout` (`:514-518`) reads and **prunes** `u.squadFollowers` by `hp > 0`.

### 1.4 The `owner === "player"` gate β€” a hard multiplayer blocker

`engine/commands.js:155`:

```js
if (leader.owner !== "player") {
  const spots = formationSlots(units, x, y, formation);
  units.forEach((u, i) => dispatch(u, makeLeaderOrder(spots[i]), queue));
  return;
}
```

The entire leader/follower squad mechanic β€” the thing that makes formations
*formations* rather than a one-shot grid spread β€” is gated on the literal owner
id `"player"`. In a 4-player match with owners `p1..p4`, **no seat gets
formations**. See Β§8/B2.

### 1.5 Silent-skip is the house style

Every role/capability filter in this file *silently skips* ineligible units
rather than failing the call (`canGatherType` `:238`, `canLogisticsType` `:246`,
`role === "combat"` `:464`, `canBuildCategory` `:422`). This is deliberate and
documented (`:293-294`, `:441-443`). **The codec must preserve it**: a mixed
selection must not be rejected wholesale because one unit is ineligible. Only
*ownership* violations are hard rejects (Β§2.6).

### 1.6 The second intent surface β€” `engine/commands.js` is not the whole story

`hudSelection.js:20-35` imports these directly, and they are all reachable from a
button click:

| Module | Exports the HUD calls | Cost-bearing? |
|---|---|---|
| `engine/production.js` | `queueProduction` `:118`, `cancelProduction` `:164`, `researchUpgrade` `:185` | **yes** β€” `payCost` at `:152`, `:205`; refund at `:173` |
| `engine/techtree.js` | `researchTech` `:190`, `cancelResearch` `:224` | **yes** |
| `engine/market.js` | `sell` `:164`, `buy` `:208` | **yes** |
| `engine/diplomacy.js` | `offerTribute` `:248`, `offerGift` `:271`, `fulfillRequest` `:290` | **yes** |
| `engine/colony.js` | `deployColonyShip` `:31`, `packCommandCenter` `:70` | **yes** (`PACK_COST`, `:86`) |
| `engine/colonyPolicy.js` | `setColonyPolicy` `:82` | no |
| `engine/bomb.js` | `lightFuse` `:249` | no (destructive) |
| `engine/galaxy.js` | `upgradeSpaceport`, `loadFreighter`, `unloadFreighter`, `createLane`, `deleteLane`, `assignShipToLane`, `upgradeToCapital`, `jumpVessel` | **yes** |

All three of `queueProduction`, `cancelProduction`, `researchUpgrade` derive the
paying player from `building.owner`, **not from a caller-supplied owner**:

```js
// engine/production.js:140
const player = state.players[building.owner];
if (!canAfford(player.resources, cost)) return false;
```

So they have exactly the same exposure class as `issueBuild` (Β§2.2). The wire
protocol below is designed as an open union so these fold in as additional
command types with the same envelope, resolver and ownership rule β€” see Β§3.6.
**Recommendation:** ship the unit-order commands (Β§3.4) in phase 1 and the
economy commands (Β§3.6) in phase 2, both through the same codec.

---

## 2. Ownership & validation gaps β€” the anti-cheat surface

**Baseline:** in single-player none of this matters, because the *only* callers
are `inputCommands.js` (which pre-filters to `owner === "player"`, e.g.
`inputCommands.js:99`, `:206`, `:211`) and the AI (which passes its own units).
Ownership enforcement lives entirely in the UI. Expose these over a socket
naively and every one of them becomes a cheat.

Ranked by severity.

### 2.1 CATASTROPHIC β€” destroy or disable another player's army

| Command | Attack |
|---|---|
| `issueRecycle(entities)` `:444` | Send the enemy's building/unit ids. `canRecycle` (`recycle.js:80`) only refuses a Command Center, a constructing building, or an already-recycling entity. Everything else starts a timer that ends in `removeEntity` (`recycle.js:149`, `:163`). **You can dismantle an opponent's entire base.** |
| `issueStop(units)` `:434` | Send every enemy unit id every tick. Clears `order`, `orderQueue`, `hold`, and `recycling`. The opposing army is permanently frozen β€” it can still auto-defend (`combat.js` re-acquires), but never moves, gathers, or builds again. |
| `issueAttack(units, targetId)` `:343` | Two attacks in one. (a) Send *your* units at a *friendly/allied* target: `combat.js:46` reads `unit.order.targetId` with **no owner filter**, and `performAttack` (`combat.js:85`) is reached without one β€” explicit orders are friendly-fire capable, unlike auto-acquisition which does filter (`combat.js:153`, `:243`, `:386`, `:411`). (b) Send *the enemy's* unit ids at *the enemy's own* buildings and they self-destruct. |
| `issueHold(units)` `:462` | Freeze the enemy's combat units in place (`u.hold = true`), then walk past them: `combat.js:83` refuses to chase while `unit.hold`. |

`recycle.js:87-89` carries a comment asserting the guard exists:

> *"Start recycling `entity` in place. Pure state mutation β€” engine/commands.js's
> issueRecycle checks ownership/canRecycle and handles the unit-order-dispatch
> side…"*

`issueRecycle` (`commands.js:444-450`) checks `canRecycle` and nothing else.
**The comment is wrong.** Fix the comment as part of the codec work so the next
reader is not misled into trusting a check that does not exist.

### 2.2 SEVERE β€” spend another player's resources

| Command | Attack |
|---|---|
| `issueBuild(state, workerId, …)` `:390` | `const player = state.players[worker.owner]` `:393`, then `payCost(player.resources, def.cost)` `:407`. Name an **enemy** worker id and you drain *their* treasury and hijack *their* worker's order (`:411`). You do not gain the building β€” but you can bankrupt them and pin their workers to construction sites at will. Placement, prereqs and affordability are all validated **against the victim**, so a well-chosen spam of expensive buildings is a total economic denial. |
| `queueProduction(state, buildingId, …)` `production.js:118` | Same shape: `state.players[building.owner]` `:140`, `payCost` `:152`. Fill the enemy's queues, drain their bank, and consume their supply cap (`:147`). |
| `cancelProduction(state, buildingId, i)` `production.js:164` | Delete an arbitrary index out of any building's queue (`:169`). Refunds to the owner, so it is pure griefing: cancel the enemy's army as fast as they queue it. |
| `researchUpgrade` / `researchTech` | Same derivation; burn the victim's bank on a doctrine they did not choose, and β€” because of the doctrine lock (`production.js:194-195`) β€” **permanently deny them the other doctrine.** This is the most damaging economic attack in the set. |

### 2.3 MODERATE β€” sabotage, waste, and free labour

| Command | Attack |
|---|---|
| `issueSetLogiPriority(state, buildingId, priority)` `:330` | No owner check anywhere. Set every enemy factory to `"low"` and their logistics chain starves (`haul.js priorityWeight`). |
| `issueSetRally(building, x, y, nodeId)` `:531` | Object-ref, zero validation. Once the codec resolves an id, an unguarded path re-points every enemy production building's rally into a corner of the map β€” or onto your own guns. |
| `issueSetHomeBase(units, ccId)` `:281` | `ccId` is never validated (not existence, not kind, not owner). Point the enemy's workers at *your* CC and their whole `zoneFirst` job search (`gather.js`) goes wrong. |
| `issueRepair(units, targetId)` `:267` | No owner check on either side. Order *your* workers to repair the *enemy's* CC. `repair.js:161`/`:172` gate the passive Mender scan on `owner`, but `updateRepairJob` runs off the explicit order. Mostly self-harm β€” but in a team game it is a way to launder resources/labour to a nominal opponent. |
| `issueAssistBuild(units, buildingId, buildingType)` `:420` | Two holes. (a) `buildingId` is unvalidated β€” send workers to accelerate an enemy site. (b) **`buildingType` is trusted from the caller** and is the only thing that resolves the eligibility category (`:421`). A client that lies about the type walks a combat unit onto a construction site the engine would otherwise refuse. The codec must read the type from the *resolved building*, never from the wire. |
| `issueSetAILogistics` `:295` / `issueSetCollectPoint` `:310` | Flip the enemy's freighters into/out of AI-logistics mode. The tech gate is correctly scoped to `u.owner` (`:298`) so you cannot grant them a mode they have not researched β€” but you can force one on, which burns their AI Cores (`haul.js payAIUpkeep`), or force one off mid-haul. |
| `issueServiceBuilding` `:244` / `issueFerryFreighter` `:255` | Same class: unvalidated target ids, cross-owner assignment. |

### 2.4 MAPHACK β€” fog is enforced only in the UI

`inputCommands.js` gates target picking on fog: `entityAt` skips non-player
entities that fail `isVisibleAt(state.fog, …)` (`inputCommands.js:58`, `:62`) and
`nodeAt` requires `isNodeDiscovered` (`:69`). **No engine function checks fog.**
A client that ignores its own renderer can:

- `issueAttack` a unit it has never seen (targeted alpha-strikes into fog),
- `issueGather` an undiscovered node β€” instant map knowledge of every deposit,
- `issueBuild` anywhere on the map: `canPlaceBuilding` (`colliders.js:26-47`)
  checks bounds, building overlap, node overlap and terrain β€” **never fog and
  never proximity to your own territory**. Wall in an enemy's expansion on turn
  one.

Fog enforcement is therefore a **new server-side rule** the codec must add; it
does not exist anywhere in the engine today.

### 2.5 DoS / resource-exhaustion

- `issuePatrol(units, points)` `:480-493` β€” `points` is unbounded and every point
  is pushed onto **every** unit's `orderQueue`. `|units| Γ— |points|` allocations
  with no cap. 400 units Γ— 100k points is a server OOM.
- Selection size is unbounded everywhere. `dispatchFormation` β†’ `formationSlots`
  β†’ `clusterUnits` (`formation.js:108`) is superlinear in group size.
- Command rate is unbounded. `issueStop` on 400 units at 20 Hz is cheap for the
  attacker and expensive for the server.

### 2.6 The rule that fixes 2.1–2.3 in one place

> **Every entity id on the wire resolves through the codec, and every resolution
> is scoped to the submitting owner.** A unit/building the submitter does not own
> is a *hard reject* of the whole command. A unit/building that has *ceased to
> exist* is a *silent drop* of that one id (it is a legitimate race between issue
> and apply, not a lie).

That distinction matters: rejecting on "not found" would make the protocol fail
constantly under normal packet latency, while dropping on "not yours" would let
an attacker probe the world for free.

Targets (the *object* of a command, not the *subject*) get a different rule β€”
see Β§3.5.

---

## 3. Wire schema

### 3.1 The envelope

One JSON object per command. Sent client→server over the WebSocket; the same
shape, once stamped, is what goes in the replay log.

```jsonc
{
  "v": 1,                     // PROTOCOL_VERSION β€” reject on mismatch, never coerce
  "seq": 417,                 // per-client monotonic counter, starts at 1
  "tick": 1183,               // ADVISORY: the tick the client believed it was on
  "cmd": { "t": "move", "ids": ["u12","u7"], "x": 900, "y": 412, "q": false,
           "f": { "s": "wedge", "l": "front", "hx": 1, "hy": 0 } }
}
```

Server-side, after admission, it becomes a **log record**:

```jsonc
{
  "v": 1,
  "seq": 417,
  "owner": "p2",              // AUTHORITATIVE β€” from the socket's session, never the client
  "applyTick": 1186,          // AUTHORITATIVE β€” stamped by the server at admission
  "cmd": { … },               // verbatim, post-validation
  "result": { "ok": true, "buildingId": "b41" }   // echo for build-like commands
}
```

**Deliberate design points.**

- **`owner` is never on the client→server wire.** It is stamped from the
  authenticated session. A field a client can set is a field a client will lie
  about. This is the single change that neutralises Β§2.1 and Β§2.2 by
  construction β€” an id-based protocol whose owner is client-supplied is *no
  safer* than passing object refs.
- **`tick` is advisory.** The server stamps `applyTick` itself (Β§5.2). The client
  value is kept only for latency telemetry and for detecting a client running
  ahead.
- **`seq` is the tie-break** and the replay/duplicate guard. `(owner, seq)` must
  be unique; a repeat is dropped idempotently.
- **Short keys** (`t`, `q`, `f`, `s`, `l`, `hx`) because these are the highest-rate
  messages in the protocol. Everything else in the game (chat, lobby, state
  snapshots) can afford long keys.

### 3.2 Batches

One right-click already fans out into several `issue*` calls β€”
`inputCommands.js:90-96` splits a selection into combatants (`issueAttackMove`)
and everyone else (`issueMove`), and `commandAt` (`:194-285`) picks one of eight
verbs. **Decision: the client resolves the gesture into primitive commands, and
ships them as an atomic batch.**

```jsonc
{ "v": 1, "seq": 418, "cmd": { "t": "batch", "c": [ {…}, {…} ] } }
```

A batch applies at one `applyTick`, in array order, all-or-nothing on validation
(if any member is rejected, the whole batch is rejected β€” the client's
disambiguation was built on a state it did not actually have). Max 16 members.

Rationale for client-side gesture resolution: `commandAt` is 92 lines of
UI-policy (`inputCommands.js:15-17` calls this out explicitly), it depends on
camera/pick-radius/touch-mode, and an MCP agent has no gesture at all β€” it wants
to say `attack`, not "right-click at (900,412)". Putting the verb on the wire
also makes the log human-readable, which matters enormously for replay debugging.
The cost is that a lying client can pick a verb the UI would not have offered β€”
which is exactly what Β§3.5's server-side re-validation is for.

### 3.3 How selections are expressed

```ts
/** Ordered, de-duplicated entity ids. ORDER IS LOAD-BEARING. */
type Ids = string[];   // 1..400
```

`ids[0]` is the **formation leader**: `dispatchFormation` takes
`const leader = units[0]` (`commands.js:145`), assigns
`leader.squadFollowers = units.slice(1)` (`:195`), and `rankSlotsByRange`
passes `spots[0]` through untouched because *"the leader is a documented player
choice … never re-picked by a stat"* (`:87-88`). `issueEscort` likewise derives
`slot: i, slots: n` from array position (`:363`).

**Therefore the codec must not sort, canonicalise or re-order `ids`.** It
de-duplicates preserving first occurrence, and drops dead ids in place. This is
also why the client's `state.selection` order is preserved through
`applyBoxSelection`'s promote-to-front behaviour (`inputCommands.js:152`) β€” that
ordering *is* game input and must be logged verbatim.

### 3.4 Command types β€” phase 1 (unit orders)

```ts
/** engine/formation.js:59-60 β€” the ONLY legal values. */
type Shape  = "grid" | "line" | "wedge" | "circle";
type LeadPos = "front" | "back" | "center";

/** Rides on move / attack-move. Maps to engine/commands.js's `formation` opts bag. */
interface WireFormation {
  s?: Shape;      // shape;     default "grid"
  l?: LeadPos;    // leaderPos; default "front"
  hx?: number;    // headingX β€” the right-click-DRAG vector; stamped as unit.facing (commands.js:218)
  hy?: number;    // headingY
}
// NOTE: originX/originY are NOT on the wire. issueHoldFormation derives them
// server-side from the live centroid (commands.js:377-380).

type WireCommand =
  // ---- movement -----------------------------------------------------------
  | { t: "move";        ids: Ids; x: number; y: number; q?: boolean; f?: WireFormation }
  | { t: "attackMove";  ids: Ids; x: number; y: number; q?: boolean; f?: WireFormation }
  | { t: "holdFormation"; ids: Ids; s?: Shape; l?: LeadPos }
  | { t: "patrol";      ids: Ids; pts: Array<{ x: number; y: number }> }   // 1..32
  | { t: "stop";        ids: Ids }
  | { t: "hold";        ids: Ids }
  | { t: "scout";       ids: Ids }

  // ---- targeted at another entity -----------------------------------------
  | { t: "attack";      ids: Ids; target: string; q?: boolean }
  | { t: "escort";      ids: Ids; target: string; q?: boolean }
  | { t: "repair";      ids: Ids; target: string; q?: boolean }
  | { t: "gather";      ids: Ids; node:   string; q?: boolean }
  | { t: "service";     ids: Ids; target: string; q?: boolean }   // building
  | { t: "ferry";       ids: Ids; target: string; q?: boolean }   // own freighter
  | { t: "setHomeBase"; ids: Ids; target: string }                // own command center
  | { t: "assistBuild"; ids: Ids; target: string; q?: boolean }   // NOTE: no buildingType β€” server reads it

  // ---- construction / teardown --------------------------------------------
  | { t: "build";       worker: string; b: string; x: number; y: number }
  | { t: "recycle";       ids: Ids }   // units AND buildings
  | { t: "cancelRecycle"; ids: Ids }

  // ---- toggles / properties ------------------------------------------------
  | { t: "setAILogistics";  ids: Ids; on: boolean }
  | { t: "setCollectPoint"; ids: Ids; on: boolean }
  | { t: "setLogiPriority"; building: string; p: "high" | "normal" | "low" }
  | { t: "setRally";        building: string; x: number; y: number; node?: string | null }

  // ---- envelope-level ------------------------------------------------------
  | { t: "batch"; c: WireCommand[] };   // 1..16, no nesting
```

**Mapping notes.**

- `q` is `queue` β€” the Ctrl-modifier. Pure boolean, rides on every command whose
  engine signature has a `queue` parameter. It is *not* meaningful for `stop`,
  `hold`, `scout`, `holdFormation`, `patrol`, `recycle` or the toggles, and is
  rejected as malformed there rather than ignored (silent ignore hides client
  bugs).
- `f` (formation) rides only on `move`/`attackMove`. `holdFormation` takes
  `s`/`l` directly because its engine signature is
  `(units, shape, leaderPos)` (`:375`), not an opts bag.
- **`assistBuild` deliberately drops `buildingType`.** The engine takes it
  (`:420`) but the codec supplies `site.type` from the resolved building β€” see
  Β§2.3. This is an example of the codec being *narrower* than the engine on
  purpose.
- `setRally.node` is a `nodeId` or `null` (`:531`); the codec validates it exists
  and is discovered.
- `escort` takes no role filter in the engine (`:361`) β€” the codec adds none
  either, matching current behaviour exactly, but it *does* reject
  `target ∈ ids` (`inputCommands.js:272` filters the target out client-side; the
  codec does it server-side so the engine cannot be handed a self-escort).

### 3.5 Server-side re-validation rules

For each command the codec applies, in order:

1. **Envelope** β€” `v === PROTOCOL_VERSION`, `seq` unseen for this owner, shape
   matches the union (unknown key β‡’ reject; JSON only, no prototypes).
2. **Subject resolution** β€” `ids`/`worker`/`building` resolve to live entities
   **owned by the stamped owner**. Foreign β‡’ hard reject `not-owner`. Missing β‡’
   silent drop of that id; empty result β‡’ reject `empty-selection`.
3. **Bounds** β€” every `x`/`y` inside `[0, map.width] Γ— [0, map.height]`
   (`colliders.js:31` checks the footprint but the codec checks the raw point
   first, so an absurd coordinate never reaches formation math).
4. **Enum** β€” `s ∈ FORMATION_SHAPES`, `l ∈ LEADER_POSITIONS`
   (`formation.js:59-60`), `p ∈ LOGI_PRIORITIES` (`haul.js:102`),
   `b ∈ Object.keys(BUILDINGS)`.
5. **Limits** β€” `ids.length ≀ 400`, `pts.length ≀ 32`, `batch.c.length ≀ 16`,
   plus a per-owner token bucket (recommend 30 commands/sec sustained, burst 60 β€”
   comfortably above human APM and above the AI's own budgeted rate).
6. **Target visibility** (new rule, Β§2.4):
   - **own** entity β‡’ no fog check.
   - foreign **unit** β‡’ `isVisibleAt(state.fogs[owner], t.x, t.y)` (`fog.js:49`).
   - foreign **building** β‡’ `isExploredAt(…)` (`fog.js:55`) β€” remembered
     structures stay attackable, which is standard RTS and matches what the
     renderer already shows.
   - **node** β‡’ `isNodeDiscovered(state.fogs[owner], node)` (`fog.js:66`).
7. **Delegate** to `engine/commands.js`, unchanged. Affordability, prereqs,
   placement, doctrine locks and role filters stay **exactly where they are** β€”
   `issueBuild:404-407` re-runs `canAfford`/`prereqsMet`/`canPlaceBuilding`
   server-side for free, because the server *is* the authority and the codec
   calls the same function the local game calls.

That last point is the payoff of D1: **`issueBuild`'s placement validation is not
re-implemented in the codec at all.** The codec's only job is to prove the worker
belongs to the submitter; `canPlaceBuilding(state, buildingType, x, y)`
(`colliders.js:26`) then runs against the authoritative state at the scheduled
tick and returns `null` if the ground was taken in the meantime. The codec maps
that `null` to a `refused` result and echoes it to the client, which rolls back
its optimistic ghost. No duplicated collision logic, no drift between client
preview and server truth.

### 3.6 Phase 2 β€” the economy commands

Same envelope, same resolver, appended to the union:

```ts
type WireCommand2 =
  | { t: "queueProduction";  building: string; u: string; alt?: boolean }
  | { t: "cancelProduction"; building: string; i: number }
  | { t: "researchUpgrade";  building: string; up: string }
  | { t: "researchTech";     building: string; tech: string }
  | { t: "cancelResearch";   building: string; i: number }
  | { t: "deployColonyShip"; ship: string }
  | { t: "packCommandCenter"; building: string }
  | { t: "lightFuse";        unit: string }
  | { t: "marketSell" | "marketBuy"; com: string; qty: number }
  | { t: "setColonyPolicy";  planet: string; patch: object };
```

Every one of these resolves its `building`/`unit`/`ship` id through the same
owner-scoped resolver, which closes Β§2.2 wholesale. `marketSell`/`marketBuy` and
the diplomacy verbs take no entity id at all and are scoped by the stamped owner
directly β€” note `market.js:164 sell(galaxy, state, com, qty)` currently has **no
owner parameter**; it will need one (or a per-owner market), which is a genuine
Odyssey-scope design question and is out of scope for this document.

---

## 4. The adapter layer β€” `net/commandCodec.js`

### 4.1 Recommendation: **wrap, do not change `engine/commands.js`**

**Decision: D1. Keep object-ref signatures. Add `net/commandCodec.js`.**

Reasons, in order of weight:

1. **The object graph is not incidental.** `follow-leader` orders carry a live
   `Unit` (`commands.js:205`), `squadFollowers`/`squadLeader` are a bidirectional
   object graph (`:46-53`), and the file itself documents this as transient,
   deliberately non-serialisable state (`:43-45`). "Make it id-based" is not a
   signature change; it is a rewrite of the squad system plus every consumer in
   `movement.js` (`keepFollowingLeader`, `escortSlot`) β€” with a real perf cost
   (`test/perf-guard.test.js` exists) from re-doing `state.units.get()` in the
   hot loop.
2. **Blast radius.** **209 call sites** outside `engine/commands.js`, across 20 files:
   `test/commands.test.js` (58), `test/formation.test.js` (36),
   `test/ferry.test.js` (28), `test/recycle.test.js` (16), `inputCommands.js`
   (13), `engine/aiMilitary.js` (11), `input.js` (6), `test/sim.test.js` (6),
   `test/scout.test.js` (6), `test/escort.test.js` (6), `hudSelection.js` (5),
   plus 9 more files. Against 2519 tests and a
   determinism guard, that is a multi-day change with a real chance of a silent
   behavioural drift that only `test/determinism.test.js` would catch β€” and only
   if the drift happens to change a fingerprinted field.
3. **The AI already holds objects.** `aiMilitary.js`/`aiEconomy.js` iterate live
   units and pass them straight in. Forcing ids means map lookups the AI does not
   need, on the sim's hot path, for zero benefit β€” the AI never crosses a wire.
4. **A choke point is worth more than scattered guards.** One file to audit, one
   file to fuzz, one file to rate-limit, one file where "did we check ownership?"
   has a single answer. Scattering `owner` parameters into 22 engine functions
   would also mean every AI and test call site must now supply an owner β€”
   the *same* 209-site churn, plus a permanently wider engine API.
5. **The engine stays DOM-free and pure.** The codec is `net/`, not `engine/`, so
   `test/engine-purity.test.js` and the determinism guard keep their current
   boundary unchanged.

**What we give up:** the engine's public API stays "unsafe by default" β€” anyone
who calls `issueRecycle` directly can still recycle an enemy. Mitigation: a
guard test asserting that no file under `net/` or `server/` imports
`engine/commands.js` **except** `net/commandCodec.js`. Same idiom as the existing
`test/engine-purity.test.js` import walk (`engine-purity.test.js:35-52`).

### 4.2 The one exception β€” `issueSetRally` (D2)

`issueSetRally(building, x, y, nodeId)` (`:531`) is the only export taking a bare
entity object with no `state`. It is a 1-line function, has **3 call sites**
total, does no validation whatsoever, and is the only place where the codec would
otherwise have to hand a raw object across the boundary. Change it to match its
id-based sibling `issueSetLogiPriority(state, buildingId, priority)` (`:330`):

```js
export function issueSetRally(state, buildingId, x, y, nodeId = null) {
  const b = state.buildings.get(buildingId);
  if (!b) return;
  b.rally = { x, y, nodeId };
}
```

Cost: 3 call sites (`inputCommands.js:199` + 2 tests). Benefit: the codec's
entity-resolution rule becomes universal with no special case.

`issueSetAILogistics(units, on, state)` (`:295`) has an odd trailing `state`
parameter. **Leave it.** It is ugly, it is not a correctness problem, and
touching it buys nothing.

### 4.3 The codec

```js
/* ============================================================
   net/commandCodec.js β€” the ONLY bridge between the wire and engine/commands.js.

   Wire commands are id-based, owner-scoped and tick-scheduled. This file
   resolves ids to the live objects engine/commands.js wants, proves the
   submitting owner actually owns them, and delegates. It deliberately
   re-implements NO game rule: affordability, prereqs, placement, doctrine
   locks and role filters all stay in engine/, which the server calls exactly
   as the single-player client does.

   INVARIANT: no other module under net/ or server/ may import
   engine/commands.js. See test/net-boundary.test.js.
   ============================================================ */

"use strict";

import * as cmd from "../engine/commands.js";
import { BUILDINGS } from "../engine/entities.js";
import { FORMATION_SHAPES, LEADER_POSITIONS } from "../engine/formation.js";
import { LOGI_PRIORITIES } from "../engine/haul.js";
import { isVisibleAt, isExploredAt, isNodeDiscovered } from "../engine/fog.js";

export const PROTOCOL_VERSION = 1;

export const LIMITS = {
  ids: 400,          // per-command selection cap
  patrolPoints: 32,  // engine/commands.js:484 pushes |ids| x |pts| orders β€” must be bounded
  batch: 16,
};

export const REJECT = {
  BAD_VERSION:  "bad-version",
  UNKNOWN_TYPE: "unknown-type",
  MALFORMED:    "malformed",
  TOO_MANY:     "too-many",
  NOT_OWNER:    "not-owner",       // a LIE β€” the submitter does not own this entity
  NO_TARGET:    "no-target",       // the object of the command does not exist
  NOT_VISIBLE:  "not-visible",     // fog gate (a rule the engine does not have)
  EMPTY:        "empty-selection", // every id resolved to nothing (a legitimate race)
  OUT_OF_BOUNDS:"out-of-bounds",
  REFUSED:      "refused",         // the ENGINE said no (afford / prereq / placement)
};

/* ---------- primitives ---------- */

const isId  = v => typeof v === "string" && v.length > 0 && v.length <= 32;
const isNum = v => typeof v === "number" && Number.isFinite(v);
const bool  = v => v === undefined || typeof v === "boolean";

const ok   = (result = null) => ({ ok: true, result });
const err  = code => ({ ok: false, code });

function inBounds(state, x, y) {
  return isNum(x) && isNum(y) && x >= 0 && y >= 0 && x <= state.map.width && y <= state.map.height;
}

/* ---------- resolvers: id -> live object, scoped to `owner` ----------

   Two failure modes, deliberately different:
     - the entity is GONE      -> drop that id (a real race between issue and apply)
     - the entity is SOMEONE ELSE'S -> reject the whole command (a lie)
   Rejecting on "gone" would make the protocol fail under ordinary latency;
   dropping on "not yours" would let an attacker probe the world for free.

   ORDER IS PRESERVED. ids[0] is the formation leader (engine/commands.js:145,
   :195) and issueEscort derives ring slots from array index (:363). Never sort.
*/

function resolveOwn(state, owner, ids, pick) {
  if (!Array.isArray(ids) || ids.length === 0) return err(REJECT.EMPTY);
  if (ids.length > LIMITS.ids) return err(REJECT.TOO_MANY);
  const seen = new Set();
  const out = [];
  for (const id of ids) {
    if (!isId(id)) return err(REJECT.MALFORMED);
    if (seen.has(id)) continue;                 // dedupe, first occurrence wins
    seen.add(id);
    const e = pick(state, id);
    if (!e) continue;                           // died in flight β€” drop
    if (e.owner !== owner) return err(REJECT.NOT_OWNER);
    out.push(e);
  }
  return out.length ? ok(out) : err(REJECT.EMPTY);
}

const pickUnit     = (s, id) => s.units.get(id);
const pickBuilding = (s, id) => s.buildings.get(id);
const pickEntity   = (s, id) => s.units.get(id) || s.buildings.get(id);

const ownUnits      = (s, o, ids) => resolveOwn(s, o, ids, pickUnit);
const ownEntities   = (s, o, ids) => resolveOwn(s, o, ids, pickEntity);   // recycle takes both

function ownBuilding(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const b = state.buildings.get(id);
  if (!b) return err(REJECT.NO_TARGET);
  if (b.owner !== owner) return err(REJECT.NOT_OWNER);
  return ok(b);
}

function ownUnit(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const u = state.units.get(id);
  if (!u) return err(REJECT.NO_TARGET);
  if (u.owner !== owner) return err(REJECT.NOT_OWNER);
  return ok(u);
}

/* Any entity as the OBJECT of a command. Own entities need no fog check; a
   foreign unit must be currently visible, a foreign building merely explored
   (remembered structures stay targetable β€” standard RTS, and it is what the
   renderer already draws). This rule does not exist in the engine at all:
   inputCommands.js:58,:62 enforces it in the UI only, so a client that ignores
   its own renderer is a maphack today. */
function targetEntity(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const e = pickEntity(state, id);
  if (!e) return err(REJECT.NO_TARGET);
  if (e.owner === owner) return ok(e);
  const fog = state.fogs[owner];
  const seen = e.kind === "building" ? isExploredAt(fog, e.x, e.y) : isVisibleAt(fog, e.x, e.y);
  return seen ? ok(e) : err(REJECT.NOT_VISIBLE);
}

function targetNode(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const n = state.map.nodes.find(n => n.id === id);
  if (!n) return err(REJECT.NO_TARGET);
  return isNodeDiscovered(state.fogs[owner], n) ? ok(n) : err(REJECT.NOT_VISIBLE);
}

/* ---------- formation ---------- */

function decodeFormation(f) {
  if (f === undefined) return undefined;                 // engine default: flat grid spread
  if (f === null || typeof f !== "object") return null;  // null => malformed
  const { s = "grid", l = "front", hx, hy } = f;
  if (!FORMATION_SHAPES.includes(s)) return null;
  if (!LEADER_POSITIONS.includes(l)) return null;
  if (hx !== undefined && !isNum(hx)) return null;
  if (hy !== undefined && !isNum(hy)) return null;
  const out = { shape: s, leaderPos: l };
  if (hx !== undefined) out.headingX = hx;               // the right-click-DRAG facing (commands.js:218)
  if (hy !== undefined) out.headingY = hy;
  return out;
}

/* ---------- the schema table ----------
   One entry per wire type. `run` receives already-resolved, already-owned
   objects and does nothing but call engine/commands.js. Everything that could
   reject has already rejected. */

const SCHEMA = {

  /* ----- movement ----- */
  move: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const f = decodeFormation(c.f); if (f === null) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueMove(r.result, c.x, c.y, !!c.q, f);
    return ok();
  }},

  attackMove: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const f = decodeFormation(c.f); if (f === null) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueAttackMove(r.result, c.x, c.y, !!c.q, f);
    return ok();
  }},

  holdFormation: { run(state, owner, c) {
    const s = c.s ?? "grid", l = c.l ?? "front";
    if (!FORMATION_SHAPES.includes(s) || !LEADER_POSITIONS.includes(l)) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueHoldFormation(r.result, s, l);   // anchor = live centroid, commands.js:377
    return ok();
  }},

  patrol: { run(state, owner, c) {
    if (!Array.isArray(c.pts) || !c.pts.length) return err(REJECT.MALFORMED);
    if (c.pts.length > LIMITS.patrolPoints) return err(REJECT.TOO_MANY);
    for (const p of c.pts) if (!p || !inBounds(state, p.x, p.y)) return err(REJECT.OUT_OF_BOUNDS);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issuePatrol(r.result, c.pts.map(p => ({ x: p.x, y: p.y })));   // strip any extra keys
    return ok();
  }},

  stop:  { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueStop(us)) },
  hold:  { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueHold(us)) },
  scout: { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueScout(us)) },

  /* ----- targeted ----- */
  attack: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
    const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
    cmd.issueAttack(r.result, t.result.id, !!c.q);
    return ok();
  }},

  escort: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
    const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
    // inputCommands.js:272 filters the target out of its own escort ring; do the
    // same here so the engine can never be handed a unit escorting itself.
    const units = r.result.filter(u => u.id !== t.result.id);
    if (!units.length) return err(REJECT.EMPTY);
    cmd.issueEscort(units, t.result.id, !!c.q);
    return ok();
  }},

  repair:  { run: (s, o, c) => targeted(s, o, c, (us, id, q) => cmd.issueRepair(us, id, q)) },
  service: { run: (s, o, c) => targeted(s, o, c, (us, id, q) => cmd.issueServiceBuilding(us, id, q)) },

  ferry: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = ownUnit(state, owner, c.target); if (!t.ok) return t;   // your OWN freighter only
    const r = ownUnits(state, owner, c.ids);   if (!r.ok) return r;
    cmd.issueFerryFreighter(r.result, t.result.id, !!c.q);
    return ok();
  }},

  setHomeBase: { run(state, owner, c) {
    const t = ownBuilding(state, owner, c.target); if (!t.ok) return t;
    // The engine never validates ccId is even a building (commands.js:281-287).
    if (t.result.type !== "command") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetHomeBase(r.result, t.result.id);
    return ok();
  }},

  assistBuild: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = ownBuilding(state, owner, c.target); if (!t.ok) return t;
    if (!t.result.constructing) return err(REJECT.NO_TARGET);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    // buildingType comes from the RESOLVED SITE, never from the wire β€” it is the
    // only thing gating unit eligibility (commands.js:421) and a lying client
    // would otherwise walk a combat unit onto a site the engine refuses.
    cmd.issueAssistBuild(r.result, t.result.id, t.result.type, !!c.q);
    return ok();
  }},

  gather: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const n = targetNode(state, owner, c.node); if (!n.ok) return n;
    const r = ownUnits(state, owner, c.ids);    if (!r.ok) return r;
    cmd.issueGather(r.result, n.result.id, !!c.q);
    return ok();
  }},

  /* ----- construction ----- */
  build: { run(state, owner, c) {
    if (typeof c.b !== "string" || !BUILDINGS[c.b]) return err(REJECT.MALFORMED);
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    const w = ownUnit(state, owner, c.worker); if (!w.ok) return w;
    // EVERYTHING else β€” odysseyOnly, canBuildCategory, canAfford, prereqsMet,
    // canPlaceBuilding, payCost β€” is re-run by the engine against authoritative
    // state at THIS tick (commands.js:396-412). We re-implement none of it.
    const id = cmd.issueBuild(state, w.result.id, c.b, c.x, c.y);
    return id ? ok({ buildingId: id }) : err(REJECT.REFUSED);
  }},

  recycle:       { run: (s, o, c) => entitiesOnly(s, o, c, es => cmd.issueRecycle(es)) },
  cancelRecycle: { run: (s, o, c) => entitiesOnly(s, o, c, es => cmd.issueCancelRecycle(es)) },

  /* ----- toggles ----- */
  setAILogistics: { run(state, owner, c) {
    if (typeof c.on !== "boolean") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetAILogistics(r.result, c.on, state);   // tech gate is owner-correct at commands.js:298
    return ok();
  }},

  setCollectPoint: { run(state, owner, c) {
    if (typeof c.on !== "boolean") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetCollectPoint(r.result, c.on);
    return ok();
  }},

  setLogiPriority: { run(state, owner, c) {
    if (!LOGI_PRIORITIES.includes(c.p)) return err(REJECT.MALFORMED);
    const b = ownBuilding(state, owner, c.building); if (!b.ok) return b;
    cmd.issueSetLogiPriority(state, b.result.id, c.p);   // engine has NO owner check (commands.js:330)
    return ok();
  }},

  setRally: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    const b = ownBuilding(state, owner, c.building); if (!b.ok) return b;
    let nodeId = null;
    if (c.node !== undefined && c.node !== null) {
      const n = targetNode(state, owner, c.node); if (!n.ok) return n;
      nodeId = n.result.id;
    }
    cmd.issueSetRally(state, b.result.id, c.x, c.y, nodeId);   // see D2: signature changed
    return ok();
  }},
};

/* ---------- shared shapes ---------- */

function unitsOnly(state, owner, c, fn) {
  const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
  fn(r.result); return ok();
}
function entitiesOnly(state, owner, c, fn) {
  const r = ownEntities(state, owner, c.ids); if (!r.ok) return r;
  fn(r.result); return ok();
}
function targeted(state, owner, c, fn) {
  if (!bool(c.q)) return err(REJECT.MALFORMED);
  const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
  const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
  fn(r.result, t.result.id, !!c.q); return ok();
}

/* ============================================================
   PUBLIC API
   ============================================================ */

/** Client side: stamp an envelope around a WireCommand. No engine access. */
export function encode(command, seq, clientTick) {
  return { v: PROTOCOL_VERSION, seq, tick: clientTick, cmd: command };
}

/** Server side: envelope shape only. Does NOT touch game state β€” this runs on
 *  ARRIVAL, so a malformed packet is dropped before it can be scheduled. */
export function decode(envelope) {
  if (!envelope || typeof envelope !== "object") return err(REJECT.MALFORMED);
  if (envelope.v !== PROTOCOL_VERSION) return err(REJECT.BAD_VERSION);
  if (!Number.isInteger(envelope.seq) || envelope.seq < 0) return err(REJECT.MALFORMED);
  const c = envelope.cmd;
  if (!c || typeof c !== "object" || typeof c.t !== "string") return err(REJECT.MALFORMED);
  if (c.t === "batch") {
    if (!Array.isArray(c.c) || !c.c.length || c.c.length > LIMITS.batch) return err(REJECT.TOO_MANY);
    for (const sub of c.c) {
      if (!sub || typeof sub.t !== "string" || sub.t === "batch") return err(REJECT.MALFORMED);
      if (!SCHEMA[sub.t]) return err(REJECT.UNKNOWN_TYPE);
    }
    return ok(c);
  }
  if (!SCHEMA[c.t]) return err(REJECT.UNKNOWN_TYPE);
  return ok(c);
}

/** Server side: apply ONE validated command against live state, as `owner`.
 *  Runs at the scheduled tick, immediately before tick(state, dt). */
export function apply(state, owner, command) {
  if (command.t === "batch") {
    // Atomic on VALIDATION: a batch is one client gesture resolved against one
    // observed state (inputCommands.js:90-96 fans a right-click into two calls).
    // If any member is invalid the client's disambiguation was wrong, so none apply.
    const results = [];
    for (const sub of command.c) {
      const r = SCHEMA[sub.t].run(state, owner, sub);
      if (!r.ok) return r;
      results.push(r.result);
    }
    return ok(results);
  }
  const entry = SCHEMA[command.t];
  if (!entry) return err(REJECT.UNKNOWN_TYPE);
  return entry.run(state, owner, command);
}

export const COMMAND_TYPES = Object.keys(SCHEMA);
```

**Note the batch caveat.** A batch validates-then-applies member by member, so a
member that fails *after* an earlier one already mutated state is not rolled
back. That is acceptable for the only batch we actually generate
(`aggressiveMove`'s two disjoint sub-selections, `inputCommands.js:94-95`), where
member 2's validity does not depend on member 1. If a future batch needs true
atomicity, validate all members against a dry-run resolver first, then apply β€”
but do not build that until something needs it.

---

## 5. Determinism of application order

### 5.1 The ordering rule

Commands arrive out of order from N clients over N sockets. The authoritative
total order is:

```
sort key = (applyTick, ownerIndex, seq)

  applyTick   integer, stamped by the server at admission (Β§5.2)
  ownerIndex  state.owners.indexOf(owner)  -- engine/state.js:227,
              "the world's side ids, in canonical iteration order"
  seq         the client's per-connection monotonic counter
```

This is a **total** order: `(owner, seq)` is unique by construction (duplicates
are dropped idempotently at admission), so no two records ever tie.

**Why `ownerIndex` and not arrival time.** Arrival time is wall clock; the
engine-purity guard forbids wall clock in the sim
(`test/engine-purity.test.js:16` bans `Date.now`), and more importantly a replay
must not depend on network jitter. `state.owners` is a stable, seed-independent
array that already drives every other owner-generic loop in the engine
(`state.js:269-270`, `sim.js`, `victory.js`).

**Why `seq` and not a content hash.** `seq` preserves the *client's own* intent
order, which is load-bearing: a player who queues `move` then `attackMove` on the
same units in the same tick means something different from the reverse.

**Fairness note.** `ownerIndex` gives seat 0 a systematic advantage in the rare
case of two players issuing conflicting commands in the same tick (e.g. both
right-clicking the last unclaimed node). This is a known, accepted asymmetry in
every lockstep RTS and is far smaller than the network jitter it replaces. If it
ever matters, rotate the ownerIndex offset by `applyTick % owners.length` β€” but
do not do this speculatively; it makes replay logs harder to reason about.

### 5.2 Where the tick stamp comes from

**The server stamps it. The client's `tick` field is never trusted.**

```js
const INPUT_DELAY_TICKS = 3;   // 150ms at 20Hz β€” covers typical RTT + jitter
applyTick = state.tick + INPUT_DELAY_TICKS;
```

Because the architecture is **server-authoritative** (not peer lockstep), the
server does not need clients to agree on a future tick β€” it only needs the choice
to be *recorded*. The stamp depends on wall-clock arrival, which is
nondeterministic during the live match but is written into the log, so replay
from the log is exact (Β§7). This is strictly simpler and more robust than
honouring a client-proposed tick, which requires rejecting late commands and
opens a "schedule everything at tick+1000" griefing vector.

`INPUT_DELAY_TICKS` exists so a command is visible to every spectator/relay
before it lands, and so the server can batch a tick's worth of input into one
sorted list. Set it to 0 and the protocol still works; set it to 3 and the
spectator stream can stay a whole tick behind the sim without stuttering.

### 5.3 Where in the loop

```js
/* server/matchLoop.js β€” the ONLY place a wire command reaches the sim. */
import { tick } from "../engine/sim.js";
import { apply } from "../net/commandCodec.js";

function ownerIndex(state, owner) { return state.owners.indexOf(owner); }

/** Pull every command scheduled at or before `state.tick`, order it, apply it,
 *  append it to the log, THEN advance the sim by one fixed step. */
export function stepMatch(match, dt) {
  const { state, pending, log } = match;

  const due = pending.filter(r => r.applyTick <= state.tick);
  if (due.length) {
    // A command whose tick has already passed (a slow admission, a resumed
    // socket) still lands here rather than being dropped β€” but it sorts by its
    // ORIGINAL applyTick, so the log stays monotonic and the replay is exact.
    due.sort((a, b) =>
      a.applyTick - b.applyTick ||
      ownerIndex(state, a.owner) - ownerIndex(state, b.owner) ||
      a.seq - b.seq);
    for (const rec of due) {
      const res = apply(state, rec.owner, rec.cmd);
      rec.result = res.ok ? res.result : { rejected: res.code };
      rec.appliedAtTick = state.tick;   // == applyTick in the normal case
      log.push(rec);
      match.emitAck(rec);
    }
    match.pending = pending.filter(r => r.applyTick > state.tick);
  }

  tick(state, dt);          // <- the sim advances AFTER every command for this tick
}
```

**Why before `tick`, never inside it.** `tick(state, dt)` opens with
`runAI(state, dt)` (`engine/sim.js:39-40`), which issues its own orders through
the same `issue*` functions. In single-player, DOM input handlers fire between
`update()` calls (JS is single-threaded; `createLoop`'s `update(dtFixed)` at
`engine/loop.js:55` runs to completion). So "commands land between ticks, before
the AI thinks" is *exactly* the existing single-player ordering. Applying them
mid-tick β€” e.g. after `runAI` but before movement β€” would be a behavioural change
with no justification, and would break the ability to validate the netcode path
against `tools/selfplay.js`.

**Rejected commands are logged too.** A rejection is a fact about the match
(anti-cheat forensics, and a spectator needs to know why nothing happened). It
carries no state mutation, so it does not affect replay β€” but it must be
*present* in the log for the log to be auditable.

### 5.4 The fixed step

The server must pick one `dt` and never change it. `tools/selfplay.js:42-57`
documents in detail why: *"A fixed step IS the simulation… dt 0.1 ended 'ai' by
elimination at 1138 s, dt 0.05 ended 'player' by elimination at 1686 s β€” opposite
winners."*

**Recommendation: `dt = 0.05` (20 Hz), matching `createLoop`'s default
(`engine/loop.js:34`) and ordinary play.** `SELFPLAY_DT = 0.1` exists for
throughput on the AI bench, not for fidelity. Record the chosen `dt` in the match
header (Β§7) so a replay cannot be run at the wrong step.

---

## 6. `state.selection`

### 6.1 Who reads it β€” the grep

`state.selection` is declared at `engine/state.js:231` as
`selection: []  // unit/building ids currently selected by the human player`, and
typed at `engine/types.js:335`.

**Readers β€” all UI, none in the sim:**

| File | Lines |
|---|---|
| `inputCommands.js` | `103`, `147`, `150`, `152`, `154`, `157`, `177`, `195`, `196`, `206` |
| `renderEffects.js` | `544`, `561`, `562`, `598`, `635` |
| `hudSelection.js` | `76` (and the whole panel-signature system downstream) |
| `render.js` | `132` |
| `techChart.js` | `258` |
| `boot.js` | `223` |

**Writers inside `engine/`:**

| File | Line | What |
|---|---|---|
| `engine/state.js` | `347` | `removeEntity` prunes the dead id out of `state.selection` |
| `engine/galaxy.js` | `1422` | `from.selection = []; dest.selection = []` on an interplanetary jump |

**No file under `engine/` ever READS `state.selection`.** It is written
defensively (so the UI never holds a dangling id) and read only by the client.
The brief's premise is confirmed.

### 6.2 Where it must move β€” and what stays

**Decision (D6): selection is per-client UI state and moves to the client
session. The `State` field stays, permanently empty, on the server.**

- **Client** (browser): `client/session.js` grows `selection: string[]`, owned by
  the input layer. `inputCommands.js`'s `applyBoxSelection` / `selectedUnits` /
  `commandAt` read it from there instead of from `state`. Every one of those call
  sites is already local to the client β€” this is a find-and-replace, not a
  redesign.
- **MCP agent**: an agent has no pointer and no box-select. Its "selection" is
  whatever id array it puts in a command. The MCP server should expose a
  *convenience* selection in the agent session (so an agent can say "select all
  my Lancers, then attack-move") but it must be an MCP-server concept, never a
  sim concept, and it must be re-validated by the codec on every command anyway.
- **Server**: `state.selection` stays as `[]` forever. Do **not** delete the
  field. `removeEntity` (`state.js:345-348`) is on the hot path of every death in
  the game; making it conditional, or removing the line, means touching
  `engine/state.js`, `engine/galaxy.js:1422`, `engine/types.js:335`, the
  persistence layer and any test that asserts on selection pruning β€” for a
  saving of one array filter over an always-empty array. Not worth it.

**Guard test:** assert that no file under `server/` or `net/` contains
`state.selection`, using the same directory-walk idiom as
`test/engine-purity.test.js:35-52`. That converts D6 from a convention into an
enforced invariant.

**One subtlety that must be carried across:** selection *order* is game input
(Β§3.3). `applyBoxSelection`'s Ctrl-click promote-to-front
(`inputCommands.js:150-152`) is how a player picks a formation leader. When
selection moves client-side, that ordering must still be what the client puts in
`cmd.ids` β€” otherwise leaders silently change and formations break.

---

## 7. Replay & spectator

### 7.1 Can a seed + ordered command log replay the match exactly?

**Yes β€” after B1 in Β§8 is fixed. Not before.**

The engine is built for this. `engine/rng.js:5-6` states the sim uses *"NO other
randomness (a determinism-guard test enforces it), so 'same seed β‡’ same game'"*,
`test/engine-purity.test.js:16` bans `Math.random`/`Date.now`/`performance.now`
across `engine/`, and `test/determinism.test.js:22-29` proves 2500 ticks replay
byte-identically from one seed.

### 7.2 What a replay file must capture

```jsonc
{
  "replayVersion": 1,
  "engineCommit": "50ceb88",              // MUST match; balance changes invalidate a replay
  "protocolVersion": 1,

  "sim": {
    "dt": 0.05,                            // Β§5.4 β€” a different step is a DIFFERENT GAME
    "createGameState": {                   // every argument of engine/state.js:154
      "planetId": "ferros",
      "seed": 12345,                       // feeds mulberry32 (engine/rng.js:24)
      "sizeMult": 1, "resourceMult": 1, "swapAsym": false,
      "matchTimeLimit": null, "popCap": null, "endless": false,
      "difficulty": "medium", "aiArchetype": null,
      "playerFaction": "…", "aiFaction": "…"
    },
    "postCreate": [                        // mutations applied AFTER createGameState returns
      { "fn": "seedDifficultyEdge", "owner": "p1" },   // engine/state.js:299
      { "fn": "createAiController", "owner": "p3", "opts": { "apm": 120, "micro": true,
                                                             "strategy": "default",
                                                             "difficulty": "hard",
                                                             "archetype": "…" } }
    ]
  },

  "seats": [ { "owner": "p1", "kind": "human",  "label": "alma" },
             { "owner": "p2", "kind": "agent",  "label": "mcp:claude-1" },
             { "owner": "p3", "kind": "ai",     "label": "scripted" } ],

  "commands": [ /* log records from Β§3.1, already in (applyTick, ownerIndex, seq) order */ ],

  "checkpoints": [ { "tick": 200,  "fp": "…" },   // tools/selfplay.js:157 fingerprint()
                   { "tick": 400,  "fp": "…" } ],

  "outcome": { "tick": 18342, "winner": "p1", "winReason": "elimination" }
}
```

### 7.3 What is easy to forget β€” and fatal if forgotten

1. **`dt`.** Covered above. Put it in the header and refuse to replay without it.
2. **Every `createGameState` option, not just the seed.** `sizeMult`,
   `resourceMult` and `swapAsym` all feed `generateMap` (`state.js:161-165`) and
   change the world.
3. **Post-`createGameState` mutations.** `seedDifficultyEdge` (`state.js:299`)
   writes `players[owner].upgrades.hardEdge` *after* construction, and
   `state.playerAi` is *"populated after createGameState, never by it"*
   (`types.js:340-341`). A replay that only records constructor args reproduces a
   different world. `tools/selfplay.js:36` already imports all three functions for
   exactly this reason β€” copy that pattern.
4. **AI seat configuration.** `{apm, micro, strategy, difficulty, archetype}` per
   AI seat (`tools/selfplay.js:69-74`). The scripted AI is a *player* in a replay
   and its dials are inputs.
5. **The rejected commands.** Keep them; they are audit evidence and they cost
   nothing to replay (they no-op).
6. **The engine commit.** A balance tweak in `entities.js` silently invalidates
   every stored replay. Refuse to replay across a commit mismatch rather than
   producing a plausible lie.
7. **Periodic fingerprints.** Reuse `fingerprint(state)`
   (`tools/selfplay.js:157-169`) verbatim β€” it already covers units (id, type,
   owner, x, y, hp, order type), buildings, per-owner resources, fog totals, both
   AI controllers, `tick`, `time`, `over`, `winner`. Store one every N ticks. On
   replay, a mismatch localises the divergence to an N-tick window instead of
   "somewhere in 20 minutes". For live play, comparing the server fingerprint
   against a client's own prediction is the desync canary.
   *Note:* `test/determinism.test.js:9-15` warns that a *weaker* local snapshot
   once masked real drift, and `test/_helpers.js`'s `entitySnapshot` is the
   stronger one. For stored checkpoints, prefer `entitySnapshot`; `fingerprint`
   is the cheap live variant.

### 7.4 Spectator

A spectator is a replay consumer with a live tail: subscribe to the same ordered
command stream plus periodic `entitySnapshot` keyframes, and run the identical
`stepMatch` loop locally. That gives full-fidelity spectating at command
bandwidth (a few hundred bytes/sec) rather than state bandwidth.

**Fog is the catch.** A spectator running the real sim has the *whole* state,
including every player's fog. Client-side fog filtering is not a security
boundary. Two honest options:

- **Deferred spectating** (recommended for v1): spectators are N seconds behind
  and receive the *stream*, but the relay withholds commands whose subject is not
  yet visible to the spectator's chosen POV. Simple, and matches how most RTS
  observers work.
- **Server-rendered POV**: the server maintains one fog-filtered state view per
  spectator POV and ships deltas. Correct, but expensive and a much bigger build.

Do not ship "full state, hidden by the client" β€” in a competitive ladder that is
a maphack with extra steps.

---

## 8. Blockers found β€” fix before the codec ships

### B1 β€” `nextEntityId` is a module-global (**critical**)

`engine/state.js:23-24`:

```js
let nextEntityId = 1;
function newId(prefix) { return `${prefix}${nextEntityId++}`; }
```

reset at `createGameState` (`state.js:155`). The comment at `state.js:17-22`
reasons that *"IDs are only ever compared within one state's own Maps, so two
live games sharing id strings is harmless"* β€” **true for two games, false for two
games in one process.** A single Node server hosting concurrent matches will
interleave `newId` calls across matches, so match A's units are minted
`u57, u59, u62…` depending on what match B did. Entity ids feed the
deterministic tie-breaks in movement/separation/gather/`rankSlotsByRange`
(`commands.js:105` sorts by `a.id < b.id`), so **the same seed and the same
command log produce a different match** depending on what else the server was
hosting. Replay, spectating and any ladder rating built on them are all invalid.

Worse, `createGameState` *resets* the counter to 1 β€” so starting match B
mid-match A makes A start minting ids that collide with its own live entities.

**Fix (small, surgical):** move the counter onto the state. Either
`state.nextEntityId` with `makeUnit`/`makeBuilding` taking `state`, or a
per-match id-minter closure passed into `createGameState`. The existing
`peekEntityId`/`restoreEntityId` pair (`state.js:29-31`) shows persistence already
treats it as per-game state β€” this makes that real. Guard with a test that two
interleaved `createGameState` runs each replay identically.

**Until this is fixed, run one match per Node process (worker/child process per
match).** That is a legitimate v1 shipping posture on a Hugging Face Space and
sidesteps B1 entirely β€” but write it down as a constraint, not an accident.

### B2 β€” formations are gated on `owner === "player"` (**critical**)

`engine/commands.js:155` (Β§1.4). In a multi-seat match no owner is literally
`"player"`, so `dispatchFormation` takes the AI branch for everyone and the
leader/follower squad system silently disappears.

**Fix:** replace the literal test with a state-level predicate β€” e.g.
`state.seats[owner]?.kind !== "ai"`, or a `humanControlled` flag set at match
creation. The comment block at `:147-154` explains the intent precisely ("there's
no analogous 'the unit you built a selection around' concept for the scripted
AI"), so the predicate is *seat kind*, not owner name. Small change, but it
changes AI-vs-AI fingerprints if done carelessly β€” gate it so the existing
`"player"`/`"ai"` two-owner world behaves byte-identically.

### B3 β€” `tick` hardcodes two fog owners (**high**)

`engine/sim.js:70-71`:

```js
updateFog(state, state.fog, "player");
updateFog(state, state.fogAI, "ai");
```

`state.fogs` is already a per-owner map (`state.js:189`, `types.js:336`) and
`createGameState` iterates `owners` correctly (`state.js:269-270`) β€” but the tick
loop does not. Seats 3+ get no fog updates at all. Fix by iterating
`state.owners`. `test/ownerScaffold.test.js` exists and is the right place to
extend. (`engine/victory.js`, `engine/diplomacy.js` and `engine/galaxy.js` carry
similar two-owner assumptions β€” worth a dedicated audit, out of scope here.)

### B4 β€” `recycle.js`'s ownership comment is false (**doc bug, real risk**)

`engine/recycle.js:87-89` claims `issueRecycle` checks ownership. It does not
(`commands.js:444-450`). Fix the comment when the codec lands, or a future reader
will build on a guarantee that isn't there.

### B5 β€” explicit attack orders are friendly-fire capable (**medium**)

`engine/combat.js:46` takes `unit.order.targetId` with no owner filter and reaches
`performAttack` (`:85`) without one, while every *auto*-acquisition path does
filter (`:153`, `:243`, `:386`, `:411`). Harmless in single-player (the UI never
offers it, `inputCommands.js:247-251`). In multiplayer it is a team-griefing
vector and an own-goal footgun. Decide deliberately: either add the filter in the
codec (reject `attack` on a target whose owner is the submitter or an ally), or
in `combat.js`. **Recommend the codec** β€” keeping the engine byte-identical
protects the determinism baseline, and "can I shoot my ally" is a *rules* question
that belongs with the other rules the codec owns.

---

## 9. TDD hooks (the repo's house style)

The source repo is strict TDD with 2519 tests and a determinism guard. Land the
codec the same way:

| Test file | Asserts |
|---|---|
| `test/net-boundary.test.js` | Only `net/commandCodec.js` imports `engine/commands.js` from outside `engine/`. Directory-walk idiom from `engine-purity.test.js:35-52`. |
| `test/commandCodec-ownership.test.js` | For **every** entry in `COMMAND_TYPES`, a command naming another owner's entity returns `NOT_OWNER` and mutates nothing. Table-driven over `COMMAND_TYPES` so a new command type cannot be added without an ownership test. |
| `test/commandCodec-fog.test.js` | Foreign unit not visible β‡’ `NOT_VISIBLE`; foreign building explored-but-not-visible β‡’ allowed; undiscovered node β‡’ `NOT_VISIBLE`. |
| `test/commandCodec-schema.test.js` | Round-trip `encode`β†’`decode`; unknown type, bad version, oversized ids/pts/batch all rejected; **`ids` order is preserved** (the D4 guard). |
| `test/commandCodec-parity.test.js` | Driving a match through the codec produces the byte-identical `entitySnapshot` as driving the same orders through `issue*` directly. This is the proof that wrapping changed nothing. |
| `test/replay.test.js` | Seed + logged commands replay to an identical `entitySnapshot`; a one-tick shift in any `applyTick` diverges (proving the guard is real, mirroring `determinism.test.js:43-47`'s "different seeds diverge" check). |
| `test/matchLoop-order.test.js` | Commands submitted in scrambled arrival order apply in `(applyTick, ownerIndex, seq)` order; duplicate `(owner, seq)` is idempotent. |

---

## 10. Summary of recommendations

1. **Wrap `engine/commands.js` behind `net/commandCodec.js`.** Do not make the
   engine id-based. (Β§4.1)
2. **One engine signature change:** `issueSetRally(state, buildingId, x, y, nodeId)`. (Β§4.2)
3. **Server stamps `owner` and `applyTick`.** Never trust either from the client. (Β§3.1, Β§5.2)
4. **Order is `(applyTick, ownerIndex, seq)`; apply immediately before `tick(state, dt)`.** (Β§5)
5. **`ids` arrays are ordered input β€” never sort them.** (Β§3.3, D4)
6. **Add fog gating in the codec** β€” it exists nowhere in the engine. (Β§2.4, Β§3.5)
7. **`state.selection` moves to the client session; the field stays empty on the server.** (Β§6)
8. **Fix B1 (`nextEntityId`) before any concurrent hosting**, or run one match per
   process until it is fixed. (Β§8)
9. **Fix B2 (`owner === "player"` formation gate)** or multiplayer ships without
   formations. (Β§8)
10. **Cover both intent surfaces.** `engine/commands.js` is phase 1; the ~20
    cost-bearing mutators the HUD calls directly (`hudSelection.js:20-35`) are
    phase 2, through the same codec. (Β§1.6, Β§3.6)