File size: 24,916 Bytes
17641e3
 
 
 
 
 
 
 
6648f62
6d7e8fa
17641e3
5e0b657
c310233
f0783f6
da8e770
c1ba8ce
3620efb
e735d2f
79c7af7
08cde58
db667bc
fb3a2d7
15e011a
a8d58c2
34ea8bc
45b37a3
6f0f031
15e011a
17641e3
dcbdeac
f12f730
11037b3
17641e3
 
 
 
dcbdeac
 
 
 
17641e3
6d7e8fa
 
 
1b1f280
 
 
 
832f494
 
 
 
08cde58
 
 
a485a5f
 
 
32d2559
 
 
 
30197bc
 
 
 
6d7e8fa
17641e3
a8d58c2
34ea8bc
816885c
17641e3
e735d2f
17641e3
5de119a
db667bc
fb3a2d7
15e011a
b0bc371
e6d8701
79c7af7
17641e3
 
6f0f031
 
 
f0783f6
 
 
 
 
6f0f031
cae0952
 
 
 
f75beff
 
 
 
 
 
 
cae0952
f12f730
11037b3
 
17641e3
 
5330c74
 
 
3620efb
 
17641e3
 
45b37a3
 
 
 
 
 
30197bc
 
 
 
 
 
45b37a3
 
 
 
 
 
 
 
30197bc
 
 
1b1f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17641e3
845de25
3f927e2
845de25
 
 
 
9f7bfe7
 
 
 
 
 
 
 
845de25
 
17641e3
1702aab
 
 
 
 
 
79c7af7
 
 
 
 
17641e3
6f0f031
c1ba8ce
 
 
 
53d4247
c1ba8ce
17641e3
7a847ac
 
 
 
 
 
 
 
 
 
 
5e0b657
 
 
 
 
 
 
 
 
 
6f39bc5
 
5e0b657
 
 
 
 
 
 
 
6f39bc5
 
 
340ed0f
5e0b657
 
340ed0f
832f494
d443e60
6f39bc5
832f494
32d2559
 
 
 
 
 
 
 
91ad84b
17641e3
6f39bc5
 
 
 
 
 
 
 
 
 
 
17641e3
9f7bfe7
 
 
 
 
 
 
6648f62
17641e3
 
 
 
 
 
91ad84b
 
 
d443e60
 
832f494
6f39bc5
 
 
32d2559
 
 
7a847ac
 
 
8583c67
b1fda0e
8583c67
b1fda0e
4c8f450
6648f62
4c8f450
 
6648f62
 
 
 
c310233
 
 
da8e770
 
 
 
 
17641e3
 
 
 
e735d2f
17641e3
 
 
9f7bfe7
 
 
a4bbde9
 
17641e3
 
6f0f031
b0bc371
 
 
 
56dcb34
b0bc371
 
 
 
 
 
 
 
6f0f031
 
 
 
 
 
 
 
b0bc371
 
 
b1fda0e
 
 
8583c67
b1fda0e
 
6648f62
 
4c8f450
 
 
 
6648f62
 
 
 
 
 
6f0f031
 
 
 
 
6648f62
6f0f031
 
 
6648f62
6f0f031
 
b0bc371
a485a5f
 
 
 
 
 
 
 
 
 
 
 
 
56dcb34
 
 
32d2559
 
 
 
 
 
b0bc371
 
56dcb34
 
 
 
 
 
 
 
 
32d2559
 
 
 
06de929
32d2559
a485a5f
b0bc371
56dcb34
 
 
 
b0bc371
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
/* ============================================================
   Per-tick orchestrator: advances every unit and building by one fixed
   timestep, runs the AI's think cycle, and checks for a winner. This is
   the only thing engine/loop.js's `update` callback calls.
   ============================================================ */

"use strict";

