Claude Claude Sonnet 5 commited on
Commit
6648f62
Β·
unverified Β·
1 Parent(s): 3370120

Redefine formation leader as first-selected unit, add persistent follow, speed cap, and drag-to-face

Browse files

Reworks the formation system per feedback on PR #9:

- Leader is now whichever unit the player selected first (the one others
get added to), not a stat-based pick. pickLeader() and layout() in
engine/formation.js simply use array order instead of scanning for
highest maxHp.

- Selecting and moving just the leader now drags along everyone
previously grouped with it, with no need to reselect the group. A new
persistent "follow-leader" order (engine/commands.js, engine/movement.js,
engine/sim.js, engine/combat.js) mirrors the existing escort-order
pattern: followers re-seek the leader's live position every tick via
object references (unit.squadLeader / leader.squadFollowers), set up
by dispatchFormation() and torn down by any direct order that bypasses
it (stop, hold, scout, build).

- A formation's travel speed is capped to its slowest member so a fast
leader can't stretch the group out of shape. dispatchFormation()
computes the cap once at issue time and stores it on the leader's
order as order.speedCap; a new orderedSpeed() helper in movement.js
applies it at every move/attack-move/hold-formation stepToward call
site.

- Right-click-and-drag now sets an explicit facing: input.js tracks a
real mousedown/mousemove/mouseup gesture (replacing the old
contextmenu-only handling) and turns a long-enough drag into a
heading passed through to issueMove/issueAttackMove/issueHoldFormation.
applyFacing() stamps unit.facing accordingly. This applies to
formations and to single/multi-unit selections alike (e.g. facing a
choke point while waiting for the enemy). renderShared.js's
updateFacing() prefers the stamped facing while a unit is
near-stationary, but still faces the direction of travel while moving.

Two issues turned up during implementation and were fixed proactively:

- The new leader/follower squad mechanic only applies to player-owned
units (dispatchFormation gates on leader.owner === "player"); AI-issued
multi-unit orders bypass it entirely so every AI unit still gets its
own real order, exactly as before. Full-suite regression caught this
as a real collision with existing AI tactical tests.

- squadLeader/squadFollowers and any live follow-leader order carry
circular object references that can't survive JSON.stringify.
persist.js now strips them (and nulls follow-leader orders) during
save, matching the existing pattern for other transient fields, with
a regression test confirming save/load doesn't throw while a squad
is active.

Rewrote test/formation.test.js (leader-order semantics, persistent
follow, speed cap, facing, AI-owner gating, save/load safety) and
test/input.test.js (drag-vs-click distinction, facing on drag). Full
suite: 814/814 passing. Typecheck clean. Manually verified in a live
browser via Playwright: group drag-move sets facing and speed cap
correctly, and a solo move of just the leader correctly drags the whole
previously-formed squad along.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dmxcvm178TEt2wVXrX5Cj1

engine/combat.js CHANGED
@@ -15,7 +15,7 @@
15
 
16
  "use strict";
17
 
18
- import { stepToward, keepEscortStation, keepFormationStation } from "./movement.js";
19
  import { UNITS, BUILDINGS, upgradeMult } from "./entities.js";
20
  import { getEntity, removeEntity } from "./state.js";
21
  import { queryNeighbors } from "./grid.js";
@@ -28,7 +28,7 @@ export function updateCombat(state, unit, dt) {
28
  unit.attackTimer = Math.max(0, unit.attackTimer - dt);
29
 
30
  if (unit.order && unit.order.type === "move") {
31
- const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
32
  if (arrived) unit.order = null;
33
  return;
34
  }
@@ -77,7 +77,7 @@ export function updateCombat(state, unit, dt) {
77
  }
78
 
79
  if (unit.order && unit.order.type === "attack-move") {
80
- const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
81
  if (arrived) unit.order = null;
82
  return;
83
  }
@@ -88,10 +88,14 @@ export function updateCombat(state, unit, dt) {
88
  if (unit.order && unit.order.type === "escort") {
89
  keepEscortStation(state, unit, def.speed, dt);
90
  } else if (unit.order && unit.order.type === "hold-formation") {
91
- // Same idea, but the "target" is the group's own fixed anchor rather than a moving ship β€”
92
- // a defensive formation holding its ground, still fully combat-reactive above (this only
93
- // runs once no threat was acquired/engaged this tick).
94
  keepFormationStation(state, unit, def.speed, dt);
 
 
 
 
95
  }
96
  }
97
 
 
15
 
16
  "use strict";
17
 
18
+ import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
19
  import { UNITS, BUILDINGS, upgradeMult } from "./entities.js";
20
  import { getEntity, removeEntity } from "./state.js";
21
  import { queryNeighbors } from "./grid.js";
 
28
  unit.attackTimer = Math.max(0, unit.attackTimer - dt);
29
 
30
  if (unit.order && unit.order.type === "move") {
31
+ const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
32
  if (arrived) unit.order = null;
33
  return;
34
  }
 
77
  }
78
 
79
  if (unit.order && unit.order.type === "attack-move") {
80
+ const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
81
  if (arrived) unit.order = null;
82
  return;
83
  }
 
88
  if (unit.order && unit.order.type === "escort") {
89
  keepEscortStation(state, unit, def.speed, dt);
90
  } else if (unit.order && unit.order.type === "hold-formation") {
91
+ // Same idea, but the "target" is this unit's OWN fixed anchor rather than a moving ship β€”
92
+ // a formation leader holding its ground, still fully combat-reactive above (this only runs
93
+ // once no threat was acquired/engaged this tick).
94
  keepFormationStation(state, unit, def.speed, dt);
95
+ } else if (unit.order && unit.order.type === "follow-leader") {
96
+ // A formation follower: keeps its slot relative to the leader's live position, same
97
+ // combat-reactive treatment as escort/hold-formation above.
98
+ keepFollowingLeader(state, unit, def.speed, dt);
99
  }
100
  }
101
 
engine/commands.js CHANGED
@@ -17,7 +17,14 @@ import { formationSlots } from "./formation.js";
17
  // fully idle unit acts immediately, so the first waypoint of a chain doesn't
18
  // sit inert. A plain command always wipes any queued waypoints, so it cancels a
19
  // chain cleanly. sim.js pulls the next queued order in as soon as `order` clears.
 
 
 
 
 
 
