Spaces:
Paused
Paused
|
Download docs/analysis/02-command-wire-protocol.md from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 72.9 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/docs/analysis/02-command-wire-protocol.md
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/docs/analysis/02-command-wire-protocol.md
-
curl -L -o 02-command-wire-protocol.md https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/docs/analysis/02-command-wire-protocol.md
72.9 kB
| # 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) | |