import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
import { buildUnitGrid } from "./grid.js";
import { updateGather } from "./gather.js";
import { updateHaul, assignHaul, updateService, assignService, updateFerry, assignFerry, assignShuttle, updateFreighterShuttle, countLogistics, payAIUpkeep, FREIGHTER_AI_TECH } from "./haul.js";
import { updateScoutMode } from "./scout.js";
import { updateRepair, updateBulwarkRegen, pickRepairTarget, assignRepair, updateRepairJob, countRepairJobs, HEALED } from "./repair.js";
import { updateCombat, updateBuildingCombat, updateWorkerCombat } from "./combat.js";
import { updateBombFuse, updateCraters } from "./bomb.js";
import { updateWreckage } from "./wreckage.js";
import { updateBuildingConstruction, updateProductionQueue, BUILD_REACH } from "./production.js";
import { updateUnitRecycle, updateBuildingRecycle } from "./recycle.js";
import { updateProduction, updateCombustors } from "./industry.js";
import { updatePlasmaRig } from "./rig.js";
import { updateResearch } from "./techtree.js";
import { updateWonder } from "./wonder.js";
import { applySeparation } from "./separation.js";
import { updateFog } from "./fog.js";
import { UNITS, BUILDINGS, canLogisticsType } from "./entities.js";
import { getEntity } from "./state.js";
import { checkWinCondition, checkEndlessLoss, checkEndlessWin } from "./victory.js";
import { runAI } from "./ai.js";
import { updateScenario } from "./scenarios.js";
import { updateMarket } from "./market.js";
import { updateDiplomacy } from "./diplomacy.js";

