Spaces:
Paused
Paused
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);
}
|