20
  function dispatch(unit, order, queue) {
 
21
  if (!unit.orderQueue) unit.orderQueue = [];
22
  unit.hold = false; // any positive order (move/attack/gather/waypoint) cancels a Hold stance
23
  if (queue && (unit.order || unit.orderQueue.length)) {
@@ -28,12 +35,116 @@ function dispatch(unit, order, queue) {
28
  }
29
  }
30
 
31
- // `formation` ({shape, leaderPos}, engine/formation.js) is optional β€” omitted, it's the
32
- // original flat grid spread (byte-identical to the old behaviour), unchanged for any caller
33
- // that doesn't pass one (the AI, and every pre-existing test).
34
- export function issueMove(units, x, y, queue = false, formation) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  const spots = formationSlots(units, x, y, formation);
36
- units.forEach((u, i) => dispatch(u, { type: "move", x: spots[i].x, y: spots[i].y }, queue));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
 
39
  export function issueGather(units, nodeId, queue = false) {
@@ -64,8 +175,9 @@ export function issueAttack(units, targetId, queue = false) {
64
  }
65
 
66
  export function issueAttackMove(units, x, y, queue = false, formation) {
67
- const spots = formationSlots(units, x, y, formation);
68
- units.forEach((u, i) => dispatch(u, { type: "attack-move", x: spots[i].x, y: spots[i].y }, queue));
 
69
  }
70
 
71
  // Escort a friendly ship: each unit takes a stable slot on a protective ring around the
@@ -79,25 +191,22 @@ export function issueEscort(units, targetId, queue = false) {
79
  }
80
 
81
  // Protection of a FORMATION, not one external ship: the group forms up in the chosen shape
82
- // right where it's standing (its own current centroid β€” see formationSlots) and holds there,
83
- // each unit re-seeking its own slot every tick (engine/movement.js keepFormationStation) so a
84
- // unit nudged out of place (avoidance jostling, a broken engagement) settles back rather than
85
- // drifting. Persists like escort β€” never cleared on arrival β€” until a new order replaces it.
86
- // Combat units also take the Hold stance (issueHold's existing "stand fast, don't chase" rule),
87
- // so the whole formation keeps its shape instead of scattering to run down a distant target;
88
- // non-combat units (workers, a Mender) still take a slot β€” sheltering behind/inside the line β€”
89
- // they just have no stance flag to set. Each unit's slot is baked into its OWN order as a fixed
90
- // offset from the anchor at issue time, so holding station never needs to re-scan the group.
91
  export function issueHoldFormation(units, shape = "grid", leaderPos = "front") {
92
  if (!units.length) return;
93
  let sx = 0, sy = 0;
94
  for (const u of units) { sx += u.x; sy += u.y; }
95
  const anchorX = sx / units.length, anchorY = sy / units.length;
96
- const slots = formationSlots(units, anchorX, anchorY, { shape, leaderPos, originX: anchorX, originY: anchorY });
97
- units.forEach((u, i) => {
98
- dispatch(u, { type: "hold-formation", anchorX, anchorY, offsetX: slots[i].x - anchorX, offsetY: slots[i].y - anchorY }, false);
99
- if (UNITS[u.type] && UNITS[u.type].role === "combat") u.hold = true;
100
- });
101
  }
102
 
103
  // Pays the cost up front, drops a constructing building on the spot, and
@@ -121,6 +230,7 @@ export function issueBuild(state, workerId, buildingType, x, y) {
121
  payCost(player.resources, def.cost);
122
  const building = makeBuilding(buildingType, worker.owner, x, y, { constructing: true });
123
  state.buildings.set(building.id, building);
 
124
  worker.order = { type: "build", buildingId: building.id };
125
  return building.id;
126
  }
@@ -136,8 +246,9 @@ export function issueAssistBuild(units, buildingId, queue = false) {
136
  // where it stands. A combat unit still defends itself (it re-auto-acquires an
137
  // enemy that wanders into range next tick), but it won't chase or resume a
138
  // path β€” the standard RTS "stop" that pulls a unit out of a move or a chain.
 
139
  export function issueStop(units) {
140
- units.forEach(u => { u.order = null; u.orderQueue = []; u.hold = false; });
141
  }
142
 
143
  // Hold position: a combat unit stands its ground and fires on anything that
@@ -146,7 +257,7 @@ export function issueStop(units) {
146
  // holding a line or a choke. Any move/attack order, or Stop, clears it.
147
  export function issueHold(units) {
148
  units.forEach(u => {
149
- if (UNITS[u.type] && UNITS[u.type].role === "combat") { u.order = null; u.orderQueue = []; u.hold = true; }
150
  });
151
  }
152
 
@@ -157,7 +268,7 @@ export function issueHold(units) {
157
  // a plain command β€” the mode is persistent, not something to stack behind.
158
  export function issueScout(units) {
159
  units.forEach(u => {
160
- if (UNITS[u.type] && UNITS[u.type].role === "scout") { u.order = { type: "scout" }; u.orderQueue = []; }
161
  });
162
  }
163
 
 
17
  // fully idle unit acts immediately, so the first waypoint of a chain doesn't
18
  // sit inert. A plain command always wipes any queued waypoints, so it cancels a
19
  // chain cleanly. sim.js pulls the next queued order in as soon as `order` clears.
20
+ //
21
+ // A DIRECT order (anything except the follow-leader order dispatchFormation itself hands out)
22
+ // releases the unit from whatever squad it was following β€” direct control always wins. The
23
+ // squad's leader is untouched by this (a leader never carries squadLeader itself), so giving
24
+ // the LEADER a fresh order here never disbands the squad; only a follower ordered on its own
25
+ // leaves it.
26
  function dispatch(unit, order, queue) {
27
+ if (order.type !== "follow-leader") setSquadLeader(unit, null);
28
  if (!unit.orderQueue) unit.orderQueue = [];
29
  unit.hold = false; // any positive order (move/attack/gather/waypoint) cancels a Hold stance
30
  if (queue && (unit.order || unit.orderQueue.length)) {
 
35
  }
36
  }
37
 
38
+ // Attach/detach `unit` to/from `leader`'s squad (or clear it with leader=null). Keeps the
39
+ // relationship bidirectional and consistent: leaving a DIFFERENT leader's squad always trims
40
+ // that leader's own squadFollowers list too, so a leader never keeps dragging along a unit that
41
+ // no longer considers it the leader. Transient, session-only state (never persisted β€” see
42
+ // persist.js's serPlanet, which strips both fields and drops a live follow-leader order
43
+ // entirely rather than trying to serialize the object reference it carries).
44
+ function setSquadLeader(unit, leader) {
45
+ const old = unit.squadLeader;
46
+ if (old && old !== leader && old.squadFollowers) {
47
+ const i = old.squadFollowers.indexOf(unit);
48
+ if (i >= 0) old.squadFollowers.splice(i, 1);
49
+ }
50
+ unit.squadLeader = leader || undefined;
51
+ }
52
+
53
+ // The shared machinery behind issueMove/issueAttackMove/issueHoldFormation: lay the group out
54
+ // in `formation`'s shape, give the LEADER (units[0] β€” see engine/formation.js) its own real
55
+ // order via `makeLeaderOrder(point)`, and give every OTHER unit a persistent "follow-leader"
56
+ // order that keeps it at a fixed offset from the leader's LIVE position from now on
57
+ // (engine/movement.js keepFollowingLeader) β€” not just for this one command. That's what makes
58
+ // "select the leader alone and move it" drag the rest of the formation along for free: a later
59
+ // solo command to the leader (the n===1 branch below) re-uses its EXISTING squadFollowers
60
+ // (pruned of anything that's died) purely to recompute the group's speed cap; it never touches
61
+ // the followers' own orders, because they're already perpetually chasing the leader regardless
62
+ // of what NEW order the leader receives.
63
+ //
64
+ // The whole squad's travel speed is capped to its SLOWEST member (order.speedCap, read by
65
+ // engine/movement.js's orderedSpeed) so a fast leader can't outrun its own formation β€” without
66
+ // this, followers would perpetually lag behind a leader moving at its own full speed, stretching
67
+ // the shape out instead of holding it.
68
+ function dispatchFormation(units, x, y, formation, queue, makeLeaderOrder) {
69
+ const leader = units[0];
70
+
71
+ // The leader/follow squad is a PLAYER selection-UX feature β€” there's no analogous "the unit
72
+ // you built a selection around" concept for the scripted AI (or a test double with no owner
73
+ // set at all), and the existing tactical AI (kiting, retreats, feint response, wave sizing…)
74
+ // reads every one of ITS units' own real move/attack-move order directly. So anything that
75
+ // isn't a confirmed player unit skips the whole leader/follower mechanic and just gets the
76
+ // plain per-unit spread this replaces β€” one real order per unit, exactly as before this
77
+ // feature existed. (Every player-issued command always passes real player.owner==="player"
78
+ // units β€” see input.js's selectedUnits().)
79
+ if (leader.owner !== "player") {
80
+ const spots = formationSlots(units, x, y, formation);
81
+ units.forEach((u, i) => dispatch(u, makeLeaderOrder(spots[i]), queue));
82
+ return;
83
+ }
84
+
85
+ if (units.length === 1) {
86
+ const order = makeLeaderOrder({ x, y });
87
+ if (leader.squadFollowers && leader.squadFollowers.length) {
88
+ leader.squadFollowers = leader.squadFollowers.filter(f => f.hp > 0);
89
+ const groupSpeed = [leader, ...leader.squadFollowers].reduce((m, u) => Math.min(m, UNITS[u.type]?.speed ?? Infinity), Infinity);
90
+ if (Number.isFinite(groupSpeed)) order.speedCap = groupSpeed;
91
+ }
92
+ dispatch(leader, order, queue);
93
+ return;
94
+ }
95
+
96
  const spots = formationSlots(units, x, y, formation);
97
+ const leaderSpot = spots[0];
98
+ const newFollowers = units.slice(1);
99
+
100
+ // Redefining the squad for exactly this unit set: anyone who WAS following this leader but
101
+ // isn't part of it this time is released β€” and actually stopped, not just unlinked, so a
102
+ // dropped unit doesn't silently keep chasing a leader that no longer lists it.
103
+ if (leader.squadFollowers) {
104
+ for (const old of leader.squadFollowers) {
105
+ if (!newFollowers.includes(old)) { setSquadLeader(old, null); old.order = null; }
106
+ }
107
+ }
108
+ leader.squadFollowers = newFollowers;
109
+
110
+ const groupSpeed = units.reduce((m, u) => Math.min(m, UNITS[u.type]?.speed ?? Infinity), Infinity);
111
+ const leaderOrder = makeLeaderOrder(leaderSpot);
112
+ if (Number.isFinite(groupSpeed)) leaderOrder.speedCap = groupSpeed;
113
+ dispatch(leader, leaderOrder, queue);
114
+
115
+ for (let i = 1; i < units.length; i++) {
116
+ setSquadLeader(units[i], leader);
117
+ dispatch(units[i], {
118
+ type: "follow-leader", leader,
119
+ offsetX: spots[i].x - leaderSpot.x, offsetY: spots[i].y - leaderSpot.y,
120
+ }, queue);
121
+ }
122
+ }
123
+
124
+ // A click-and-drag command's drag vector (formation.headingX/headingY) is also the direction
125
+ // the player wants the group to FACE β€” not just how the shape is oriented while travelling.
126
+ // Stamped onto every commanded unit as `facing` (read by renderShared.js's updateFacing once a
127
+ // unit stops moving, overriding the angle it would otherwise just freeze at) β€” the "give a
128
+ // formation, or a single/multiple selection, a direction to face" ask (e.g. holding a line
129
+ // facing the enemy). A PLAIN command (a click, no drag β€” no explicit heading) clears any
130
+ // earlier facing instead, so it never lingers stale once the player stops aiming their orders.
131
+ function applyFacing(units, formation) {
132
+ const hx = formation?.headingX || 0, hy = formation?.headingY || 0;
133
+ if (hx || hy) {
134
+ const angle = Math.atan2(hy, hx);
135
+ for (const u of units) u.facing = angle;
136
+ } else {
137
+ for (const u of units) delete u.facing;
138
+ }
139
+ }
140
+
141
+ // `formation` ({shape, leaderPos, headingX, headingY}, engine/formation.js) is optional β€”
142
+ // omitted, it's the original flat grid spread (byte-identical to the old behaviour), unchanged
143
+ // for any caller that doesn't pass one (the AI, and every pre-existing test).
144
+ export function issueMove(units, x, y, queue = false, formation) {
145
+ if (!units.length) return;
146
+ dispatchFormation(units, x, y, formation, queue, pt => ({ type: "move", x: pt.x, y: pt.y }));
147
+ applyFacing(units, formation);
148
  }
149
 
150
  export function issueGather(units, nodeId, queue = false) {
 
175
  }
176
 
177
  export function issueAttackMove(units, x, y, queue = false, formation) {
178
+ if (!units.length) return;
179
+ dispatchFormation(units, x, y, formation, queue, pt => ({ type: "attack-move", x: pt.x, y: pt.y }));
180
+ applyFacing(units, formation);
181
  }
182
 
183
  // Escort a friendly ship: each unit takes a stable slot on a protective ring around the
 
191
  }
192
 
193
  // Protection of a FORMATION, not one external ship: the group forms up in the chosen shape
194
+ // right where it's standing (its own current centroid) and holds there β€” the LEADER holds its
195
+ // own fixed anchor (engine/movement.js keepFormationStation), everyone else follows it there and
196
+ // keeps station relative to it (keepFollowingLeader) exactly like a hold-formation-flavoured
197
+ // move, so a later solo command to just the leader picks the whole formation back up too (see
198
+ // dispatchFormation). Combat units also take the Hold stance (issueHold's existing "stand fast,
199
+ // don't chase" rule) β€” LEADER AND FOLLOWERS alike β€” so the whole formation keeps its shape
200
+ // instead of scattering to run down a distant target; non-combat units (workers, a Mender) still
201
+ // take a slot β€” sheltering behind/inside the line β€” they just have no stance flag to set.
 
202
  export function issueHoldFormation(units, shape = "grid", leaderPos = "front") {
203
  if (!units.length) return;
204
  let sx = 0, sy = 0;
205
  for (const u of units) { sx += u.x; sy += u.y; }
206
  const anchorX = sx / units.length, anchorY = sy / units.length;
207
+ dispatchFormation(units, anchorX, anchorY, { shape, leaderPos, originX: anchorX, originY: anchorY }, false,
208
+ pt => ({ type: "hold-formation", anchorX: pt.x, anchorY: pt.y, offsetX: 0, offsetY: 0 }));
209
+ for (const u of units) if (UNITS[u.type] && UNITS[u.type].role === "combat") u.hold = true;
 
 
210
  }
211
 
212
  // Pays the cost up front, drops a constructing building on the spot, and
 
230
  payCost(player.resources, def.cost);
231
  const building = makeBuilding(buildingType, worker.owner, x, y, { constructing: true });
232
  state.buildings.set(building.id, building);
233
+ setSquadLeader(worker, null); // a direct order releases the worker from any squad it was following
234
  worker.order = { type: "build", buildingId: building.id };
235
  return building.id;
236
  }
 
246
  // where it stands. A combat unit still defends itself (it re-auto-acquires an
247
  // enemy that wanders into range next tick), but it won't chase or resume a
248
  // path β€” the standard RTS "stop" that pulls a unit out of a move or a chain.
249
+ // A direct order like this always releases the unit from a squad it was following.
250
  export function issueStop(units) {
251
+ units.forEach(u => { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = false; });
252
  }
253
 
254
  // Hold position: a combat unit stands its ground and fires on anything that
 
257
  // holding a line or a choke. Any move/attack order, or Stop, clears it.
258
  export function issueHold(units) {
259
  units.forEach(u => {
260
+ if (UNITS[u.type] && UNITS[u.type].role === "combat") { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = true; }
261
  });
262
  }
263
 
 
268
  // a plain command β€” the mode is persistent, not something to stack behind.
269
  export function issueScout(units) {
270
  units.forEach(u => {
271
+ if (UNITS[u.type] && UNITS[u.type].role === "scout") { setSquadLeader(u, null); u.order = { type: "scout" }; u.orderQueue = []; }
272
  });
273
  }
274
 
engine/formation.js CHANGED
@@ -18,20 +18,28 @@
18
  explicitly asked for), or takes a point ON the ring at the
19
  front/back with everyone else filling the rest evenly
20
 
21
- The "leader" is deterministic, not selection-order-dependent: the unit
22
- with the most maxHp (a bigger hull naturally leads), ties broken by id β€”
23
- see pickLeader.
 
 
 
 
 
 
24
 
25
  NESTED formations: a selection that's ALREADY spread into distinct spatial
26
  clusters (e.g. two armies on opposite sides of the map, box-selected
27
  together) is laid out as a formation OF formations β€” clusterUnits splits
28
- it, the SAME shape arranges the clusters' centres relative to each other
29
- (a wedge of wedges, a line of lines, …), spaced by each cluster's own
30
- footprint so sub-formations can't overlap, and each cluster is then laid
31
- out again internally around its assigned centre. A single blob of units β€”
32
- the ordinary case, everyone selected in one place β€” collapses to exactly
33
- ONE cluster, so nesting only ever engages when the selection already
34
- calls for it.
 
 
35
 
36
  Spacing is derived from the group's own hull sizes (spacingFor), not a
37
  flat constant, EXCEPT the plain single-group "grid" path, which keeps the
@@ -69,17 +77,11 @@ function centroid(units) {
69
  return { x: sx / units.length, y: sy / units.length };
70
  }
71
 
72
- // The deterministic "flagship": the unit with the most maxHp (ties broken by id, never by
73
- // selection/array order β€” the same pick must come out of the same group regardless of how the
74
- // player happened to box-select it).
75
  /** @param {Unit[]} units @returns {Unit} */
76
  export function pickLeader(units) {
77
- let best = units[0];
78
- for (const u of units) {
79
- const bhp = best.maxHp ?? 0, uhp = u.maxHp ?? 0;
80
- if (uhp > bhp || (uhp === bhp && u.id < best.id)) best = u;
81
- }
82
- return best;
83
  }
84
 
85
  // Radius-aware baseline spacing for a group: room for the two largest hulls present to clear
@@ -96,6 +98,11 @@ function spacingFor(units) {
96
  // other (transitively) land in the same cluster β€” a plain union-find pass over CURRENT
97
  // positions. A single blob of units (the common case) collapses to one cluster. O(n^2) on the
98
  // selection size, which is issue-time work on a small array, not a per-tick cost.
 
 
 
 
 
99
  /** @param {Unit[]} units @returns {Unit[][]} */
100
  export function clusterUnits(units) {
101
  const n = units.length;
@@ -186,12 +193,13 @@ function shapeOffsets(n, shape, leaderPos, spacing) {
186
  return centerOffsets(raw);
187
  }
188
 
189
- // Lay out `members` (units OR cluster-arrays β€” anything `weightOf`/`idOf`/`spacingOf` can read)
190
- // into `shape`, oriented by (headingX,headingY), centred on (cx,cy). Returns points in the SAME
191
- // order as `members`. `shape==="grid"` ignores heading entirely (world-axis-aligned, exactly the
192
- // legacy behaviour); every other shape orients its forward axis along the heading (or a stable
193
- // default when the group isn't actually travelling β€” e.g. re-forming in place).
194
- function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf, weightOf, idOf) {
 
195
  const n = members.length;
196
  if (n === 1) return [{ x: cx, y: cy }];
197
  const spacing = spacingOf(members);
@@ -208,33 +216,28 @@ function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf
208
  const hlen = Math.hypot(hx, hy);
209
  if (hlen < 1e-6) { hx = 1; hy = 0; } else { hx /= hlen; hy /= hlen; }
210
 
211
- // Leader-first slot order: which member sits in raw[0] (the shape's leader treatment) β€” most
212
- // weight wins, ties by id β€” same rule as pickLeader, generalised to non-Unit members (clusters).
213
- const order = members.map((_, i) => i).sort((ia, ib) => {
214
- const wa = weightOf(members[ia]), wb = weightOf(members[ib]);
215
- if (wa !== wb) return wb - wa;
216
- const a = idOf(members[ia]), b = idOf(members[ib]);
217
- return a < b ? -1 : (a > b ? 1 : 0);
218
- });
219
-
220
  const raw = shapeOffsets(n, shape, leaderPos, spacing);
221
- const out = new Array(n);
222
- order.forEach((memberIdx, slot) => {
223
- const { forward, right } = raw[slot];
224
- out[memberIdx] = { x: cx + forward * hx + right * hy, y: cy + forward * hy - right * hx };
225
  });
226
- return out;
227
  }
228
 
229
  /**
230
  * Where each unit in `units` should stand for a move to (destX,destY) β€” or, for a stationary
231
  * hold, where the group's own current centroid already is (pass it as both the destination AND
232
- * `originX`/`originY`, or simply omit `originX`/`originY` and let it default there). Returns
233
- * one {x,y} per unit, in the SAME order as `units`.
 
 
 
 
 
 
234
  * @param {Unit[]} units
235
  * @param {number} destX
236
  * @param {number} destY
237
- * @param {{shape?:string, leaderPos?:string, originX?:number, originY?:number}} [opts]
238
  * @returns {{x:number, y:number}[]}
239
  */
240
  export function formationSlots(units, destX, destY, opts = {}) {
@@ -243,19 +246,22 @@ export function formationSlots(units, destX, destY, opts = {}) {
243
  const { shape = "grid", leaderPos = "front" } = opts || {};
244
  if (n === 1) return [{ x: destX, y: destY }];
245
 
246
- let { originX, originY } = opts || {};
247
- if (!Number.isFinite(originX) || !Number.isFinite(originY)) {
248
- const c = centroid(units);
249
- originX = c.x; originY = c.y;
 
 
 
 
250
  }
251
- const headingX = destX - originX, headingY = destY - originY;
252
 
253
  const clusters = clusterUnits(units);
254
  if (clusters.length <= 1) {
255
  // The one path that must stay byte-identical to the old flat grid spread: no shape chosen,
256
  // one blob of units β€” the legacy formationSpots' exact spacing constant, untouched.
257
  const spacingOf = shape === "grid" ? () => GRID_SPACING : spacingFor;
258
- return layout(units, destX, destY, headingX, headingY, shape, leaderPos, spacingOf, u => u.maxHp ?? 0, u => u.id);
259
  }
260
 
261
  // Nested: a formation OF formations. Each cluster is one outer slot, arranged by the SAME
@@ -263,17 +269,13 @@ export function formationSlots(units, destX, destY, opts = {}) {
263
  // footprint so sub-formations can't overlap ("sufficient spacing… in formation of formations").
264
  const footprints = clusters.map(c => Math.max(1, Math.ceil(Math.sqrt(c.length))) * spacingFor(c));
265
  const outerSpacing = Math.max(...footprints) * NEST_GAP_MULT;
266
- const clusterWeight = c => c.reduce((s, u) => s + (u.maxHp ?? 0), 0);
267
- const centers = layout(
268
- clusters, destX, destY, headingX, headingY, shape, leaderPos,
269
- () => outerSpacing, clusterWeight, c => c[0].id,
270
- );
271
 
272
  const placed = new Map();
273
  clusters.forEach((cluster, i) => {
274
  const { x: ccx, y: ccy } = centers[i];
275
  const cc = centroid(cluster);
276
- const inner = layout(cluster, ccx, ccy, ccx - cc.x, ccy - cc.y, shape, leaderPos, spacingFor, u => u.maxHp ?? 0, u => u.id);
277
  cluster.forEach((u, k) => placed.set(u, inner[k]));
278
  });
279
  return units.map(u => placed.get(u));
 
18
  explicitly asked for), or takes a point ON the ring at the
19
  front/back with everyone else filling the rest evenly
20
 
21
+ The "leader" is whichever unit is FIRST in the `units` array β€” i.e.
22
+ whichever unit the player selected first and added the rest to (the
23
+ normal box-select-additive selection order preserves this: an existing
24
+ selection is kept first, newly-added picks are appended after β€” see
25
+ input.js's applyBoxSelection). Deliberately NOT a stat-based pick (e.g.
26
+ highest maxHp): the player chooses their leader by building the
27
+ selection around it, the same unit engine/commands.js then keeps giving
28
+ its own real order to while everyone else follows it (issueHoldFormation
29
+ / follow-leader β€” see commands.js and movement.js).
30
 
31
  NESTED formations: a selection that's ALREADY spread into distinct spatial
32
  clusters (e.g. two armies on opposite sides of the map, box-selected
33
  together) is laid out as a formation OF formations β€” clusterUnits splits
34
+ it (preserving each unit's relative position in the original array, so
35
+ the cluster CONTAINING the overall leader is always clusters[0] and thus
36
+ still gets the outer leader slot), the SAME shape arranges the clusters'
37
+ centres relative to each other (a wedge of wedges, a line of lines, …),
38
+ spaced by each cluster's own footprint so sub-formations can't overlap,
39
+ and each cluster is then laid out again internally around its assigned
40
+ centre. A single blob of units β€” the ordinary case, everyone selected in
41
+ one place β€” collapses to exactly ONE cluster, so nesting only ever
42
+ engages when the selection already calls for it.
43
 
44
  Spacing is derived from the group's own hull sizes (spacingFor), not a
45
  flat constant, EXCEPT the plain single-group "grid" path, which keeps the
 
77
  return { x: sx / units.length, y: sy / units.length };
78
  }
79
 
80
+ // The leader is simply whichever unit is first in the given array β€” see the file header. A
81
+ // one-line function so callers (engine/commands.js) don't have to know/duplicate the rule.
 
82
  /** @param {Unit[]} units @returns {Unit} */
83
  export function pickLeader(units) {
84
+ return units[0];
 
 
 
 
 
85
  }
86
 
87
  // Radius-aware baseline spacing for a group: room for the two largest hulls present to clear
 
98
  // other (transitively) land in the same cluster β€” a plain union-find pass over CURRENT
99
  // positions. A single blob of units (the common case) collapses to one cluster. O(n^2) on the
100
  // selection size, which is issue-time work on a small array, not a per-tick cost.
101
+ //
102
+ // Preserves each unit's relative order from the input array within its cluster, AND across
103
+ // clusters (the cluster containing units[0] is always clusters[0]) β€” both `layout` calls below
104
+ // rely on member[0] being the leader, so this ordering guarantee is what keeps the overall
105
+ // leader's own cluster the "leader cluster" of the outer formation too.
106
  /** @param {Unit[]} units @returns {Unit[][]} */
107
  export function clusterUnits(units) {
108
  const n = units.length;
 
193
  return centerOffsets(raw);
194
  }
195
 
196
+ // Lay out `members` (units OR cluster-arrays) into `shape`, oriented by (headingX,headingY),
197
+ // centred on (cx,cy). Returns points in the SAME order as `members` β€” member[0] (the leader,
198
+ // see the file header) always takes the shape's own leader slot (raw[0]). `shape==="grid"`
199
+ // ignores heading entirely (world-axis-aligned, exactly the legacy behaviour); every other
200
+ // shape orients its forward axis along the heading (or a stable default when the group isn't
201
+ // actually travelling β€” e.g. re-forming in place).
202
+ function layout(members, cx, cy, headingX, headingY, shape, leaderPos, spacingOf) {
203
  const n = members.length;
204
  if (n === 1) return [{ x: cx, y: cy }];
205
  const spacing = spacingOf(members);
 
216
  const hlen = Math.hypot(hx, hy);
217
  if (hlen < 1e-6) { hx = 1; hy = 0; } else { hx /= hlen; hy /= hlen; }
218
 
 
 
 
 
 
 
 
 
 
219
  const raw = shapeOffsets(n, shape, leaderPos, spacing);
220
+ return members.map((_, i) => {
221
+ const { forward, right } = raw[i];
222
+ return { x: cx + forward * hx + right * hy, y: cy + forward * hy - right * hx };
 
223
  });
 
224
  }
225
 
226
  /**
227
  * Where each unit in `units` should stand for a move to (destX,destY) β€” or, for a stationary
228
  * hold, where the group's own current centroid already is (pass it as both the destination AND
229
+ * `originX`/`originY`, or simply omit `originX`/`originY` and let it default there). `units[0]`
230
+ * is always the leader (see the file header) and always lands on the shape's own leader slot.
231
+ * Returns one {x,y} per unit, in the SAME order as `units`.
232
+ *
233
+ * The heading (which way the shape faces) is normally derived from `origin -> dest`, but a
234
+ * player can instead set it explicitly β€” e.g. a click-and-drag command, where the drag vector
235
+ * IS the desired facing regardless of how far the destination actually is β€” via `headingX`/
236
+ * `headingY`; either non-zero overrides the origin-derived heading entirely.
237
  * @param {Unit[]} units
238
  * @param {number} destX
239
  * @param {number} destY
240
+ * @param {{shape?:string, leaderPos?:string, originX?:number, originY?:number, headingX?:number, headingY?:number}} [opts]
241
  * @returns {{x:number, y:number}[]}
242
  */
243
  export function formationSlots(units, destX, destY, opts = {}) {
 
246
  const { shape = "grid", leaderPos = "front" } = opts || {};
247
  if (n === 1) return [{ x: destX, y: destY }];
248
 
249
+ let headingX = opts?.headingX, headingY = opts?.headingY;
250
+ if (!((headingX || 0) || (headingY || 0))) {
251
+ let { originX, originY } = opts || {};
252
+ if (!Number.isFinite(originX) || !Number.isFinite(originY)) {
253
+ const c = centroid(units);
254
+ originX = c.x; originY = c.y;
255
+ }
256
+ headingX = destX - originX; headingY = destY - originY;
257
  }
 
258
 
259
  const clusters = clusterUnits(units);
260
  if (clusters.length <= 1) {
261
  // The one path that must stay byte-identical to the old flat grid spread: no shape chosen,
262
  // one blob of units β€” the legacy formationSpots' exact spacing constant, untouched.
263
  const spacingOf = shape === "grid" ? () => GRID_SPACING : spacingFor;
264
+ return layout(units, destX, destY, headingX, headingY, shape, leaderPos, spacingOf);
265
  }
266
 
267
  // Nested: a formation OF formations. Each cluster is one outer slot, arranged by the SAME
 
269
  // footprint so sub-formations can't overlap ("sufficient spacing… in formation of formations").
270
  const footprints = clusters.map(c => Math.max(1, Math.ceil(Math.sqrt(c.length))) * spacingFor(c));
271
  const outerSpacing = Math.max(...footprints) * NEST_GAP_MULT;
272
+ const centers = layout(clusters, destX, destY, headingX, headingY, shape, leaderPos, () => outerSpacing);
 
 
 
 
273
 
274
  const placed = new Map();
275
  clusters.forEach((cluster, i) => {
276
  const { x: ccx, y: ccy } = centers[i];
277
  const cc = centroid(cluster);
278
+ const inner = layout(cluster, ccx, ccy, ccx - cc.x, ccy - cc.y, shape, leaderPos, spacingFor);
279
  cluster.forEach((u, k) => placed.set(u, inner[k]));
280
  });
281
  return units.map(u => placed.get(u));
engine/movement.js CHANGED
@@ -70,16 +70,48 @@ export function keepEscortStation(state, unit, speed, dt) {
70
  return true;
71
  }
72
 
73
- // Advance a unit holding a formation station (order {type:"hold-formation", anchorX, anchorY,
74
- // offsetX, offsetY} β€” engine/commands.js issueHoldFormation) one tick toward its assigned slot,
75
- // a fixed offset from the anchor baked in at issue time. Unlike escort's ring (which re-seeks a
76
- // MOVING external target every tick), the anchor here never moves β€” that's the point: this is a
77
- // group defending its own position, not following anyone. So there's nothing that can "vanish"
78
- // and drop the order the way an escort's target can; it persists until a new order replaces it.
 
79
  /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt */
80
  export function keepFormationStation(state, unit, speed, dt) {
81
  const o = unit.order;
82
- stepToward(state, unit, o.anchorX + o.offsetX, o.anchorY + o.offsetY, speed, dt);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  // Moves `unit` at most `speed * dt` toward (tx, ty). Returns true once it
 
70
  return true;
71
  }
72
 
73
+ // Advance a formation LEADER holding station (order {type:"hold-formation", anchorX, anchorY,
74
+ // offsetX, offsetY} β€” engine/commands.js issueHoldFormation) one tick toward its own fixed
75
+ // anchor point. Unlike escort's ring (which re-seeks a MOVING external target every tick), the
76
+ // anchor here never moves β€” that's the point: this is a unit (or a formation's leader β€” see
77
+ // keepFollowingLeader below for everyone else in the group) defending its own position, not
78
+ // following anyone. Nothing can "vanish" and drop the order the way an escort's target can; it
79
+ // persists until a new order replaces it.
80
  /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt */
81
  export function keepFormationStation(state, unit, speed, dt) {
82
  const o = unit.order;
83
+ stepToward(state, unit, o.anchorX + o.offsetX, o.anchorY + o.offsetY, orderedSpeed(speed, o), dt);
84
+ }
85
+
86
+ // Advance a formation FOLLOWER (order {type:"follow-leader", leader, offsetX, offsetY} β€”
87
+ // engine/commands.js dispatchFormation) one tick toward a fixed offset from its leader's LIVE
88
+ // position β€” re-seeked every tick, exactly like escort's ring around its guarded ship, except
89
+ // the "target" is another player unit that itself is under direct orders (move/attack-move/
90
+ // hold-formation) rather than just standing still. This is what makes "select the leader alone
91
+ // and move it" drag the rest of the formation along for free: the leader gets a fresh order,
92
+ // the followers were never re-ordered at all β€” they're still chasing `leader.pos + offset`
93
+ // every tick, whatever that position now is. Drops the order (returns to independent control)
94
+ // the moment the leader dies, the same as an escort losing its guarded ship.
95
+ /** @param {State} state @param {Unit} unit @param {number} speed @param {number} dt @returns {boolean} */
96
+ export function keepFollowingLeader(state, unit, speed, dt) {
97
+ const o = unit.order;
98
+ const leader = o.leader;
99
+ if (!leader || leader.hp <= 0) { unit.order = null; return false; }
100
+ stepToward(state, unit, leader.x + o.offsetX, leader.y + o.offsetY, speed, dt);
101
+ return true;
102
+ }
103
+
104
+ // A formation's overall pace is capped to its SLOWEST member (order.speedCap, stamped by
105
+ // engine/commands.js's dispatchFormation) so a fast leader can't outrun the group it's
106
+ // supposedly leading β€” without this, followers (who each move at their OWN top speed toward the
107
+ // leader's live position) would perpetually lag behind while the leader is in motion, stretching
108
+ // the shape out instead of holding it. Followers never need this themselves: every follower's own
109
+ // speed is, by construction, at least the group minimum, so they can always keep pace with a
110
+ // leader that's capped to it. Identity (returns `speed` unchanged) for any order without a cap β€”
111
+ // every order type except move/attack-move/hold-formation, and those two before this feature.
112
+ /** @param {number} speed @param {Order} order @returns {number} */
113
+ export function orderedSpeed(speed, order) {
114
+ return Number.isFinite(order?.speedCap) ? Math.min(speed, order.speedCap) : speed;
115
  }
116
 
117
  // Moves `unit` at most `speed * dt` toward (tx, ty). Returns true once it
engine/persist.js CHANGED
@@ -159,6 +159,11 @@ function cleanEntity(e, def, map) {
159
  // value can't win the "most recently used pad" tie-break it's only ever compared with. Only
160
  // present once a jump has actually landed there, so this only fires when the field exists.
161
  if (e.type === "spaceport" && e.lastLanding !== undefined) e.lastLanding = Math.max(0, num(e.lastLanding, 0));
 
 
 
 
 
162
  // A producer's output buffer (building.store) and a factory's input buffer (building.input) are
163
  // untrusted save data β€” a hand-edited file could smuggle in a bogus commodity, a negative/NaN qty,
164
  // or an over-capacity buffer. Keep only real commodities with a positive qty and clamp each buffer's
@@ -282,7 +287,16 @@ function serPlanet(state) {
282
  // `_gi` is the grid broad-phase index β€” a transient stamped fresh onto every unit each tick
283
  // by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
284
  // per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
285
- units: [...state.units.values()].map(({ _gi, repairTargetId, ...u }) => u), // both transient (grid index; live repair pick)
 
 
 
 
 
 
 
 
 
286
  // `haulers`/`servers` (logistics tallies, engine/haul.js), `powered`/`fuel` (Generator fuel
287
  // state, engine/industry.js), `menderClaims` (auto-repair Mender tally, engine/sim.js) and
288
  // `lastYield`/`lastTier` (a Plasma Rig's last-strike HUD readout, engine/rig.js β€” regenerated on
 
159
  // value can't win the "most recently used pad" tie-break it's only ever compared with. Only
160
  // present once a jump has actually landed there, so this only fires when the field exists.
161
  if (e.type === "spaceport" && e.lastLanding !== undefined) e.lastLanding = Math.max(0, num(e.lastLanding, 0));
162
+ // A unit's player-set `facing` (engine/commands.js applyFacing) is untrusted too β€” a bogus
163
+ // non-numeric value would flow straight into Math.cos/sin (renderShared.js) as NaN, silently
164
+ // mis-drawing (never crashing) the sprite. Coerce it to a finite number, or drop the field
165
+ // entirely so the unit just falls back to its normal movement-inferred facing.
166
+ if (e.facing !== undefined) { const f = num(e.facing, NaN); if (Number.isFinite(f)) e.facing = f; else delete e.facing; }
167
  // A producer's output buffer (building.store) and a factory's input buffer (building.input) are
168
  // untrusted save data β€” a hand-edited file could smuggle in a bogus commodity, a negative/NaN qty,
169
  // or an over-capacity buffer. Keep only real commodities with a positive qty and clamp each buffer's
 
287
  // `_gi` is the grid broad-phase index β€” a transient stamped fresh onto every unit each tick
288
  // by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
289
  // per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
290
+ // `squadLeader`/`squadFollowers` (engine/commands.js) are live UNIT OBJECT REFERENCES, not
291
+ // ids β€” session-only, transient bookkeeping like the rest of this denylist, but they ALSO
292
+ // can't survive JSON.stringify at all: a follower points at its leader, the leader's own
293
+ // squadFollowers list points right back, and JSON.stringify throws on a circular structure
294
+ // rather than silently truncating it β€” a live squad would crash every save. Strip both
295
+ // fields, and null out a live `follow-leader` order too (it embeds the same leader
296
+ // reference) β€” a reload simply drops squad membership, the same "recomputed/reset, never
297
+ // persisted" treatment repairTargetId already gets.
298
+ units: [...state.units.values()].map(({ _gi, repairTargetId, squadLeader, squadFollowers, ...u }) =>
299
+ u.order && u.order.type === "follow-leader" ? { ...u, order: null } : u),
300
  // `haulers`/`servers` (logistics tallies, engine/haul.js), `powered`/`fuel` (Generator fuel
301
  // state, engine/industry.js), `menderClaims` (auto-repair Mender tally, engine/sim.js) and
302
  // `lastYield`/`lastTier` (a Plasma Rig's last-strike HUD readout, engine/rig.js β€” regenerated on
engine/sim.js CHANGED
@@ -6,7 +6,7 @@
6
 
7
  "use strict";
8
 
9
- import { stepToward, keepEscortStation, keepFormationStation } from "./movement.js";
10
  import { buildUnitGrid } from "./grid.js";
11
  import { updateGather } from "./gather.js";
12
  import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "./haul.js";
@@ -160,7 +160,7 @@ function updateUnit(state, unit, dt) {
160
  if (!unit.order) return;
161
  switch (unit.order.type) {
162
  case "move": {
163
- const arrived = stepToward(state, unit, unit.order.x, unit.order.y, def.speed, dt);
164
  if (arrived) unit.order = null;
165
  break;
166
  }
@@ -178,9 +178,13 @@ function updateUnit(state, unit, dt) {
178
  keepEscortStation(state, unit, def.speed, dt);
179
  break;
180
  case "hold-formation":
181
- // A non-combat unit (worker, freighter) sheltering in a formation just keeps its slot.
182
  keepFormationStation(state, unit, def.speed, dt);
183
  break;
 
 
 
 
184
  case "scout":
185
  updateScoutMode(state, unit, dt);
186
  break;
@@ -235,23 +239,28 @@ function updateSupport(state, unit, def, dt) {
235
  keepEscortStation(state, unit, def.speed, dt);
236
  return;
237
  }
238
- // Holding a defensive formation: same idea, station-keeping at a fixed slot instead of a
239
- // moving target β€” the drone mends whatever's near it (the repair pass) right where it stands.
240
  if (o.type === "hold-formation") {
241
  keepFormationStation(state, unit, def.speed, dt);
242
  return;
243
  }
244
- let tx, ty, follow = false;
 
 
 
 
 
245
  if (o.type === "attack") {
246
  const t = getEntity(state, o.targetId);
247
  if (!t || t.hp <= 0) { unit.order = null; return; }
248
  tx = t.x; ty = t.y; follow = true; // keep chasing a moving foe rather than stopping on arrival
249
  } else if (o.type === "move" || o.type === "attack-move") {
250
- tx = o.x; ty = o.y;
251
  } else {
252
  unit.order = null; return; // gather / scout / build are meaningless for a drone
253
  }
254
- const arrived = stepToward(state, unit, tx, ty, def.speed, dt);
255
  if (arrived && !follow) unit.order = null;
256
  }
257
 
 
6
 
7
  "use strict";
8
 
9
+ import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
10
  import { buildUnitGrid } from "./grid.js";
11
  import { updateGather } from "./gather.js";
12
  import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "./haul.js";
 
160
  if (!unit.order) return;
161
  switch (unit.order.type) {
162
  case "move": {
163
+ const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
164
  if (arrived) unit.order = null;
165
  break;
166
  }
 
178
  keepEscortStation(state, unit, def.speed, dt);
179
  break;
180
  case "hold-formation":
181
+ // A non-combat formation LEADER (worker, freighter) holds its own fixed anchor.
182
  keepFormationStation(state, unit, def.speed, dt);
183
  break;
184
+ case "follow-leader":
185
+ // A non-combat formation FOLLOWER just keeps its slot relative to the leader.
186
+ keepFollowingLeader(state, unit, def.speed, dt);
187
+ break;
188
  case "scout":
189
  updateScoutMode(state, unit, dt);
190
  break;
 
239
  keepEscortStation(state, unit, def.speed, dt);
240
  return;
241
  }
242
+ // Holding a defensive formation: same idea, station-keeping at its own fixed anchor instead
243
+ // of a moving target β€” the drone mends whatever's near it (the repair pass) right where it stands.
244
  if (o.type === "hold-formation") {
245
  keepFormationStation(state, unit, def.speed, dt);
246
  return;
247
  }
248
+ // Following a formation leader: keeps its slot relative to the leader's LIVE position.
249
+ if (o.type === "follow-leader") {
250
+ keepFollowingLeader(state, unit, def.speed, dt);
251
+ return;
252
+ }
253
+ let tx, ty, follow = false, speed = def.speed;
254
  if (o.type === "attack") {
255
  const t = getEntity(state, o.targetId);
256
  if (!t || t.hp <= 0) { unit.order = null; return; }
257
  tx = t.x; ty = t.y; follow = true; // keep chasing a moving foe rather than stopping on arrival
258
  } else if (o.type === "move" || o.type === "attack-move") {
259
+ tx = o.x; ty = o.y; speed = orderedSpeed(def.speed, o);
260
  } else {
261
  unit.order = null; return; // gather / scout / build are meaningless for a drone
262
  }
263
+ const arrived = stepToward(state, unit, tx, ty, speed, dt);
264
  if (arrived && !follow) unit.order = null;
265
  }
266
 
engine/types.js CHANGED
@@ -35,7 +35,7 @@
35
 
36
  /**
37
  * A unit order. `type` is always present; the rest depend on the order kind
38
- * (move/gather/attack/attack-move/build/escort/scout/hold-formation).
39
  * @typedef {Object} Order
40
  * @property {string} type
41
  * @property {number} [x]
@@ -48,10 +48,12 @@
48
  * @property {number} [slots]
49
  * @property {string} [phase]
50
  * @property {boolean} [manual] a player-assigned service order sticks to its building (engine/haul.js)
51
- * @property {number} [anchorX] hold-formation: the formation's fixed anchor point (engine/commands.js issueHoldFormation)
52
  * @property {number} [anchorY]
53
- * @property {number} [offsetX] hold-formation: this unit's fixed offset from the anchor (engine/formation.js)
54
  * @property {number} [offsetY]
 
 
55
  */
56
 
57
  /**
@@ -94,6 +96,9 @@
94
  * @property {string|null} [targetId] aim target (combat.js / render.js)
95
  * @property {Object.<string, number>} [freight] a freighter's player-managed cargo hold, commodity β†’ qty (engine/galaxy.js)
96
  * @property {boolean} [armed] a Helium Bomb set to detonate on attack/enemy presence/command (engine/bomb.js)
 
 
 
97
  */
98
 
99
  /**
 
35
 
36
  /**
37
  * A unit order. `type` is always present; the rest depend on the order kind
38
+ * (move/gather/attack/attack-move/build/escort/scout/hold-formation/follow-leader).
39
  * @typedef {Object} Order
40
  * @property {string} type
41
  * @property {number} [x]
 
48
  * @property {number} [slots]
49
  * @property {string} [phase]
50
  * @property {boolean} [manual] a player-assigned service order sticks to its building (engine/haul.js)
51
+ * @property {number} [anchorX] hold-formation: this unit's own fixed anchor point (engine/commands.js issueHoldFormation)
52
  * @property {number} [anchorY]
53
+ * @property {number} [offsetX] hold-formation/follow-leader: this unit's fixed offset from the anchor/leader (engine/formation.js)
54
  * @property {number} [offsetY]
55
+ * @property {Unit} [leader] follow-leader: the formation leader this unit is chasing (engine/movement.js keepFollowingLeader) β€” a live object reference, NEVER persisted (engine/persist.js drops a follow-leader order entirely on save)
56
+ * @property {number} [speedCap] move/attack-move/hold-formation: a formation leader's travel speed, capped to its slowest member (engine/movement.js orderedSpeed)
57
  */
58
 
59
  /**
 
96
  * @property {string|null} [targetId] aim target (combat.js / render.js)
97
  * @property {Object.<string, number>} [freight] a freighter's player-managed cargo hold, commodity β†’ qty (engine/galaxy.js)
98
  * @property {boolean} [armed] a Helium Bomb set to detonate on attack/enemy presence/command (engine/bomb.js)
99
+ * @property {Unit} [squadLeader] transient, NEVER persisted: the leader this unit is following, if any (engine/commands.js setSquadLeader) β€” a live object reference
100
+ * @property {Unit[]} [squadFollowers] transient, NEVER persisted: the units following THIS unit as their leader (engine/commands.js dispatchFormation)
101
+ * @property {number} [facing] a player-set facing angle (radians), from a click-and-drag move/attack-move (engine/commands.js applyFacing) β€” overrides the movement-inferred angle a STATIONARY unit would otherwise freeze at (renderShared.js updateFacing); a plain (non-drag) move/attack-move clears it, so it never lingers stale after a later un-aimed order
102
  */
103
 
104
  /**
hudSelection.js CHANGED
@@ -1122,6 +1122,11 @@ function rebuildSelectionPanel(sel) {
1122
  head.textContent = "Formation";
1123
  panelEl.appendChild(head);
1124
 
 
 
 
 
 
1125
  const shapeRow = document.createElement("div");
1126
  shapeRow.className = "formation-row";
1127
  for (const shape of FORMATION_SHAPES) {
@@ -1206,7 +1211,7 @@ function rebuildSelectionPanel(sel) {
1206
  const hint = document.createElement("p");
1207
  hint.className = "hint";
1208
  hint.textContent = touch ? "Tap the map to move. Tap a foe to attack. Attack-Move advances and engages."
1209
- : "A + click to attack-move. Right-click moves (ignores enemies). Ctrl+right-click queues a waypoint.";
1210
  panelEl.appendChild(hint);
1211
  } else if (sel.length === 1 && (cc || barracks)) {
1212
  const hint = document.createElement("p");
@@ -1241,6 +1246,7 @@ function controlsLegend() {
1241
  ] : [
1242
  ["Left-drag", "select Β· Ctrl+drag adds"],
1243
  ["Right-click", "move / attack / gather"],
 
1244
  ["A + click", "attack-move"],
1245
  ["Ctrl+right", "queue a waypoint"],
1246
  ["Shift+1–9", "set group Β· 1–9 recall"],
 
1122
  head.textContent = "Formation";
1123
  panelEl.appendChild(head);
1124
 
1125
+ const leaderHint = document.createElement("p");
1126
+ leaderHint.className = "hint";
1127
+ leaderHint.textContent = "The first unit you selected leads β€” the rest follow it, even on a later solo move.";
1128
+ panelEl.appendChild(leaderHint);
1129
+
1130
  const shapeRow = document.createElement("div");
1131
  shapeRow.className = "formation-row";
1132
  for (const shape of FORMATION_SHAPES) {
 
1211
  const hint = document.createElement("p");
1212
  hint.className = "hint";
1213
  hint.textContent = touch ? "Tap the map to move. Tap a foe to attack. Attack-Move advances and engages."
1214
+ : "A + click to attack-move. Right-click moves (ignores enemies). Right-drag also sets a facing. Ctrl+right-click queues a waypoint.";
1215
  panelEl.appendChild(hint);
1216
  } else if (sel.length === 1 && (cc || barracks)) {
1217
  const hint = document.createElement("p");
 
1246
  ] : [
1247
  ["Left-drag", "select Β· Ctrl+drag adds"],
1248
  ["Right-click", "move / attack / gather"],
1249
+ ["Right-drag", "…and face that direction"],
1250
  ["A + click", "attack-move"],
1251
  ["Ctrl+right", "queue a waypoint"],
1252
  ["Shift+1–9", "set group Β· 1–9 recall"],
input.js CHANGED
@@ -38,6 +38,7 @@ export function attachInput(canvas, state, onChange) {
38
  const heldKeys = new Set();
39
 
40
  let dragBox = null;
 
41
  let buildMode = null;
42
  let attackMoveArmed = false; // set by the A key; the next left-click issues an attack-move
43
  let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
@@ -86,17 +87,25 @@ export function attachInput(canvas, state, onChange) {
86
 
87
  // The player's current formation choice (session.js), read fresh on every order β€” so
88
  // switching shapes in the HUD mid-game takes effect on the very next command issued.
89
- function currentFormation() {
90
- return { shape: game.formation.shape, leaderPos: game.formation.leaderPos };
 
 
 
 
 
 
 
 
91
  }
92
 
93
  // Attack-move to (x,y): combat units advance-and-engage anything met on the
94
  // way; non-combat units (workers, the Ranger) can't attack-move, so they just
95
  // move. Same split the Ctrl-queue and minimap-command paths use.
96
- function aggressiveMove(units, x, y, queue = false) {
97
  const combatants = units.filter(u => UNITS[u.type].role === "combat");
98
  const others = units.filter(u => UNITS[u.type].role !== "combat");
99
- const formation = currentFormation();
100
  if (combatants.length) issueAttackMove(combatants, x, y, queue, formation);
101
  if (others.length) issueMove(others, x, y, queue, formation);
102
  }
@@ -173,7 +182,12 @@ export function attachInput(canvas, state, onChange) {
173
  // a single selected production building sets its rally; otherwise the current
174
  // unit selection assists-builds, attacks, gathers, or moves as the target
175
  // warrants. `queue` chains it as a waypoint instead of replacing the order.
176
- function commandAt(p, queue) {
 
 
 
 
 
177
  if (state.selection.length === 1) {
178
  const building = state.buildings.get(state.selection[0]);
179
  if (building && building.owner === "player" && BUILDINGS[building.type].produces) {
@@ -220,13 +234,19 @@ export function attachInput(canvas, state, onChange) {
220
  if (workers.length) { issueGather(workers, node.id, queue); sound.playOrder(); onChange(); }
221
  return;
222
  }
223
- if (queue) aggressiveMove(selected, p.x, p.y, true);
224
- else issueMove(selected, p.x, p.y, false, currentFormation());
225
  sound.playOrder();
226
  onChange();
227
  }
228
 
229
  canvas.addEventListener("mousedown", e => {
 
 
 
 
 
 
230
  if (e.button !== 0) return;
231
  const p = toWorld(e.clientX, e.clientY);
232
  if (buildMode) { placeBuildingAt(p); return; }
@@ -258,6 +278,23 @@ export function attachInput(canvas, state, onChange) {
258
  canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal });
259
 
260
  window.addEventListener("mouseup", e => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  if (e.button !== 0 || !dragBox) return;
262
  const box = dragBox;
263
  dragBox = null;
@@ -278,19 +315,13 @@ export function attachInput(canvas, state, onChange) {
278
  selectSameTypeAt(toWorld(e.clientX, e.clientY));
279
  }, { signal });
280
 
281
- canvas.addEventListener("contextmenu", e => {
282
- e.preventDefault();
283
- if (buildMode) { buildMode = null; onChange(); return; }
284
- if (attackMoveArmed) { setArmed(false); return; } // right-click cancels a pending attack-move
285
-
286
- // Holding Ctrl queues the order as a waypoint instead of replacing what the
287
- // units are doing, so a sequence of Ctrl+right-clicks lays down a path
288
- // (move/attack/gather steps) the units run through in order. A plain
289
- // right-click issues immediately and clears any queued waypoints. NOT Shift:
290
- // Firefox force-shows the native context menu on Shift+right-click and
291
- // bypasses preventDefault, so a Shift-queued order could never be captured.
292
- commandAt(toWorld(e.clientX, e.clientY), e.ctrlKey);
293
- }, { signal });
294
 
295
  canvas.addEventListener("wheel", e => {
296
  e.preventDefault();
 
38
  const heldKeys = new Set();
39
 
40
  let dragBox = null;
41
+ let rightDragStart = null; // world-space {x,y} where a right-button drag began, or null between drags
42
  let buildMode = null;
43
  let attackMoveArmed = false; // set by the A key; the next left-click issues an attack-move
44
  let lastWorldPos = { x: state.map.width / 2, y: state.map.height / 2 };
 
87
 
88
  // The player's current formation choice (session.js), read fresh on every order β€” so
89
  // switching shapes in the HUD mid-game takes effect on the very next command issued.
90
+ // `heading` ({x,y}, a world-space vector β€” NOT normalized, engine/formation.js does that) is
91
+ // set only by a right-click-DRAG (see rightDragStart below): the drag direction becomes both
92
+ // the formation's facing while it travels and the persisted `unit.facing` it holds once
93
+ // stopped (engine/commands.js applyFacing). A plain click carries no heading, so the shape
94
+ // orients itself off the group's own travel direction as before, and any earlier facing is
95
+ // cleared.
96
+ function currentFormation(heading) {
97
+ const f = { shape: game.formation.shape, leaderPos: game.formation.leaderPos };
98
+ if (heading) { f.headingX = heading.x; f.headingY = heading.y; }
99
+ return f;
100
  }
101
 
102
  // Attack-move to (x,y): combat units advance-and-engage anything met on the
103
  // way; non-combat units (workers, the Ranger) can't attack-move, so they just
104
  // move. Same split the Ctrl-queue and minimap-command paths use.
105
+ function aggressiveMove(units, x, y, queue = false, heading) {
106
  const combatants = units.filter(u => UNITS[u.type].role === "combat");
107
  const others = units.filter(u => UNITS[u.type].role !== "combat");
108
+ const formation = currentFormation(heading);
109
  if (combatants.length) issueAttackMove(combatants, x, y, queue, formation);
110
  if (others.length) issueMove(others, x, y, queue, formation);
111
  }
 
182
  // a single selected production building sets its rally; otherwise the current
183
  // unit selection assists-builds, attacks, gathers, or moves as the target
184
  // warrants. `queue` chains it as a waypoint instead of replacing the order.
185
+ // `heading` (world-space {x,y}, only ever set by a right-click-drag β€” see
186
+ // rightDragStart) only matters to the final plain-move fallback: gathering,
187
+ // attacking, escorting, and assisting/servicing a building all target a SPECIFIC
188
+ // entity/node, where "which way to face while getting there" isn't a choice β€”
189
+ // only an undirected move onto open ground is.
190
+ function commandAt(p, queue, heading) {
191
  if (state.selection.length === 1) {
192
  const building = state.buildings.get(state.selection[0]);
193
  if (building && building.owner === "player" && BUILDINGS[building.type].produces) {
 
234
  if (workers.length) { issueGather(workers, node.id, queue); sound.playOrder(); onChange(); }
235
  return;
236
  }
237
+ if (queue) aggressiveMove(selected, p.x, p.y, true, heading);
238
+ else issueMove(selected, p.x, p.y, false, currentFormation(heading));
239
  sound.playOrder();
240
  onChange();
241
  }
242
 
243
  canvas.addEventListener("mousedown", e => {
244
+ if (e.button === 2) {
245
+ // The actual command (or a build/attack-move cancel) fires on mouseup below, once we
246
+ // know whether this stayed a click or became a drag β€” just mark where it started.
247
+ rightDragStart = toWorld(e.clientX, e.clientY);
248
+ return;
249
+ }
250
  if (e.button !== 0) return;
251
  const p = toWorld(e.clientX, e.clientY);
252
  if (buildMode) { placeBuildingAt(p); return; }
 
278
  canvas.addEventListener("mouseleave", () => { edgePan = [0, 0]; }, { signal });
279
 
280
  window.addEventListener("mouseup", e => {
281
+ if (e.button === 2) {
282
+ const start = rightDragStart;
283
+ rightDragStart = null;
284
+ if (!start) return; // the button went down somewhere this listener never saw (e.g. off-canvas)
285
+ if (buildMode) { buildMode = null; onChange(); return; }
286
+ if (attackMoveArmed) { setArmed(false); return; } // right-click cancels a pending attack-move
287
+ const end = toWorld(e.clientX, e.clientY);
288
+ const dx = end.x - start.x, dy = end.y - start.y;
289
+ // A plain click carries no heading (the shape/facing fall back to the group's own travel
290
+ // direction, exactly as before); a real drag's direction becomes the explicit heading β€”
291
+ // see currentFormation/applyFacing. Holding Ctrl queues the order as a waypoint instead of
292
+ // replacing what the units are doing, so a sequence of Ctrl+right-drags lays down a
293
+ // multi-leg, individually-aimed path.
294
+ const heading = Math.hypot(dx, dy) >= CLICK_THRESHOLD ? { x: dx, y: dy } : null;
295
+ commandAt(start, e.ctrlKey, heading);
296
+ return;
297
+ }
298
  if (e.button !== 0 || !dragBox) return;
299
  const box = dragBox;
300
  dragBox = null;
 
315
  selectSameTypeAt(toWorld(e.clientX, e.clientY));
316
  }, { signal });
317
 
318
+ // The actual right-click handling (build/attack-move cancel, or commandAt with whatever
319
+ // heading the drag implies) lives on mouseup above, which fires before this β€” contextmenu's
320
+ // only remaining job is suppressing the browser's native menu. NOT Shift+right-click: Firefox
321
+ // force-shows the native context menu on it and bypasses preventDefault, so a Shift-modified
322
+ // right-drag could never be captured β€” Ctrl is the queue modifier here instead, for exactly
323
+ // that reason.
324
+ canvas.addEventListener("contextmenu", e => { e.preventDefault(); }, { signal });
 
 
 
 
 
 
325
 
326
  canvas.addEventListener("wheel", e => {
327
  e.preventDefault();
overlays.js CHANGED
@@ -120,6 +120,7 @@ export function flashHint(msg) {
120
  const HELP_ROWS = [
121
  ["Left-drag", "Select units Β· Ctrl+drag adds to selection"],
122
  ["Right-click", "Move Β· attack an enemy Β· gather a node"],
 
123
  ["A, then click", "Attack-move β€” advance and engage on the way"],
124
  ["Ctrl+right-click", "Queue a waypoint (chain a path)"],
125
  ["1–9 / Shift+1–9", "Recall / bind a control group"],
 
120
  const HELP_ROWS = [
121
  ["Left-drag", "Select units Β· Ctrl+drag adds to selection"],
122
  ["Right-click", "Move Β· attack an enemy Β· gather a node"],
123
+ ["Right-click and drag", "…the drag direction also becomes the group's (or a lone unit's) facing"],
124
  ["A, then click", "Attack-move β€” advance and engage on the way"],
125
  ["Ctrl+right-click", "Queue a waypoint (chain a path)"],
126
  ["1–9 / Shift+1–9", "Recall / bind a control group"],
renderShared.js CHANGED
@@ -81,14 +81,22 @@ export function resetFacing() {
81
 
82
  // Infer and store a unit's facing angle from its frame-to-frame movement; returns the angle so
83
  // an oriented hull can draw pointing the way it's actually moving. A near-stationary unit keeps
84
- // its previous angle (no jitter). Buildings call it too (a turret barrel) β€” the "b*"/"u*" id
85
- // spaces can't collide in the shared map.
 
 
 
 
 
 
86
  export function updateFacing(unit) {
87
  const prev = facing.get(unit.id);
88
- let angle = prev ? prev.angle : -Math.PI / 2;
 
89
  if (prev) {
90
  const dx = unit.x - prev.x, dy = unit.y - prev.y;
91
  if (Math.hypot(dx, dy) > 0.5) angle = Math.atan2(dy, dx);
 
92
  }
93
  facing.set(unit.id, { x: unit.x, y: unit.y, angle });
94
  return angle;
 
81
 
82
  // Infer and store a unit's facing angle from its frame-to-frame movement; returns the angle so
83
  // an oriented hull can draw pointing the way it's actually moving. A near-stationary unit keeps
84
+ // its previous angle EXCEPT when the player has set an explicit one (unit.facing β€” a real,
85
+ // engine-side field set by a click-and-drag move/attack-move, engine/commands.js applyFacing):
86
+ // then it snaps to/holds THAT instead, so a formation told to face the enemy actually does once
87
+ // it stops, rather than freezing at whatever direction its last step of pathing happened to
88
+ // leave it pointing. While actually moving, travel direction still wins β€” a unit sliding
89
+ // sideways while nominally "facing" a fixed angle would read as broken, not deliberate. Buildings
90
+ // call it too (a turret barrel, which never sets unit.facing) β€” the "b*"/"u*" id spaces can't
91
+ // collide in the shared map.
92
  export function updateFacing(unit) {
93
  const prev = facing.get(unit.id);
94
+ const explicit = Number.isFinite(unit.facing);
95
+ let angle = prev ? prev.angle : (explicit ? unit.facing : -Math.PI / 2);
96
  if (prev) {
97
  const dx = unit.x - prev.x, dy = unit.y - prev.y;
98
  if (Math.hypot(dx, dy) > 0.5) angle = Math.atan2(dy, dx);
99
+ else if (explicit) angle = unit.facing;
100
  }
101
  facing.set(unit.id, { x: unit.x, y: unit.y, angle });
102
  return angle;
test/formation.test.js CHANGED
@@ -1,7 +1,7 @@
1
  import { test } from "node:test";
2
  import assert from "node:assert/strict";
3
  import { formationSlots, clusterUnits, pickLeader, FORMATION_SHAPES } from "../engine/formation.js";
4
- import { issueHoldFormation } from "../engine/commands.js";
5
  import { UNITS } from "../engine/entities.js";
6
  import { createGameState, makeUnit } from "../engine/state.js";
7
  import { tick } from "../engine/sim.js";
@@ -9,9 +9,14 @@ import { updateCombat } from "../engine/combat.js";
9
  import { serializeGame, deserializeGame } from "../engine/persist.js";
10
 
11
  // A bare positioned "unit" β€” enough for formation.js (which only reads id/type/x/y/maxHp),
12
- // without going through makeUnit/a real game state.
 
 
13
  function fakeUnit(id, type, x, y, maxHp) {
14
- return { id, type, x, y, maxHp: maxHp ?? UNITS[type].hp };
 
 
 
15
  }
16
 
17
  // A minimal state (as the movement/combat tests use): just a unit Map + players, so we can drive
@@ -25,20 +30,13 @@ function miniState(...units) {
25
  };
26
  }
27
 
28
- // ---- pickLeader -------------------------------------------------------------
29
-
30
- test("pickLeader picks the unit with the most maxHp", () => {
31
- const a = fakeUnit("u2", "skiff", 0, 0); // maxHp 72
32
- const b = fakeUnit("u1", "dreadnought", 0, 0); // maxHp 340
33
- const c = fakeUnit("u3", "worker", 0, 0); // maxHp 40
34
- assert.equal(pickLeader([a, b, c]).id, "u1");
35
- });
36
 
37
- test("pickLeader breaks a maxHp tie by the lower id, never by array order", () => {
38
- const first = fakeUnit("u5", "skiff", 0, 0);
39
- const second = fakeUnit("u4", "skiff", 0, 0); // same maxHp, lower id, listed SECOND
40
- assert.equal(pickLeader([first, second]).id, "u4");
41
- assert.equal(pickLeader([second, first]).id, "u4");
42
  });
43
 
44
  // ---- clusterUnits: the nesting trigger ---------------------------------------
@@ -56,6 +54,13 @@ test("clusterUnits splits a selection spread across the map into separate armies
56
  assert.deepEqual(clusters.map(c => c.length).sort(), [3, 3]);
57
  });
58
 
 
 
 
 
 
 
 
59
  // ---- formationSlots: the default (grid, single cluster) path stays legacy-exact ----
60
 
61
  test("formationSlots grid (no shape chosen) matches the exact legacy spacing formula", () => {
@@ -89,65 +94,74 @@ test("line/wedge/circle center their group's centroid exactly on the destination
89
  }
90
  });
91
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  // ---- line ---------------------------------------------------------------------
 
93
 
94
  test("line formation: leaderPos front pushes the leader ahead of the rest of the line", () => {
95
- const leader = fakeUnit("L", "dreadnought", 0, 0);
96
  const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
97
- const units = [...followers, leader];
98
  const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "front", originX: 0, originY: 0 });
99
- const leaderX = spots[units.indexOf(leader)].x;
100
- const avgFollowerX = followers.reduce((s, f) => s + spots[units.indexOf(f)].x, 0) / followers.length;
101
  assert.ok(leaderX > avgFollowerX, "the leader sits ahead of the line toward the heading");
102
  });
103
 
104
  test("line formation: leaderPos back tucks the leader behind the line", () => {
105
- const leader = fakeUnit("L", "dreadnought", 0, 0);
106
  const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
107
- const units = [...followers, leader];
108
  const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "back", originX: 0, originY: 0 });
109
- const leaderX = spots[units.indexOf(leader)].x;
110
- const avgFollowerX = followers.reduce((s, f) => s + spots[units.indexOf(f)].x, 0) / followers.length;
111
  assert.ok(leaderX < avgFollowerX, "the leader trails behind the line");
112
  });
113
 
114
  // ---- wedge ----------------------------------------------------------------------
115
 
116
  test("wedge formation: leaderPos front puts the leader at the tip, most forward of everyone", () => {
117
- const leader = fakeUnit("L", "dreadnought", 0, 0);
118
  const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
119
  const units = [leader, ...others];
120
  const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "front", originX: 0, originY: 0 });
121
- const leaderX = spots[units.indexOf(leader)].x;
122
- for (const o of others) assert.ok(leaderX >= spots[units.indexOf(o)].x - 1e-6, `${o.id} trails the tip`);
123
  });
124
 
125
  test("wedge formation: leaderPos back shields the leader at the rear vertex, flanks leading", () => {
126
- const leader = fakeUnit("L", "dreadnought", 0, 0);
127
  const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
128
  const units = [leader, ...others];
129
  const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "back", originX: 0, originY: 0 });
130
- const leaderX = spots[units.indexOf(leader)].x;
131
- for (const o of others) assert.ok(leaderX <= spots[units.indexOf(o)].x + 1e-6, `${o.id} leads ahead of the shielded leader`);
132
  });
133
 
134
  // ---- circle -----------------------------------------------------------------
135
 
136
  test("circle formation: leaderPos center seats the leader at the anchor, escorted by a ring", () => {
137
- const leader = fakeUnit("L", "dreadnought", 0, 0);
138
  const others = [1, 2, 3, 4, 5].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
139
  const units = [leader, ...others];
140
  const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "center", originX: 0, originY: 0 });
141
- const leaderSpot = spots[units.indexOf(leader)];
142
- assert.ok(Math.hypot(leaderSpot.x - 900, leaderSpot.y - 700) < 0.5, "leader sits at the anchor");
143
- for (const o of others) {
144
- const p = spots[units.indexOf(o)];
145
- assert.ok(Math.hypot(p.x - 900, p.y - 700) > 10, `${o.id} stands out on the protective ring`);
146
- }
147
  });
148
 
149
  test("circle formation: leaderPos front/back puts every unit, leader included, on the ring", () => {
150
- const leader = fakeUnit("L", "dreadnought", 0, 0);
151
  const others = [1, 2, 3].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
152
  const units = [leader, ...others];
153
  const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "front", originX: 0, originY: 0 });
@@ -204,24 +218,52 @@ test("formationSlots lays out a nested formation of formations β€” tight within
204
  assert.ok(Math.abs(avgX - 1500) < 5 && Math.abs(avgY - 0) < 5, "the nested layout as a whole is still centered on the destination");
205
  });
206
 
207
- // ---- issueHoldFormation (engine/commands.js) ---------------------------------
 
 
 
208
 
209
- test("issueHoldFormation anchors the group on its own current centroid, not the map origin", () => {
210
- const units = [
211
- { id: "u1", type: "skiff", x: 100, y: 100, maxHp: 72, order: null },
212
- { id: "u2", type: "skiff", x: 120, y: 100, maxHp: 72, order: null },
213
- ];
214
- issueHoldFormation(units, "grid", "front");
215
  for (const u of units) {
216
- assert.equal(u.order.type, "hold-formation");
217
- assert.equal(u.order.anchorX, 110);
218
- assert.equal(u.order.anchorY, 100);
219
- assert.equal(u.hold, true, "a combat unit also takes the Hold stance");
220
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  });
222
 
223
  test("issueHoldFormation gives a non-combat unit a slot but no Hold stance to set", () => {
224
- const worker = { id: "u1", type: "worker", x: 0, y: 0, maxHp: 40, order: null };
225
  issueHoldFormation([worker]);
226
  assert.equal(worker.order.type, "hold-formation");
227
  assert.ok(!worker.hold);
@@ -231,18 +273,99 @@ test("issueHoldFormation on an empty selection is a safe no-op", () => {
231
  assert.doesNotThrow(() => issueHoldFormation([]));
232
  });
233
 
234
- // ---- sim/combat integration: keepFormationStation ----------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
- test("a combat unit holding formation converges on its own anchor+offset slot and never clears the order on arrival", () => {
237
- const units = [makeUnit("skiff", "player", 100, 100), makeUnit("skiff", "player", 300, 300)];
 
 
238
  const s = miniState(...units);
239
  issueHoldFormation(units, "line", "front");
240
  for (let i = 0; i < 400; i++) for (const u of units) updateCombat(s, u, 0.1);
241
- for (const u of units) {
242
- const tx = u.order.anchorX + u.order.offsetX, ty = u.order.anchorY + u.order.offsetY;
243
- assert.ok(Math.hypot(u.x - tx, u.y - ty) < 5, `${u.id} reached its formation slot`);
244
- assert.equal(u.order.type, "hold-formation", "arrival never clears a hold-formation order");
245
- }
 
 
 
246
  });
247
 
248
  test("a unit holding formation still fires on a threat that wanders into weapon range", () => {
@@ -268,31 +391,78 @@ test("the Hold stance keeps a formation unit from chasing a target out of weapon
268
  assert.ok(Math.hypot(guard.x - startX, guard.y - startY) < 2, "held ground instead of closing on an out-of-range target");
269
  });
270
 
271
- test("a worker (non-combat) holding formation reaches its slot via the full sim tick path", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  const s = createGameState({ planetId: "ferros", seed: 2 });
273
  const w1 = makeUnit("worker", "player", 500, 500);
274
  const w2 = makeUnit("worker", "player", 700, 500);
 
275
  s.units.set(w1.id, w1); s.units.set(w2.id, w2);
276
- issueHoldFormation([w1, w2], "line", "front");
277
  for (let i = 0; i < 200; i++) tick(s, 0.1);
278
- for (const u of [w1, w2]) {
279
- const tx = u.order.anchorX + u.order.offsetX, ty = u.order.anchorY + u.order.offsetY;
280
- assert.ok(Math.hypot(u.x - tx, u.y - ty) < 10, `${u.id} reached its slot`);
281
- assert.equal(u.order.type, "hold-formation");
282
- }
283
  });
284
 
285
- test("a support unit (Mender) holding formation keeps station via updateSupport", () => {
286
  const s = createGameState({ planetId: "ferros", seed: 3 });
287
  const m1 = makeUnit("mender", "player", 500, 500);
288
  const m2 = makeUnit("mender", "player", 700, 500);
289
  s.units.set(m1.id, m1); s.units.set(m2.id, m2);
290
  issueHoldFormation([m1, m2], "circle", "front");
291
  for (let i = 0; i < 200; i++) tick(s, 0.1);
292
- for (const u of [m1, m2]) {
293
- const tx = u.order.anchorX + u.order.offsetX, ty = u.order.anchorY + u.order.offsetY;
294
- assert.ok(Math.hypot(u.x - tx, u.y - ty) < 10, `${u.id} reached its slot`);
295
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  });
297
 
298
  // ---- persistence --------------------------------------------------------------
@@ -318,3 +488,22 @@ test("a hold-formation order survives save/load, and a tampered anchor/offset ca
318
  tick(restored, 0.1);
319
  assert.ok(Number.isFinite(ru1.x) && Number.isFinite(ru1.y), "position stays finite even with a garbage anchor/offset");
320
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { test } from "node:test";
2
  import assert from "node:assert/strict";
3
  import { formationSlots, clusterUnits, pickLeader, FORMATION_SHAPES } from "../engine/formation.js";
4
+ import { issueHoldFormation, issueMove, issueAttackMove, issueStop } from "../engine/commands.js";
5
  import { UNITS } from "../engine/entities.js";
6
  import { createGameState, makeUnit } from "../engine/state.js";
7
  import { tick } from "../engine/sim.js";
 
9
  import { serializeGame, deserializeGame } from "../engine/persist.js";
10
 
11
  // A bare positioned "unit" β€” enough for formation.js (which only reads id/type/x/y/maxHp),
12
+ // without going through makeUnit/a real game state. No `owner` on purpose for the pure-geometry
13
+ // tests below (formation.js itself doesn't care); tests that exercise the PLAYER leader/follow
14
+ // squad mechanic (engine/commands.js) need a real owner:"player" β€” see fakePlayerUnit.
15
  function fakeUnit(id, type, x, y, maxHp) {
16
+ return { id, type, x, y, maxHp: maxHp ?? UNITS[type].hp, order: null };
17
+ }
18
+ function fakePlayerUnit(id, type, x, y) {
19
+ return { ...fakeUnit(id, type, x, y), owner: "player" };
20
  }
21
 
22
  // A minimal state (as the movement/combat tests use): just a unit Map + players, so we can drive
 
30
  };
31
  }
32
 
33
+ // ---- pickLeader: the unit FIRST in the array, not a stat-based pick ----------
 
 
 
 
 
 
 
34
 
35
+ test("pickLeader is simply the first unit in the array β€” the one the rest were added to", () => {
36
+ const a = fakeUnit("u2", "dreadnought", 0, 0); // highest maxHp, but NOT first
37
+ const b = fakeUnit("u1", "skiff", 0, 0); // listed first
38
+ assert.equal(pickLeader([b, a]).id, "u1");
39
+ assert.equal(pickLeader([a, b]).id, "u2", "array order alone decides it β€” not maxHp");
40
  });
41
 
42
  // ---- clusterUnits: the nesting trigger ---------------------------------------
 
54
  assert.deepEqual(clusters.map(c => c.length).sort(), [3, 3]);
55
  });
56
 
57
+ test("clusterUnits keeps the cluster containing units[0] first, so it stays the outer leader cluster", () => {
58
+ const leaderSide = [0, 1].map(i => fakeUnit(`L${i}`, "skiff", i * 10, 0));
59
+ const otherSide = [0, 1].map(i => fakeUnit(`O${i}`, "skiff", 3000 + i * 10, 0));
60
+ const clusters = clusterUnits([...leaderSide, ...otherSide]);
61
+ assert.equal(clusters[0][0].id, "L0", "units[0]'s cluster is clusters[0]");
62
+ });
63
+
64
  // ---- formationSlots: the default (grid, single cluster) path stays legacy-exact ----
65
 
66
  test("formationSlots grid (no shape chosen) matches the exact legacy spacing formula", () => {
 
94
  }
95
  });
96
 
97
+ // ---- explicit heading override ------------------------------------------------
98
+
99
+ test("an explicit headingX/headingY overrides the origin-derived heading", () => {
100
+ const units = [0, 1, 2, 3].map(i => fakeUnit(`u${i}`, "skiff", 0, 0));
101
+ // origin==dest (no travel at all) defaults to a stable heading (see the "line formation"
102
+ // tests below, which rely on exactly this default) β€” an explicit override must still change
103
+ // the resulting layout rather than being silently ignored.
104
+ const withoutOverride = formationSlots(units, 500, 500, { shape: "line", originX: 500, originY: 500 });
105
+ const withOverride = formationSlots(units, 500, 500, { shape: "line", originX: 500, originY: 500, headingX: 0, headingY: -1 });
106
+ assert.notDeepEqual(withOverride, withoutOverride, "an explicit heading produces a different layout than the origin-derived default");
107
+ });
108
+
109
  // ---- line ---------------------------------------------------------------------
110
+ // The leader is always units[0] β€” these tests put the intended leader FIRST in the array.
111
 
112
  test("line formation: leaderPos front pushes the leader ahead of the rest of the line", () => {
113
+ const leader = fakeUnit("L", "skiff", 0, 0);
114
  const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
115
+ const units = [leader, ...followers];
116
  const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "front", originX: 0, originY: 0 });
117
+ const leaderX = spots[0].x;
118
+ const avgFollowerX = spots.slice(1).reduce((s, p) => s + p.x, 0) / followers.length;
119
  assert.ok(leaderX > avgFollowerX, "the leader sits ahead of the line toward the heading");
120
  });
121
 
122
  test("line formation: leaderPos back tucks the leader behind the line", () => {
123
+ const leader = fakeUnit("L", "skiff", 0, 0);
124
  const followers = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
125
+ const units = [leader, ...followers];
126
  const spots = formationSlots(units, 1000, 0, { shape: "line", leaderPos: "back", originX: 0, originY: 0 });
127
+ const leaderX = spots[0].x;
128
+ const avgFollowerX = spots.slice(1).reduce((s, p) => s + p.x, 0) / followers.length;
129
  assert.ok(leaderX < avgFollowerX, "the leader trails behind the line");
130
  });
131
 
132
  // ---- wedge ----------------------------------------------------------------------
133
 
134
  test("wedge formation: leaderPos front puts the leader at the tip, most forward of everyone", () => {
135
+ const leader = fakeUnit("L", "skiff", 0, 0);
136
  const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
137
  const units = [leader, ...others];
138
  const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "front", originX: 0, originY: 0 });
139
+ const leaderX = spots[0].x;
140
+ for (const p of spots.slice(1)) assert.ok(leaderX >= p.x - 1e-6, "every flank trails the tip");
141
  });
142
 
143
  test("wedge formation: leaderPos back shields the leader at the rear vertex, flanks leading", () => {
144
+ const leader = fakeUnit("L", "skiff", 0, 0);
145
  const others = [1, 2, 3, 4].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
146
  const units = [leader, ...others];
147
  const spots = formationSlots(units, 1000, 0, { shape: "wedge", leaderPos: "back", originX: 0, originY: 0 });
148
+ const leaderX = spots[0].x;
149
+ for (const p of spots.slice(1)) assert.ok(leaderX <= p.x + 1e-6, "every flank leads ahead of the shielded leader");
150
  });
151
 
152
  // ---- circle -----------------------------------------------------------------
153
 
154
  test("circle formation: leaderPos center seats the leader at the anchor, escorted by a ring", () => {
155
+ const leader = fakeUnit("L", "skiff", 0, 0);
156
  const others = [1, 2, 3, 4, 5].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
157
  const units = [leader, ...others];
158
  const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "center", originX: 0, originY: 0 });
159
+ assert.ok(Math.hypot(spots[0].x - 900, spots[0].y - 700) < 0.5, "leader sits at the anchor");
160
+ for (const p of spots.slice(1)) assert.ok(Math.hypot(p.x - 900, p.y - 700) > 10, "an escort stands out on the protective ring");
 
 
 
 
161
  });
162
 
163
  test("circle formation: leaderPos front/back puts every unit, leader included, on the ring", () => {
164
+ const leader = fakeUnit("L", "skiff", 0, 0);
165
  const others = [1, 2, 3].map(i => fakeUnit(`f${i}`, "skiff", 0, 0));
166
  const units = [leader, ...others];
167
  const spots = formationSlots(units, 900, 700, { shape: "circle", leaderPos: "front", originX: 0, originY: 0 });
 
218
  assert.ok(Math.abs(avgX - 1500) < 5 && Math.abs(avgY - 0) < 5, "the nested layout as a whole is still centered on the destination");
219
  });
220
 
221
+ // ---- AI-owned (and owner-less) units never form a squad ----------------------
222
+ // The leader/follow mechanic is a PLAYER selection-UX feature: the scripted AI (and any test
223
+ // double with no owner set) always gets the plain, pre-existing per-unit spread β€” every unit its
224
+ // own real order, no leader, no follow-leader, no speed cap.
225
 
226
+ test("issueMove on AI-owned units gives every unit its own real move order β€” no squad", () => {
227
+ const units = [fakeUnit("a1", "skiff", 0, 0), fakeUnit("a2", "skiff", 0, 0)].map(u => ({ ...u, owner: "ai" }));
228
+ issueMove(units, 900, 700);
 
 
 
229
  for (const u of units) {
230
+ assert.equal(u.order.type, "move");
231
+ assert.ok(Number.isFinite(u.order.x) && Number.isFinite(u.order.y));
 
 
232
  }
233
+ assert.equal(units[1].order.type, "move", "the second unit is NOT a follow-leader");
234
+ assert.equal(units.some(u => u.squadLeader), false);
235
+ });
236
+
237
+ test("issueMove on owner-less units (a bare test double) also skips the squad mechanic", () => {
238
+ const units = [fakeUnit("u1", "skiff", 0, 0), fakeUnit("u2", "skiff", 0, 0)]; // no owner field at all
239
+ issueMove(units, 900, 700);
240
+ units.forEach(u => assert.equal(u.order.type, "move"));
241
+ });
242
+
243
+ // ---- issueHoldFormation / the leader-follow squad (engine/commands.js) -------
244
+
245
+ test("issueHoldFormation makes units[0] the leader (anchored near the group's own centroid) and everyone else a follower", () => {
246
+ const units = [fakePlayerUnit("u1", "skiff", 100, 100), fakePlayerUnit("u2", "skiff", 120, 100)];
247
+ issueHoldFormation(units, "grid", "front");
248
+ const [leader, follower] = units;
249
+ assert.equal(leader.order.type, "hold-formation");
250
+ // The grid shape gives the leader its own slot in the grid (not necessarily the exact
251
+ // centroid β€” see the "matches the exact legacy spacing formula" test), but it's always
252
+ // WITHIN the group's own footprint, close to where the two units actually are.
253
+ assert.ok(Math.hypot(leader.order.anchorX - 110, leader.order.anchorY - 100) < 20);
254
+ assert.equal(follower.order.type, "follow-leader");
255
+ assert.equal(follower.order.leader, leader);
256
+ assert.equal(follower.squadLeader, leader);
257
+ assert.deepEqual(leader.squadFollowers, [follower]);
258
+ assert.equal(leader.hold, true, "a combat leader also takes the Hold stance");
259
+ assert.equal(follower.hold, true, "…and so does a combat follower");
260
+ // Leader and follower end up at distinct points (the formation's own spacing), not stacked.
261
+ const followerTarget = { x: leader.order.anchorX + follower.order.offsetX, y: leader.order.anchorY + follower.order.offsetY };
262
+ assert.ok(Math.hypot(followerTarget.x - leader.order.anchorX, followerTarget.y - leader.order.anchorY) > 5);
263
  });
264
 
265
  test("issueHoldFormation gives a non-combat unit a slot but no Hold stance to set", () => {
266
+ const worker = fakePlayerUnit("u1", "worker", 0, 0);
267
  issueHoldFormation([worker]);
268
  assert.equal(worker.order.type, "hold-formation");
269
  assert.ok(!worker.hold);
 
273
  assert.doesNotThrow(() => issueHoldFormation([]));
274
  });
275
 
276
+ // ---- persistent leader-follow: select the leader alone, move it, others trail --
277
+
278
+ test("a solo move on the leader alone drags its existing followers along, capped to the group's slowest speed", () => {
279
+ const leader = fakePlayerUnit("L", "bulkfreighter", 100, 100); // slow (speed 32)
280
+ const follower = fakePlayerUnit("F", "ranger", 120, 100); // fast (speed 115)
281
+ issueMove([leader, follower], 1000, 100);
282
+ assert.equal(leader.order.speedCap, UNITS.bulkfreighter.speed, "capped to the slowest member");
283
+ assert.equal(follower.order.type, "follow-leader");
284
+ assert.equal(follower.order.leader, leader);
285
+
286
+ // Now command the LEADER alone β€” the follower's order is untouched (still follow-leader,
287
+ // still pointing at the same live leader object), so it keeps trailing automatically.
288
+ issueMove([leader], 50, 100);
289
+ assert.equal(leader.order.type, "move");
290
+ assert.equal(leader.order.speedCap, UNITS.bulkfreighter.speed, "the solo command still re-derives the group's speed cap");
291
+ assert.equal(follower.order.type, "follow-leader", "never re-issued β€” it was already chasing the leader");
292
+ assert.equal(follower.order.leader, leader);
293
+ });
294
+
295
+ test("a follower given a DIRECT order leaves the squad β€” the leader's own list drops it", () => {
296
+ const leader = fakePlayerUnit("L", "skiff", 0, 0);
297
+ const follower = fakePlayerUnit("F", "skiff", 0, 0);
298
+ issueHoldFormation([leader, follower]);
299
+ assert.equal(follower.squadLeader, leader);
300
+
301
+ issueMove([follower], 500, 500); // a direct command to the follower alone
302
+
303
+ assert.equal(follower.order.type, "move", "direct control wins");
304
+ assert.equal(follower.squadLeader, undefined, "no longer tracked as this leader's follower");
305
+ assert.deepEqual(leader.squadFollowers, [], "the leader's own list was trimmed to match");
306
+ });
307
+
308
+ test("issueStop on a follower releases it from the squad too", () => {
309
+ const leader = fakePlayerUnit("L", "skiff", 0, 0);
310
+ const follower = fakePlayerUnit("F", "skiff", 0, 0);
311
+ issueHoldFormation([leader, follower]);
312
+ issueStop([follower]);
313
+ assert.equal(follower.order, null);
314
+ assert.equal(follower.squadLeader, undefined);
315
+ assert.deepEqual(leader.squadFollowers, []);
316
+ });
317
+
318
+ test("redefining a leader's squad with a smaller group stops (not just unlinks) the dropped-out unit", () => {
319
+ const leader = fakePlayerUnit("L", "skiff", 0, 0);
320
+ const a = fakePlayerUnit("A", "skiff", 0, 0);
321
+ const b = fakePlayerUnit("B", "skiff", 0, 0);
322
+ issueHoldFormation([leader, a, b]);
323
+ assert.deepEqual(leader.squadFollowers, [a, b]);
324
+
325
+ issueMove([leader, a], 500, 0); // a fresh command that only re-selects the leader + A
326
+
327
+ assert.deepEqual(leader.squadFollowers, [a], "B fell out of the squad");
328
+ assert.equal(b.squadLeader, undefined);
329
+ assert.equal(b.order, null, "B is actually stopped, not left silently chasing a leader that dropped it");
330
+ assert.equal(a.order.type, "follow-leader", "A is still following");
331
+ });
332
+
333
+ // ---- click-and-drag facing (engine/commands.js applyFacing) ------------------
334
+
335
+ test("issueMove with an explicit heading stamps every commanded unit's facing; a plain move clears it", () => {
336
+ const units = [fakePlayerUnit("u1", "skiff", 0, 0), fakePlayerUnit("u2", "skiff", 0, 0)];
337
+ issueMove(units, 100, 0, false, { headingX: 0, headingY: 1 }); // facing straight down (+y)
338
+ for (const u of units) assert.ok(Math.abs(u.facing - Math.PI / 2) < 1e-6);
339
+
340
+ issueMove(units, 100, 0); // a later plain move, no heading
341
+ for (const u of units) assert.ok(!Number.isFinite(u.facing), "no explicit heading clears the earlier facing");
342
+ });
343
+
344
+ test("issueAttackMove also stamps and clears facing the same way", () => {
345
+ const units = [fakePlayerUnit("u1", "skiff", 0, 0)];
346
+ issueAttackMove(units, 100, 0, false, { headingX: -1, headingY: 0 }); // facing left
347
+ assert.ok(Math.abs(units[0].facing - Math.PI) < 1e-6);
348
+ issueAttackMove(units, 100, 0);
349
+ assert.ok(!Number.isFinite(units[0].facing));
350
+ });
351
+
352
+ // ---- sim/combat integration: keepFormationStation / keepFollowingLeader -----
353
 
354
+ test("a leader holding formation converges on its own anchor; a follower converges on leader+offset β€” arrival never clears either", () => {
355
+ const leader = makeUnit("skiff", "player", 100, 100);
356
+ const follower = makeUnit("skiff", "player", 300, 300);
357
+ const units = [leader, follower];
358
  const s = miniState(...units);
359
  issueHoldFormation(units, "line", "front");
360
  for (let i = 0; i < 400; i++) for (const u of units) updateCombat(s, u, 0.1);
361
+
362
+ const leaderTarget = { x: leader.order.anchorX + leader.order.offsetX, y: leader.order.anchorY + leader.order.offsetY };
363
+ assert.ok(Math.hypot(leader.x - leaderTarget.x, leader.y - leaderTarget.y) < 5, "leader reached its own anchor");
364
+ assert.equal(leader.order.type, "hold-formation");
365
+
366
+ const followerTarget = { x: leader.x + follower.order.offsetX, y: leader.y + follower.order.offsetY };
367
+ assert.ok(Math.hypot(follower.x - followerTarget.x, follower.y - followerTarget.y) < 5, "follower reached leader+offset");
368
+ assert.equal(follower.order.type, "follow-leader");
369
  });
370
 
371
  test("a unit holding formation still fires on a threat that wanders into weapon range", () => {
 
391
  assert.ok(Math.hypot(guard.x - startX, guard.y - startY) < 2, "held ground instead of closing on an out-of-range target");
392
  });
393
 
394
+ test("a combat follower still auto-engages a threat, then resumes following", () => {
395
+ const leader = makeUnit("skiff", "player", 500, 500);
396
+ const follower = makeUnit("skiff", "player", 540, 500);
397
+ const units = [leader, follower];
398
+ const s = miniState(...units);
399
+ issueHoldFormation(units);
400
+ for (let i = 0; i < 100; i++) for (const u of units) updateCombat(s, u, 0.1); // settle into formation
401
+
402
+ const enemy = makeUnit("skiff", "ai", follower.x + 20, follower.y); // right on the follower
403
+ enemy.hp = enemy.maxHp = 100000;
404
+ s.units.set(enemy.id, enemy);
405
+ for (let i = 0; i < 80; i++) updateCombat(s, follower, 0.1);
406
+ assert.ok(enemy.hp < enemy.maxHp, "the follower broke to engage a threat at its post");
407
+ assert.equal(follower.order.type, "follow-leader", "still tracking its leader throughout");
408
+ });
409
+
410
+ test("a follower drops its order (free to be re-ordered) the moment its leader dies", () => {
411
+ const leader = makeUnit("skiff", "player", 500, 500);
412
+ const follower = makeUnit("skiff", "player", 540, 500);
413
+ const s = miniState(leader, follower);
414
+ issueHoldFormation([leader, follower]);
415
+ leader.hp = 0; // the leader has died (removeEntity already dropped it from state.units in real play)
416
+ updateCombat(s, follower, 0.1);
417
+ assert.equal(follower.order, null, "no live leader to follow β†’ free for a new order");
418
+ });
419
+
420
+ test("a worker (non-combat) leader+follower pair reaches its formation via the full sim tick path", () => {
421
  const s = createGameState({ planetId: "ferros", seed: 2 });
422
  const w1 = makeUnit("worker", "player", 500, 500);
423
  const w2 = makeUnit("worker", "player", 700, 500);
424
+ const units = [w1, w2];
425
  s.units.set(w1.id, w1); s.units.set(w2.id, w2);
426
+ issueHoldFormation(units, "line", "front");
427
  for (let i = 0; i < 200; i++) tick(s, 0.1);
428
+
429
+ const leaderTarget = { x: w1.order.anchorX + w1.order.offsetX, y: w1.order.anchorY + w1.order.offsetY };
430
+ assert.ok(Math.hypot(w1.x - leaderTarget.x, w1.y - leaderTarget.y) < 10, "leader reached its anchor");
431
+ const followerTarget = { x: w1.x + w2.order.offsetX, y: w1.y + w2.order.offsetY };
432
+ assert.ok(Math.hypot(w2.x - followerTarget.x, w2.y - followerTarget.y) < 10, "follower reached leader+offset");
433
  });
434
 
435
+ test("a support unit (Mender) leader+follower pair keeps station via updateSupport", () => {
436
  const s = createGameState({ planetId: "ferros", seed: 3 });
437
  const m1 = makeUnit("mender", "player", 500, 500);
438
  const m2 = makeUnit("mender", "player", 700, 500);
439
  s.units.set(m1.id, m1); s.units.set(m2.id, m2);
440
  issueHoldFormation([m1, m2], "circle", "front");
441
  for (let i = 0; i < 200; i++) tick(s, 0.1);
442
+ const leaderTarget = { x: m1.order.anchorX + m1.order.offsetX, y: m1.order.anchorY + m1.order.offsetY };
443
+ assert.ok(Math.hypot(m1.x - leaderTarget.x, m1.y - leaderTarget.y) < 10);
444
+ const followerTarget = { x: m1.x + m2.order.offsetX, y: m1.y + m2.order.offsetY };
445
+ assert.ok(Math.hypot(m2.x - followerTarget.x, m2.y - followerTarget.y) < 10);
446
+ });
447
+
448
+ test("a formation's travel speed never outruns its slowest member, via the real tick loop", () => {
449
+ const s = createGameState({ planetId: "ferros", seed: 5 });
450
+ const leader = makeUnit("bulkfreighter", "player", 500, 500); // speed 32 β€” the slowest
451
+ // Placed ALONGSIDE the leader (perpendicular to the +x travel direction), not directly ahead
452
+ // of it on the path β€” so movement.js's lateral-avoidance sensing (same-owner neighbours AHEAD
453
+ // in the seek direction) never engages and curves either unit's path, keeping this a clean
454
+ // measurement of the speed cap alone.
455
+ const follower = makeUnit("ranger", "player", 500, 530); // speed 115 β€” much faster
456
+ s.units.set(leader.id, leader); s.units.set(follower.id, follower);
457
+ issueMove([leader, follower], 1500, 500);
458
+ for (let i = 0; i < 50; i++) tick(s, 0.1); // 5 sim seconds
459
+ const traveled = leader.x - 500;
460
+ assert.ok(traveled < UNITS.ranger.speed * 5 * 0.5,
461
+ `the leader (${traveled.toFixed(0)} travelled) moves far slower than the follower's own top speed would allow`);
462
+ // The formation's grid layout for 2 units puts the follower's slot 20 units off the leader's
463
+ // own β€” see the "matches the exact legacy spacing formula" test above (spacing 20, cols 2).
464
+ const gap = Math.hypot(follower.x - (leader.x + 20), follower.y - leader.y);
465
+ assert.ok(gap < 10, `the fast follower stays tight on the slow leader (gap ${gap.toFixed(1)}), not racing ahead and idling`);
466
  });
467
 
468
  // ---- persistence --------------------------------------------------------------
 
488
  tick(restored, 0.1);
489
  assert.ok(Number.isFinite(ru1.x) && Number.isFinite(ru1.y), "position stays finite even with a garbage anchor/offset");
490
  });
491
+
492
+ test("a follow-leader order (and squadLeader/squadFollowers) never survives save/load β€” no circular reference crash", () => {
493
+ const s = createGameState({ planetId: "ferros", seed: 6 });
494
+ const leader = makeUnit("skiff", "player", 500, 500);
495
+ const follower = makeUnit("skiff", "player", 540, 500);
496
+ s.units.set(leader.id, leader); s.units.set(follower.id, follower);
497
+ issueHoldFormation([leader, follower]);
498
+
499
+ let payload;
500
+ assert.doesNotThrow(() => { payload = JSON.parse(JSON.stringify(serializeGame(s))); },
501
+ "serializing a live squad (a circular leader<->follower reference) must not throw");
502
+
503
+ const restored = deserializeGame(payload);
504
+ const rLeader = [...restored.units.values()].find(u => u.id === leader.id);
505
+ const rFollower = [...restored.units.values()].find(u => u.id === follower.id);
506
+ assert.equal(rFollower.order, null, "the follow-leader order is dropped, not reconstructed");
507
+ assert.equal(rLeader.squadFollowers, undefined);
508
+ assert.equal(rFollower.squadLeader, undefined);
509
+ });
test/input.test.js CHANGED
@@ -87,6 +87,16 @@ function clientFor(controller, wx, wy) {
87
  return { clientX: VW / 2 + (wx - cam.x) * cam.zoom, clientY: VH / 2 + (wy - cam.y) * cam.zoom };
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
90
  // ============================================================================
91
  // Bug 1 β€” alt-tab camera drift: a backgrounded tab doesn't reliably get a keyup, so a
92
  // pan key held at the moment of alt-tab must not stay "held" forever. A window blur is
@@ -148,7 +158,7 @@ test("commandAt still notifies on a rally order (regression: the branch that alr
148
  state.selection = [cc.id];
149
 
150
  const { clientX, clientY } = clientFor(controller, 2500, 2600);
151
- canvas.dispatchEvent(ev("contextmenu", { clientX, clientY, ctrlKey: false }));
152
 
153
  assert.deepEqual(cc.rally, { x: 2500, y: 2600, nodeId: null });
154
  assert.equal(calls(), 1, "onChange fires exactly once for the order");
@@ -164,7 +174,7 @@ test("commandAt notifies on an attack order (the headline branch the review flag
164
  reveal(state.fog, enemy.x, enemy.y); // entityAt only returns a non-player unit if it's visible
165
 
166
  const { clientX, clientY } = clientFor(controller, enemy.x, enemy.y);
167
- canvas.dispatchEvent(ev("contextmenu", { clientX, clientY, ctrlKey: false }));
168
 
169
  assert.deepEqual(attacker.order, { type: "attack", targetId: enemy.id });
170
  assert.equal(calls(), 1, "the HUD's Attack-Move button (and everything else onChange refreshes) must hear about this immediately, not on a stray HUD tick");
@@ -179,7 +189,7 @@ test("commandAt notifies on a gather order", () => {
179
  state.map.nodes.push(node);
180
 
181
  const { clientX, clientY } = clientFor(controller, node.x, node.y);
182
- canvas.dispatchEvent(ev("contextmenu", { clientX, clientY, ctrlKey: false }));
183
 
184
  assert.deepEqual(worker.order, { type: "gather", nodeId: "node-1" });
185
  assert.equal(calls(), 1);
@@ -192,12 +202,50 @@ test("commandAt notifies on the plain move fallback (nothing at the click point)
192
  state.selection = [worker.id];
193
 
194
  const { clientX, clientY } = clientFor(controller, 2800, 1400); // empty ground, no unit/building/node
195
- canvas.dispatchEvent(ev("contextmenu", { clientX, clientY, ctrlKey: false }));
196
 
197
  assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 });
198
  assert.equal(calls(), 1, "the fallback move branch must notify too, not just the rally special case");
199
  });
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  // ============================================================================
202
  // Bug 3 β€” dblclick ignored buildMode/attackMoveArmed and ran its normal
203
  // reselect-fleet-of-this-type logic regardless, silently overriding the click that had
 
87
  return { clientX: VW / 2 + (wx - cam.x) * cam.zoom, clientY: VH / 2 + (wy - cam.y) * cam.zoom };
88
  }
89
 
90
+ // A real right-click (as of the click-and-drag heading gesture) is a button-2 mousedown on the
91
+ // canvas followed by a button-2 mouseup on window β€” commandAt now fires from mouseup, not a bare
92
+ // `contextmenu` event (contextmenu's only remaining job is suppressing the browser's native
93
+ // menu). Mouse down and up at the SAME point is a plain click: no drag, so no explicit heading β€”
94
+ // byte-identical to the old single-event behaviour these tests were written against.
95
+ function rightClick(canvas, window, clientX, clientY, ctrlKey = false) {
96
+ canvas.dispatchEvent(ev("mousedown", { button: 2, clientX, clientY }));
97
+ window.dispatchEvent(ev("mouseup", { button: 2, clientX, clientY, ctrlKey }));
98
+ }
99
+
100
  // ============================================================================
101
  // Bug 1 β€” alt-tab camera drift: a backgrounded tab doesn't reliably get a keyup, so a
102
  // pan key held at the moment of alt-tab must not stay "held" forever. A window blur is
 
158
  state.selection = [cc.id];
159
 
160
  const { clientX, clientY } = clientFor(controller, 2500, 2600);
161
+ rightClick(canvas, window, clientX, clientY);
162
 
163
  assert.deepEqual(cc.rally, { x: 2500, y: 2600, nodeId: null });
164
  assert.equal(calls(), 1, "onChange fires exactly once for the order");
 
174
  reveal(state.fog, enemy.x, enemy.y); // entityAt only returns a non-player unit if it's visible
175
 
176
  const { clientX, clientY } = clientFor(controller, enemy.x, enemy.y);
177
+ rightClick(canvas, window, clientX, clientY);
178
 
179
  assert.deepEqual(attacker.order, { type: "attack", targetId: enemy.id });
180
  assert.equal(calls(), 1, "the HUD's Attack-Move button (and everything else onChange refreshes) must hear about this immediately, not on a stray HUD tick");
 
189
  state.map.nodes.push(node);
190
 
191
  const { clientX, clientY } = clientFor(controller, node.x, node.y);
192
+ rightClick(canvas, window, clientX, clientY);
193
 
194
  assert.deepEqual(worker.order, { type: "gather", nodeId: "node-1" });
195
  assert.equal(calls(), 1);
 
202
  state.selection = [worker.id];
203
 
204
  const { clientX, clientY } = clientFor(controller, 2800, 1400); // empty ground, no unit/building/node
205
+ rightClick(canvas, window, clientX, clientY);
206
 
207
  assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 });
208
  assert.equal(calls(), 1, "the fallback move branch must notify too, not just the rally special case");
209
  });
210
 
211
+ // ============================================================================
212
+ // Click-and-drag heading: a right-DRAG (mousedown then mouseup at a different point) sets the
213
+ // destination at the DRAG'S START and stamps the drag direction as an explicit facing (a plain
214
+ // click carries none β€” the two commandAt tests above already cover that byte-identical case).
215
+ // ============================================================================
216
+
217
+ test("a right-drag moves to where the drag STARTED, not where it ended", () => {
218
+ const { state, canvas, controller } = setup();
219
+ const worker = makeUnit("worker", "player", 500, 500);
220
+ state.units.set(worker.id, worker);
221
+ state.selection = [worker.id];
222
+
223
+ const start = clientFor(controller, 2800, 1400);
224
+ const end = clientFor(controller, 3200, 1400); // dragged further along +x
225
+ canvas.dispatchEvent(ev("mousedown", { button: 2, clientX: start.clientX, clientY: start.clientY }));
226
+ window.dispatchEvent(ev("mouseup", { button: 2, clientX: end.clientX, clientY: end.clientY }));
227
+
228
+ assert.deepEqual(worker.order, { type: "move", x: 2800, y: 1400 }, "destination is the drag's START point");
229
+ });
230
+
231
+ test("a right-drag stamps the drag direction as the unit's facing; a plain click clears it", () => {
232
+ const { state, canvas, controller } = setup();
233
+ const worker = makeUnit("worker", "player", 500, 500);
234
+ state.units.set(worker.id, worker);
235
+ state.selection = [worker.id];
236
+
237
+ const start = clientFor(controller, 2800, 1400);
238
+ const end = clientFor(controller, 2800, 1800); // dragged straight down (+y) from the start point
239
+ canvas.dispatchEvent(ev("mousedown", { button: 2, clientX: start.clientX, clientY: start.clientY }));
240
+ window.dispatchEvent(ev("mouseup", { button: 2, clientX: end.clientX, clientY: end.clientY }));
241
+
242
+ assert.ok(Number.isFinite(worker.facing), "the drag set an explicit facing");
243
+ assert.ok(Math.abs(worker.facing - Math.PI / 2) < 1e-6, "facing straight down (+y) is angle PI/2");
244
+
245
+ rightClick(canvas, window, start.clientX, start.clientY); // a later PLAIN click, no drag
246
+ assert.ok(!Number.isFinite(worker.facing), "a plain command with no heading clears the earlier facing");
247
+ });
248
+
249
  // ============================================================================
250
  // Bug 3 β€” dblclick ignored buildMode/attackMoveArmed and ran its normal
251
  // reselect-fleet-of-this-type logic regardless, silently overriding the click that had