export function tick(state, dt) {
  if (state.over) return;

  // Scenario mode drives the convoy/piracy/objective itself; the skirmish
  // economy AI only runs in a normal match (see engine/scenarios.js).
  if (state.scenario) updateScenario(state, dt);
  else runAI(state, dt);

  // Broad-phase spatial index for this tick, shared by movement avoidance,
  // combat acquisition, and the separation pass below (see engine/grid.js).
  state.unitGrid = buildUnitGrid(state);
  // Per-node miner count for this tick, read by gather.js's saturation falloff.
  // Frozen before any worker mines so every miner on a node sees the same count
  // regardless of Map iteration order (determinism).
  countMiners(state);
  // Per-building hauler/supplier counts for this tick, frozen before any idle worker is
  // assigned a logistics job below β€” so the "≀2 per building" caps read the same regardless
  // of Map iteration order (determinism). A no-op in skirmish: nothing there has a buffer.
  countLogistics(state);
  // Settle the fuel-burning Generators' Power BEFORE any consumer reads powerCap this tick,
  // so the grid a factory/rig sees is stable. A no-op without a Combustion Generator.
  updateCombustors(state, dt);
  // Per-building count of auto-repair Menders already committed to it, frozen before any Mender
  // re-targets below β€” so they SPREAD OUT (one per building) instead of all piling on the worst one.
  countMenderTargets(state);
  // Per-building count of workers already assigned to REPAIR it this tick, frozen before any idle
  // worker claims a fresh repair job below β€” the ≀MAX_REPAIRERS cap (engine/repair.js), same shape
  // as countLogistics above. A no-op wherever nothing is damaged.
  countRepairJobs(state);
  // Aura projectors (Aegis) for this tick, read by combat.js attackDamage. Collected
  // once here (a tiny list β€” the units are Tier-3 and rare) so a landed hit costs O(anvils)
  // not O(units); positions are frozen at tick start like the grid above (deterministic).
  collectAnvils(state);

  for (const unit of state.units.values()) updateUnit(state, unit, dt);
  applySeparation(state, dt);
  updateFog(state, state.fog, "player");
  updateFog(state, state.fogAI, "ai");   // the AI sees only what its own units/buildings reveal, same as the player
  for (const building of state.buildings.values()) {
    updateBuildingConstruction(state, building, dt);
    updateProductionQueue(state, building, dt);
    updateProduction(state, building, dt);   // Odyssey factories refine raw hauls into goods (no-op without a recipe)
    updatePlasmaRig(state, building, dt);    // Odyssey Plasma Rig digs raw materials from the core (no-op without a rig def)
    updateResearch(state, building, dt);     // Odyssey Datacenter develops the tech tree (no-op for any other building)
    updateWonder(state, building, dt);       // Odyssey Antimatter Gate charges toward the galaxy win (no-op off a wonder)
    if (state.endless && !state.background) updateDecay(building, dt);   // Odyssey structures wear out if not mended
    updateBuildingCombat(state, building, dt);
    updateBuildingRecycle(state, building, dt);   // last: may remove the building this tick (engine/commands.js issueRecycle)
  }

  // Healing runs last, once every combatant and building has taken its damage
  // for the tick, so a Mender patches the freshest wounds (see repair.js).
  updateRepair(state, dt);
  // Bulwark doctrine regen (engine/repair.js) β€” same "after this tick's combat" timing as the
  // Mender pass above, so a unit hit THIS tick (lastHitAt just stamped to the still-current
  // state.time, below) never regens on that same tick. A no-op for any player without the
  // doctrine researched, so games without it stay byte-identical.
  updateBulwarkRegen(state, dt);

  // Scenario mode settles its own win/lose inside updateScenario; Odyssey
  // (endless) only ends on losing the player's Command Center; a normal match
  // uses the CC/score victory check.
  if (state.scenario) { /* updateScenario already set state.over if finished */ }
  // checkEndlessWin runs on EVERY endless world, background included β€” a rival Gate (Phase 7)
  // usually completes on a colony the player has left, not the active seat, and its AI-owned
  // branch only ever pushes a galaxy event (never finish()), so running it in the background is
  // harmless there. checkEndlessLoss stays active-world-only as before: it already no-ops for
  // every galaxy world via state.inGalaxy, so this scoping only ever matters to a standalone
  // endless test fixture, and a colony (background) is never "over" regardless.
  else if (state.endless) { checkEndlessWin(state); if (!state.background) checkEndlessLoss(state); }
  else checkWinCondition(state);

  if (state.market) updateMarket(state, dt);       // Odyssey: relax trade pressure back toward equilibrium
  if (state.diplomacy) updateDiplomacy(state, dt);  // Odyssey: drift the neighbour's stance with scarcity
  state.time += dt;
  state.tick++;
  // A Helium Bomb crater (engine/bomb.js) matures on its own schedule, independent of any
  // unit/building β€” checked once per tick against the just-advanced state.time.
  updateCraters(state);
  // Battle wreckage (engine/wreckage.js) matures the same way, on its own schedule.
  updateWreckage(state);
}

// Collect this tick's aura projectors β€” units with a guardAura (the Aegis) AND buildings with one
// (the Aegis Bastion, entities.js) β€” into a flat list on the state, so combat.js can look up "is
// this target inside a friendly aura" against a handful of entries instead of scanning every unit
// per hit. Transient (never serialized), rebuilt each tick from live positions β€” deterministic.
// Exported so combat-only test harnesses (which drive updateCombat directly, bypassing tick) can
// build the same list before a fight.
export function collectAnvils(state) {
  const out = [];
  for (const u of state.units.values()) {
    const g = UNITS[u.type]?.guardAura;
    if (g && u.hp > 0) out.push({ id: u.id, owner: u.owner, x: u.x, y: u.y, range: g.range, mult: g.damageTakenMult });
  }
  // The static guard-aura projector: same {id,owner,x,y,range,mult} shape as the units pass
  // above, so combat.js's anvilAura (already target-kind-agnostic β€” it reads target.owner/id,
  // never target.kind) needs zero changes to pick these up too. Skips a still-constructing one
  // (no aura until it's actually finished standing).
  for (const b of state.buildings.values()) {
    const g = BUILDINGS[b.type]?.guardAura;
    if (g && b.hp > 0 && !b.constructing) out.push({ id: b.id, owner: b.owner, x: b.x, y: b.y, range: g.range, mult: g.damageTakenMult });
  }
  state.anvils = out;
}

// Tally how many workers (both sides) are assigned to gather each node this
// tick β€” the single source of truth for gather.js's saturation efficiency.
// Counts every worker on a `gather` order, not just those physically at the
// rock, so the "~3 workers per node" rule reads by intent rather than by who
// happens to be mid-haul. Recomputed from scratch each tick β€” never accumulates.
function countMiners(state) {
  for (const n of state.map.nodes) n.miners = 0;
  for (const u of state.units.values()) {
    const o = u.order;
    if (o && o.type === "gather") {
      const n = state.map.nodesById ? state.map.nodesById.get(o.nodeId) : null;
      if (n) n.miners++;
    }
  }
}

function updateUnit(state, unit, dt) {
  // Whenever the active order finishes (arrival, target killed, node drained),
  // pull in the next queued waypoint before anything else runs this tick
  // β€” so a completed step flows straight into the next, and a combat unit only
  // falls back to auto-acquiring once its whole chain is exhausted.
  if (!unit.order && unit.orderQueue && unit.orderQueue.length) {
    unit.order = unit.orderQueue.shift();
    // A patrol leg (engine/commands.js issuePatrol) loops forever: the instant it's pulled off
    // the queue and made active, requeue it at the tail too, so once THIS leg completes and the
    // next one is pulled, this one is already waiting to come back around. The very FIRST leg of
    // a fresh patrol never passes through here at all (issuePatrol places it directly into
    // `order`, same as any other plain command) β€” issuePatrol gives it the equivalent trailing
    // copy by hand for exactly that reason, so every leg ends up with one "next occurrence"
    // queued behind it and the whole chain cycles instead of draining to idle.
    if (unit.order.patrol) unit.orderQueue.push(unit.order);
  }

  const def = UNITS[unit.type];
  // Belt-and-suspenders: persist.js's cleanEntity already strips any unit with an
  // unknown type before it reaches state.units on load (test/save-hardening.test.js),
  // so def is always populated today. This just guards a future entity source that
  // might bypass that path, matching the same "if (!def) skip" style repair.js uses
  // for mender defs above.
  if (!def) return;
  // A unit the player is Recycling (engine/commands.js issueRecycle) is on its own countdown,
  // checked before anything else this tick β€” same "takes no further orders" precedent as the bomb
  // fuse right below, and before the role dispatch so EVERY role (worker, combat, support, a
  // freighter, …) is covered by the one check rather than needing it threaded into each branch.
  if (updateUnitRecycle(state, unit, dt)) return;
  if (def.role === "combat") { updateCombat(state, unit, dt); return; }
  if (def.role === "support") { updateSupport(state, unit, def, dt); return; }
  // An ARMED Helium Bomb lights its fuse the instant a live enemy comes within
  // range (or the player triggers it directly) and detonates BOMB_FUSE_DELAY
  // sim-seconds later β€” checked before anything else this tick. Once fused it
  // takes no further orders; once it actually goes off, there's nothing left
  // to update (it's no longer in state.units).
  if (def.role === "bomb" && updateBombFuse(state, unit)) return;

  // A COLLECTION-POINT freighter (`unit.collectPoint`, HUD toggle β€” no research needed) that's
  // idle and full offers itself a SHUTTLE run: drive to the Command Center, bank the whole hold,
  // drive back to its anchor (engine/haul.js assignShuttle/updateFreighterShuttle). Checked before
  // the AI-logistics offer below so a full collection-point freighter always empties itself first.
  if (!unit.order && def.role === "freighter" && unit.owner === "player" && unit.collectPoint) {
    assignShuttle(state, unit);
  }
  // Toggling collection-point mode off mid-run stands the freighter down immediately, same as
  // AI-logistics below β€” whatever's in its hold just sits there until reassigned.
  if (unit.order && unit.order.type === "shuttle" && !unit.collectPoint) { unit.order = null; }

  // An idle Odyssey worker with nothing queued offers itself for logistics: FIRST it offers to
  // ferry a nearby COLLECTION-POINT freighter (`assignFerry`) β€” checked ahead of haul/service
  // because ferry and haul share the SAME per-producer ≀MAX_HAULERS cap (engine/haul.js
  // countLogistics). If plain CC-bound haulers got first refusal, a busy rig's two slots would
  // always go to them as workers cycle through, and a collection-point freighter parked right next
  // to it would never actually get fed β€” exactly the "workers don't see it" gap this priority
  // avoids. THEN it clears a pure producer's backed-up output to a Command Center (haul β€” the Rig,
  // the drop-offs), else it runs a factory a round-trip service (carry inputs in, output back).
  // Player-only β€” the AI builds no producers/factories, so its workers never do this and its
  // replay is unchanged.
  //
  // A freighter the player has toggled into AI-logistics mode (`aiLogistics`, HUD button β€” requires
  // the FREIGHTER_AI_TECH research) offers itself the haul/service way too (never ferry β€” a
  // freighter doesn't ferry another freighter), at its own far larger cargo capacity (engine/
  // haul.js tripCapacity) β€” the order it lands is stamped `aiJob` so the upkeep check below knows
  // to bill it. Regular freighters (aiLogistics off) are untouched β€” fully player-controlled, same
  // as before. Claiming a NEW job also requires at least SOME AI Cores in stock: a freighter that
  // can't afford to move shouldn't still reserve one of a producer's scarce ≀MAX_HAULERS slots
  // doing nothing β€” an already-running job is a separate case, handled by the upkeep check below,
  // which pauses it in place rather than dropping it the instant the treasury dips dry.
  const aiFreighter = def.role === "freighter" && unit.owner === "player" && unit.aiLogistics
    && !!state.players[unit.owner].upgrades[FREIGHTER_AI_TECH]
    && (state.players[unit.owner].resources.ai || 0) > 0;
  if (!unit.order && canLogisticsType(unit.type) && unit.owner === "player") {
    assignFerry(state, unit);
  }
  if (!unit.order && (canLogisticsType(unit.type) || aiFreighter) && unit.owner === "player") {
    assignHaul(state, unit);
    if (!unit.order) assignService(state, unit);
    if (unit.order && aiFreighter) unit.order.aiJob = true;
  }
  // Still nothing to do: a worker (not a freighter β€” patching a hull isn't a cargo run) offers
  // itself to REPAIR the own damaged building most in need, zone-first like haul/service (engine/
  // repair.js assignRepair). Checked last so an economy job always outranks a repair, matching the
  // ferry > haul > service priority above β€” a stalled factory needs labour more urgently than a
  // building that's merely below full HP.
  if (!unit.order && canLogisticsType(unit.type) && unit.owner === "player") {
    assignRepair(state, unit);
  }

  if (!unit.order) return;

  // An autonomous freighter's job burns AI Cores from the treasury every tick it's active
  // (engine/haul.js payAIUpkeep). Toggling AI-logistics off mid-job stands it down immediately (its
  // cargo, if any, just sits aboard until reassigned); running dry of AI Cores pauses it in place
  // for the tick β€” nothing lost, it resumes the moment the treasury can cover it again. A plain
  // worker's order never carries `aiJob`, so this is a no-op for the whole worker roster.
  if (unit.order.aiJob) {
    if (!unit.aiLogistics || !state.players[unit.owner].upgrades[FREIGHTER_AI_TECH]) { unit.order = null; return; }
    if (!payAIUpkeep(state, unit, dt)) return;
  }

  switch (unit.order.type) {
    case "move":
    // A non-combat/non-support role (today: only "scout", the Ranger) reaches an "attack-move"
    // order here rather than through combat.js's updateCombat (role "combat" only) β€” the patrol
    // order (engine/commands.js issuePatrol) is gated to combat/scout roles alike, and a scout
    // never auto-acquires regardless of order type (that's entirely role-gated, in updateCombat),
    // so for it "attack-move" just means "get there", identically to a plain move.
    case "attack-move": {
      const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
      if (arrived) unit.order = null;
      break;
    }
    case "gather":
      updateGather(state, unit, dt);
      break;
    case "haul":
      updateHaul(state, unit, dt);
      break;
    case "service":
      updateService(state, unit, dt);
      break;
    case "ferry":
      updateFerry(state, unit, dt);
      break;
    case "repair":
      updateRepairJob(state, unit, dt);
      break;
    case "shuttle":
      updateFreighterShuttle(state, unit, dt);
      break;
    case "escort":
      // A non-combat escort (worker) just keeps station on the ring around the guarded ship.
      keepEscortStation(state, unit, def.speed, dt);
      break;
    case "hold-formation":
      // A non-combat formation LEADER (worker, freighter) holds its own fixed anchor.
      keepFormationStation(state, unit, def.speed, dt);
      break;
    case "follow-leader":
      // A non-combat formation FOLLOWER just keeps its slot relative to the leader.
      keepFollowingLeader(state, unit, def.speed, dt);
      break;
    case "scout":
      updateScoutMode(state, unit, dt);
      break;
    case "attack":
      // Workers only reach here on an explicit attack order (combat units are
      // handled by updateCombat above); they close in and fight weakly.
      updateWorkerCombat(state, unit, def, dt);
      break;
    case "build": {
      const b = state.buildings.get(unit.order.buildingId);
      if (!b) { unit.order = null; break; }
      const dist = Math.hypot(b.x - unit.x, b.y - unit.y);
      if (dist > BUILD_REACH) stepToward(state, unit, b.x, b.y, def.speed, dt);
      else if (!b.constructing) unit.order = null;
      break;
    }
    // Any order type this non-combat/non-support unit can't act on (e.g. a "gather"
    // order that reached a freighter) is dropped rather than left to stick forever
    // and wedge the unit's whole order queue. No valid caller hits this today.
    default:
      unit.order = null;
  }
}

// Odyssey structures WEAR OUT: a completed building sheds a small fraction of its max HP per second,
// down to a floor (neglect weakens it but never razes it β€” combat still can). A Mender's passive
// repair (repair.js) out-heals this easily, so keeping a mender on the base is real upkeep. Gentle
// and deterministic; only in Odyssey (a skirmish is short and its byte-identical replay is untouched).
const DECAY_FRAC = 0.0006;  // HP shed per second as a share of maxHp β€” a gentle, long-game wear (~18 min to the floor)
const DECAY_FLOOR = 0.35;   // …never below this share from wear alone
function updateDecay(building, dt) {
  if (building.constructing) return;
  const floor = building.maxHp * DECAY_FLOOR;
  if (building.hp <= floor) return;
  building.hp = Math.max(floor, building.hp - building.maxHp * DECAY_FRAC * dt);
}

// Support-role units (the Mender) carry no weapon β€” the actual healing is the
// global updateRepair pass in repair.js. All this does is MOVE them, so the
// player can position the drone: a plain move/attack-move goes to the point;
// an 'attack' order (should one ever be issued β€” input.js won't, since a Mender
// has no attack stat) is reinterpreted as "go to that foe" so the drone can be
// pushed toward a fight to mend the units in it, and it never deals damage.
function updateSupport(state, unit, def, dt) {
  const o = unit.order;
  // Idle + AUTO-REPAIR on: roam to the nearest damaged friendly so the drone actively maintains
  // the base/army instead of sitting still (the passive repair pass heals it once in reach).
  if (!o) { if (unit.autoRepair) autoRepairRoam(state, unit, def, dt); return; }
  // A support escort (Mender) trails the guarded ship in formation, mending whatever's near it
  // via the global repair pass β€” a medic keeping station on the fleet it's protecting.
  if (o.type === "escort") {
    keepEscortStation(state, unit, def.speed, dt);
    return;
  }
  // Holding a defensive formation: same idea, station-keeping at its own fixed anchor instead
  // of a moving target β€” the drone mends whatever's near it (the repair pass) right where it stands.
  if (o.type === "hold-formation") {
    keepFormationStation(state, unit, def.speed, dt);
    return;
  }
  // Following a formation leader: keeps its slot relative to the leader's LIVE position.
  if (o.type === "follow-leader") {
    keepFollowingLeader(state, unit, def.speed, dt);
    return;
  }
  let tx, ty, follow = false, speed = def.speed;
  if (o.type === "attack") {
    const t = getEntity(state, o.targetId);
    if (!t || t.hp <= 0) { unit.order = null; return; }
    tx = t.x; ty = t.y; follow = true;   // keep chasing a moving foe rather than stopping on arrival
  } else if (o.type === "move" || o.type === "attack-move") {
    tx = o.x; ty = o.y; speed = orderedSpeed(def.speed, o);
  } else {
    unit.order = null; return;   // gather / scout / build are meaningless for a drone
  }
  const arrived = stepToward(state, unit, tx, ty, speed, dt);
  if (arrived && !follow) unit.order = null;
}

// How many auto-repair Menders are already committed to each BUILDING this tick β€” frozen before any
// re-targets, so the "one Mender per building" cap reads the same regardless of Map order. Only
// buildings are capped (the pile-up the player hit); a wounded unit can still draw several menders.
// Transient (stripped on serialize). A no-op when no Mender is in auto-repair mode.
function countMenderTargets(state) {
  for (const b of state.buildings.values()) b.menderClaims = 0;
  for (const u of state.units.values()) {
    if (!u.autoRepair || !u.repairTargetId) continue;
    const t = state.buildings.get(u.repairTargetId);
    if (t) t.menderClaims = (t.menderClaims || 0) + 1;
  }
}

// An auto-repair Mender roams to the friendly that needs it MOST. Hysteresis kills the ping-pong:
// it only gets ATTRACTED to something worn past NEEDS_REPAIR, and once it commits it STAYS on that
// target until it's topped past HEALED β€” so a full building nicked by a hair of wear can't yank the
// drone back and forth. Picking a NEW target (engine/repair.js pickRepairTarget) is zone-first (a
// damaged building/unit in the Mender's own Command Center zone wins over a nearer one belonging
// to another base β€” engine/gather.js zoneFirst) and, within a zone, most-worn-first, distance
// breaking ties. A BUILDING already claimed by another auto-repair Mender is skipped, so the drones
// spread out one per building instead of dogpiling the worst one. Deterministic; drone parks when
// nothing qualifies.
function autoRepairRoam(state, mender, def, dt) {
  const range = def.repairRange || 100;
  const ownFriendly = e => e && e.owner === mender.owner && e.hp > 0 && !e.constructing;

  // Keep the current target while it still needs work (hysteresis) β€” don't drop it for a
  // marginally-worse one, and don't re-grab a just-topped-up one on its first hair of decay.
  let target = mender.repairTargetId
    ? (state.buildings.get(mender.repairTargetId) || state.units.get(mender.repairTargetId))
    : null;
  if (!(ownFriendly(target) && target !== mender && target.hp < target.maxHp * HEALED)) target = null;

  if (!target) {   // pick the MOST worn eligible friendly below the attract threshold, own zone first
    target = pickRepairTarget(state, mender.owner, mender.x, mender.y, {
      exclude: mender,
      isClaimed: e => e.kind === "building" && (e.menderClaims || 0) >= 1,   // one Mender per building
      homeId: mender.homeCC,
    });
    if (target && target.kind === "building") target.menderClaims = (target.menderClaims || 0) + 1;   // claim it for the rest of this tick
  }

  mender.repairTargetId = target ? target.id : null;
  if (target && Math.hypot(target.x - mender.x, target.y - mender.y) > range * 0.7)
    stepToward(state, mender, target.x, target.y, def.speed, dt);
}