SpaceCities / engine /commands.js
Claude
Tier 3b: rank formation slots per cluster, not globally
cdae5f9 unverified
Raw History Blame
32 kB
/* ============================================================
Player/AI intent -> unit order. Both input.js (mouse/keyboard) and
ai.js (the scripted opponent) call these directly; orders take effect
immediately and the next sim tick acts on them.
============================================================ */
"use strict";
import { BUILDINGS, UNITS, canAfford, payCost, prereqsMet, canGatherType, canLogisticsType, canBuildCategory } from "./entities.js";
import { canPlaceBuilding } from "./colliders.js";
import { makeBuilding } from "./state.js";
import { formationSlots, resolveHeading, clusterUnits } from "./formation.js";
import { FREIGHTER_AI_TECH, LOGI_PRIORITIES } from "./haul.js";
import { canRecycle, beginRecycle, cancelRecycle } from "./recycle.js";
// Give a unit an order, either replacing what it's doing (a plain command)
// or appending it as a waypoint (queue = true, the Ctrl+command from input.js).
// Appending only queues when the unit is actually busy β€” a queued command to a
// fully idle unit acts immediately, so the first waypoint of a chain doesn't
// sit inert. A plain command always wipes any queued waypoints, so it cancels a
// chain cleanly. sim.js pulls the next queued order in as soon as `order` clears.
//
// A DIRECT order (anything except the follow-leader order dispatchFormation itself hands out)
// releases the unit from whatever squad it was following β€” direct control always wins. The
// squad's leader is untouched by this (a leader never carries squadLeader itself), so giving
// the LEADER a fresh order here never disbands the squad; only a follower ordered on its own
// leaves it.
function dispatch(unit, order, queue) {
if (order.type !== "follow-leader") setSquadLeader(unit, null);
if (!unit.orderQueue) unit.orderQueue = [];
unit.hold = false; // any positive order (move/attack/gather/waypoint) cancels a Hold stance
if (queue && (unit.order || unit.orderQueue.length)) {
unit.orderQueue.push(order);
} else {
unit.order = order;
unit.orderQueue = [];
}
}
// Attach/detach `unit` to/from `leader`'s squad (or clear it with leader=null). Keeps the
// relationship bidirectional and consistent: leaving a DIFFERENT leader's squad always trims
// that leader's own squadFollowers list too, so a leader never keeps dragging along a unit that
// no longer considers it the leader. Transient, session-only state (never persisted β€” see
// persist.js's serPlanet, which strips both fields and drops a live follow-leader order
// entirely rather than trying to serialize the object reference it carries).
function setSquadLeader(unit, leader) {
const old = unit.squadLeader;
if (old && old !== leader && old.squadFollowers) {
const i = old.squadFollowers.indexOf(unit);
if (i >= 0) old.squadFollowers.splice(i, 1);
}
unit.squadLeader = leader || undefined;
}
// A formation leader trails a little BELOW its slowest member's own top speed β€” an exact tie
// would leave a straggler (knocked off its slot by avoidance steering, a longer way around an
// obstacle, still catching up from a fresh spawn, …) with no way to ever close the gap and
// reform, since it can never out-pace a leader moving at its own maximum. This slack gives it
// the room to actually catch up.
const FORMATION_PACE = 0.95;
// The slowest unit's own speed across `units`, trimmed by FORMATION_PACE β€” a formation leader's
// travel pace is capped to this (order.speedCap, read by engine/movement.js's orderedSpeed) so a
// fast leader can't outrun the group it's leading. Returns undefined (leave the order uncapped)
// when there's nothing to cap to, e.g. an empty follower list.
function groupSpeedCap(units) {
const speed = units.reduce((m, u) => Math.min(m, UNITS[u.type]?.speed ?? Infinity), Infinity);
return Number.isFinite(speed) ? speed * FORMATION_PACE : undefined;
}
// Range-layered formation ranks: re-pair `units.slice(1)` (the followers) with `spots.slice(1)`
// (their assigned slots) by weapon range instead of raw selection-array order β€” a wedge or line
// otherwise interleaves a Bastion and a Breacher arbitrarily, purely by which order the player
// happened to add them to the selection. `forwardness` is each follower slot's projection onto the
// SAME heading formationSlots itself just oriented the shape around (resolveHeading, engine/
// formation.js β€” deliberately not unit-normalized, which is fine: a positive scalar multiple never
// changes which of two spots projects further forward, only relative order matters here). Units
// are sorted by (UNITS[type].range ?? Infinity) ascending, id tie-break (deterministic β€” a string
// comparison, unlike object/Map iteration order); slots are sorted by forwardness descending; the
// two lists are zipped index-for-index β€” so the shortest-ranged unit (Bastion, Skiff) lands on the
// most forward slot, the longest-ranged (Lancer, Breacher, Colossus) trail behind, and any unarmed
// unit (Mender, a freighter caught in a squad select) sinks to the rearmost slot of all β€” the
// missing-range fallback has to sort LAST, not first, or an unarmed support unit would rank as the
// single shortest range in the group and lead the charge. Returns a
// new spots array, same length and leader-first as the input, so callers keep reading `spots[i]`
// exactly as before; `spots[0]` (the leader's own slot) passes through untouched β€” the leader is a
// documented player choice (engine/formation.js header), never re-picked by a stat.
function rankSlotsByRange(units, spots, x, y, formation) {
const leaderSpot = spots[0];
const heading = resolveHeading(units, x, y, formation);
const followers = units.slice(1);
const slotOf = new Map(units.map((u, i) => [u, spots[i]]));
// Rank WITHIN each spatial cluster, not across the whole selection. formationSlots deliberately
// gives each cluster its own sub-formation β€” "spaced by each cluster's own footprint so
// sub-formations can't overlap" β€” and clusterUnits promises it "preserves each unit's relative
// position". A single global sort of all followers by range against all slots by forwardness had
// no cluster awareness in either half, so two armies far apart cross-swapped sub-formations and
// each walked the extra distance to the other's slots: the resulting shape was still correct, but
// units marched through each other to reach it, which reads as pathing jank rather than as two
// features disagreeing. Ranking per cluster honours both β€” each keeps its members, and each is
// internally range-layered, which is the whole point of the layering.
const byRange = us => us.slice().sort((a, b) => {
const ra = UNITS[a.type]?.range ?? Infinity, rb = UNITS[b.type]?.range ?? Infinity;
return ra !== rb ? ra - rb : (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
});
const byForwardness = ss => ss.slice().sort((s1, s2) => {
const f1 = (s1.x - leaderSpot.x) * heading.x + (s1.y - leaderSpot.y) * heading.y;
const f2 = (s2.x - leaderSpot.x) * heading.x + (s2.y - leaderSpot.y) * heading.y;
return f2 - f1;
});
// Cluster the WHOLE selection, exactly as formationSlots does β€” not just the followers. Excluding
// the leader first splits its own cluster-mates off into singletons, which re-creates the very
// cross-cluster swap this is fixing. The leader's slot is then held out of its group's pool,
// since spots[0] passes through untouched by design.
const leader = units[0];
const spotFor = new Map();
for (const group of clusterUnits(units)) {
const members = group.filter(u => u !== leader);
if (!members.length) continue;
const ranked = byRange(members);
const ordered = byForwardness(members.map(u => slotOf.get(u)));
ranked.forEach((u, i) => spotFor.set(u, ordered[i]));
}
return [leaderSpot, ...followers.map(u => spotFor.get(u))];
}
// The shared machinery behind issueMove/issueAttackMove/issueHoldFormation: lay the group out
// in `formation`'s shape, give the LEADER (units[0] β€” see engine/formation.js) its own real
// order via `makeLeaderOrder(point)`, and give every OTHER unit a persistent "follow-leader"
// order that keeps it at a fixed offset from the leader's LIVE position from now on
// (engine/movement.js keepFollowingLeader) β€” not just for this one command. That's what makes
// "select the leader alone and move it" drag the rest of the formation along for free: a later
// solo command to the leader (the n===1 branch below) re-uses its EXISTING squadFollowers
// (pruned of anything that's died) purely to recompute the group's speed cap; it never touches
// the followers' own orders, because they're already perpetually chasing the leader regardless
// of what NEW order the leader receives.
//
// The whole squad's travel speed is capped to its SLOWEST member (order.speedCap, read by
// engine/movement.js's orderedSpeed) so a fast leader can't outrun its own formation β€” without
// this, followers would perpetually lag behind a leader moving at its own full speed, stretching
// the shape out instead of holding it.
function dispatchFormation(units, x, y, formation, queue, makeLeaderOrder) {
const leader = units[0];
// The leader/follow squad is a PLAYER selection-UX feature β€” there's no analogous "the unit
// you built a selection around" concept for the scripted AI (or a test double with no owner
// set at all), and the existing tactical AI (kiting, retreats, feint response, wave sizing…)
// reads every one of ITS units' own real move/attack-move order directly. So anything that
// isn't a confirmed player unit skips the whole leader/follower mechanic and just gets the
// plain per-unit spread this replaces β€” one real order per unit, exactly as before this
// feature existed. (Every player-issued command always passes real player.owner==="player"
// units β€” see input.js's selectedUnits().)
if (leader.owner !== "player") {
const spots = formationSlots(units, x, y, formation);
units.forEach((u, i) => dispatch(u, makeLeaderOrder(spots[i]), queue));
return;
}
if (units.length === 1) {
const order = makeLeaderOrder({ x, y });
if (leader.squadFollowers && leader.squadFollowers.length) {
leader.squadFollowers = leader.squadFollowers.filter(f => f.hp > 0);
const cap = groupSpeedCap([leader, ...leader.squadFollowers]);
if (cap !== undefined) order.speedCap = cap;
}
dispatch(leader, order, queue);
return;
}
let spots = formationSlots(units, x, y, formation);
// Range-layered ranks (see rankSlotsByRange above): only for an opt-in SHAPED formation, never
// the legacy grid spread and never when the caller passed no formation at all (formationSlots'
// own shape default is "grid" either way) β€” the AI/owner-less path above already returned before
// reaching here, so this only ever reorders a real player leader/follower squad.
if (formation && formation.shape && formation.shape !== "grid") {
spots = rankSlotsByRange(units, spots, x, y, formation);
}
const leaderSpot = spots[0];
const newFollowers = units.slice(1);
// Redefining the squad for exactly this unit set: anyone who WAS following this leader but
// isn't part of it this time is released β€” and actually stopped, not just unlinked, so a
// dropped unit doesn't silently keep chasing a leader that no longer lists it.
if (leader.squadFollowers) {
// Iterate a SNAPSHOT: setSquadLeader(old, null) splices `old` out of this very array, and a
// for...of iterator is index-based, so mutating in place skips the next element. Dropping two
// adjacent followers used to release only the first, leaving the second with squadLeader
// pointing at a leader whose own list no longer contained it.
for (const old of [...leader.squadFollowers]) {
if (!newFollowers.includes(old)) { setSquadLeader(old, null); old.order = null; }
}
}
leader.squadFollowers = newFollowers;
const leaderOrder = makeLeaderOrder(leaderSpot);
const cap = groupSpeedCap(units);
if (cap !== undefined) leaderOrder.speedCap = cap;
dispatch(leader, leaderOrder, queue);
for (let i = 1; i < units.length; i++) {
setSquadLeader(units[i], leader);
dispatch(units[i], {
type: "follow-leader", leader,
offsetX: spots[i].x - leaderSpot.x, offsetY: spots[i].y - leaderSpot.y,
}, queue);
}
}
// A click-and-drag command's drag vector (formation.headingX/headingY) is also the direction
// the player wants the group to FACE β€” not just how the shape is oriented while travelling.
// Stamped onto every commanded unit as `facing` (read by renderShared.js's updateFacing once a
// unit stops moving, overriding the angle it would otherwise just freeze at) β€” the "give a
// formation, or a single/multiple selection, a direction to face" ask (e.g. holding a line
// facing the enemy). A PLAIN command (a click, no drag β€” no explicit heading) clears any
// earlier facing instead, so it never lingers stale once the player stops aiming their orders.
function applyFacing(units, formation) {
const hx = formation?.headingX || 0, hy = formation?.headingY || 0;
if (hx || hy) {
const angle = Math.atan2(hy, hx);
for (const u of units) u.facing = angle;
} else {
for (const u of units) delete u.facing;
}
}
// `formation` ({shape, leaderPos, headingX, headingY}, engine/formation.js) is optional β€”
// omitted, it's the original flat grid spread (byte-identical to the old behaviour), unchanged
// for any caller that doesn't pass one (the AI, and every pre-existing test).
export function issueMove(units, x, y, queue = false, formation) {
if (!units.length) return;
dispatchFormation(units, x, y, formation, queue, pt => ({ type: "move", x: pt.x, y: pt.y }));
applyFacing(units, formation);
}
export function issueGather(units, nodeId, queue = false) {
units.forEach(u => { if (canGatherType(u.type)) dispatch(u, { type: "gather", nodeId }, queue); });
}
// Assign workers to SERVICE a building β€” a standing round trip that carries its inputs in and its
// output out (engine/haul.js). `manual` keeps them on that one building until re-ordered, instead
// of the auto-logistics that re-picks the nearest needy building each cycle.
export function issueServiceBuilding(units, buildingId, queue = false) {
units.forEach(u => {
if (canLogisticsType(u.type)) dispatch(u, { type: "service", buildingId, phase: "plan", manual: true }, queue);
});
}
// Assign workers to FERRY a landed freighter β€” a standing loop that carries nearby producer
// backlog onto its hold (so it becomes its own mobile collection point) and, once there's nothing
// left to load, carries some of that hold back to the treasury (engine/haul.js updateFerry). Any
// freighter qualifies β€” this is a worker physically loading a ship, not the ship acting on its
// own; see issueSetAILogistics for that separate, research-gated mode.
export function issueFerryFreighter(units, freighterId, queue = false) {
units.forEach(u => {
if (canLogisticsType(u.type)) dispatch(u, { type: "ferry", freighterId, phase: "plan", manual: true }, queue);
});
}
// Assign workers to REPAIR a damaged own building OR wounded own unit β€” a standing job that keeps
// patching the target at WORKER_REPAIR_RATE hp/sec (engine/repair.js updateRepairJob) until it's
// fully healed, then waits there for it to take damage again, the same auto-vs-manual split
// issueServiceBuilding uses. ANY completed building qualifies (a turret, a Habitat, the Command
// Center itself) β€” not just a logistics-buffered one β€” and any mobile unit too; this is a worker
// literally patching the hull, unrelated to hauling its goods.
export function issueRepair(units, targetId, queue = false) {
units.forEach(u => {
if (canLogisticsType(u.type)) dispatch(u, { type: "repair", targetId, phase: "toSite", manual: true }, queue);
});
}
// Pin `units`' assigned home Command Center β€” an explicit player override for zoneFirst's usual
// "nearest CC by distance" guess (engine/gather.js), so the player decides which base's territory a
// worker/Mender/freighter's job search, ferry-target pick, and repair-roam all stay loyal to instead
// of pure distance ("Player is in charge of where sufficient resources are, per area"). Passive: it
// never touches whatever order the unit is already running β€” it only changes what the NEXT idle job
// search prefers. Silently no-ops any unit type that never consults a home zone (a combat unit, a
// scout) so a mixed selection right-clicking a Command Center still does the right thing for the
// units that actually care.
export function issueSetHomeBase(units, ccId) {
units.forEach(u => {
if (canLogisticsType(u.type) || UNITS[u.type]?.role === "support" || UNITS[u.type]?.role === "freighter") {
u.homeCC = ccId;
}
});
}
// Toggle a freighter's AI-LOGISTICS mode: put to work in the local haul/service chain like a
// worker (engine/sim.js updateUnit), but at its own far larger cargo capacity, burning AI Cores
// from the treasury while active (engine/haul.js payAIUpkeep). Turning it OFF always works (stand
// down is never gated); turning it ON requires its owner to have researched FREIGHTER_AI_TECH β€”
// unresearched freighters in the selection are silently skipped rather than the whole call failing,
// so a mixed selection still does what it can.
export function issueSetAILogistics(units, on, state) {
units.forEach(u => {
if (UNITS[u.type]?.role !== "freighter") return;
if (on && !state.players[u.owner]?.upgrades[FREIGHTER_AI_TECH]) return;
u.aiLogistics = on;
if (on && !u.cargo) u.cargo = { com: null, qty: 0 };
});
}
// Toggle a freighter's COLLECTION-POINT mode (engine/haul.js assignShuttle/updateFreighterShuttle):
// once its hold is full it drives itself to the nearest Command Center, banks everything, and
// returns to its ANCHOR to keep collecting. No research needed β€” unlike AI-logistics this doesn't
// fold it into the base's whole haul/service chain, it's just "empty me when I'm full". Turning it
// ON stamps the freighter's CURRENT spot as that anchor, so it comes back to wherever it was
// standing when you switched it on. Turning it OFF always works.
export function issueSetCollectPoint(units, on) {
units.forEach(u => {
if (UNITS[u.type]?.role !== "freighter") return;
u.collectPoint = on;
if (on) u.anchor = { x: u.x, y: u.y };
});
}
// Toggle a building's per-building LOGISTICS PRIORITY β€” high/normal/low, a building-panel cycle
// button (hudSelection.js) on a factory or fuel-burning power station. A pure weight on the SAME
// distance-then-id nearest-first scans every producer/factory already competes on
// (engine/haul.js priorityWeight, read by nearestBacklogProducer and assignService's scanFor):
// "keep the Reactor fed before the Smelter" without dedicating a worker to it by hand
// (order.manual, issueServiceBuilding). Same idiom as issueSetCollectPoint just above: a plain
// field flip, no cost, no prereq. Takes a single building (by id), not a unit selection β€” this is
// a property of the BUILDING being serviced, not of whoever's servicing it. An unknown building id
// is a silent no-op (the panel button that calls this only ever names the currently selected
// building); an unrecognised priority string collapses to "normal" β€” the same fallback
// engine/haul.js's priorityWeight already applies to a missing field, so a building can never end
// up in an unrecognised state.
export function issueSetLogiPriority(state, buildingId, priority) {
const b = state.buildings.get(buildingId);
if (!b) return;
b.logiPriority = LOGI_PRIORITIES.includes(priority) ? priority : "normal";
}
// Only ARMED units (or a support drone, whose 'attack' order updateSupport reinterprets
// as "advance on that foe" and mend nearby, dealing no damage) accept an attack order.
// A weaponless rider β€” a colony ship, a freighter β€” has no `attack` stat, so routing an
// order to it would make attackDamage compute `undefined + …` = NaN and permanently
// NaN-poison the target's hp (never ≀ 0, so it never dies: an un-killable ghost). Today
// only the UI filters this; the engine boundary now self-defends, so tests/AI/future
// callers can't trip it. A no-op for every valid caller, so determinism is unchanged.
export function issueAttack(units, targetId, queue = false) {
units.forEach(u => {
const def = UNITS[u.type];
if (def && (def.attack || def.role === "support")) dispatch(u, { type: "attack", targetId }, queue);
});
}
export function issueAttackMove(units, x, y, queue = false, formation) {
if (!units.length) return;
dispatchFormation(units, x, y, formation, queue, pt => ({ type: "attack-move", x: pt.x, y: pt.y }));
applyFacing(units, formation);
}
// Escort a friendly ship: each unit takes a stable slot on a protective ring around the
// target and follows it wherever it's ordered (engine/movement.js escortSlot), holding the
// formation until given another order. A combat escort still auto-acquires threats that come
// near, then reforms. The target itself is never told to escort (it's filtered out by the
// caller). `slot`/`slots` fix each unit's place at issue time, so the ring is deterministic.
export function issueEscort(units, targetId, queue = false) {
const n = units.length;
units.forEach((u, i) => dispatch(u, { type: "escort", targetId, slot: i, slots: n }, queue));
}
// Protection of a FORMATION, not one external ship: the group forms up in the chosen shape
// right where it's standing (its own current centroid) and holds there β€” the LEADER holds its
// own fixed anchor (engine/movement.js keepFormationStation), everyone else follows it there and
// keeps station relative to it (keepFollowingLeader) exactly like a hold-formation-flavoured
// move, so a later solo command to just the leader picks the whole formation back up too (see
// dispatchFormation). Combat units also take the Hold stance (issueHold's existing "stand fast,
// don't chase" rule) β€” LEADER AND FOLLOWERS alike β€” so the whole formation keeps its shape
// instead of scattering to run down a distant target; non-combat units (workers, a Mender) still
// take a slot β€” sheltering behind/inside the line β€” they just have no stance flag to set.
export function issueHoldFormation(units, shape = "grid", leaderPos = "front") {
if (!units.length) return;
let sx = 0, sy = 0;
for (const u of units) { sx += u.x; sy += u.y; }
const anchorX = sx / units.length, anchorY = sy / units.length;
dispatchFormation(units, anchorX, anchorY, { shape, leaderPos, originX: anchorX, originY: anchorY }, false,
pt => ({ type: "hold-formation", anchorX: pt.x, anchorY: pt.y, offsetX: 0, offsetY: 0 }));
for (const u of units) if (UNITS[u.type] && UNITS[u.type].role === "combat") u.hold = true;
}
// Pays the cost up front, drops a constructing building on the spot, and
// sends the chosen worker to stand at it until it finishes. Placement is
// validated (and payment withheld) before anything is committed, so a bad
// click never charges the player -- see engine/colliders.js for what
// counts as valid ground.
export function issueBuild(state, workerId, buildingType, x, y) {
const worker = state.units.get(workerId);
if (!worker) return null;
const player = state.players[worker.owner];
const def = BUILDINGS[buildingType];
if (!def) return null;
// Odyssey-only buildings (e.g. the Spaceport) can never be founded on the skirmish
// path β€” the same hard guarantee queueProduction makes for units (production.js), so
// the skirmish sim/AI stays byte-identical and can't be handed Odyssey content.
if (def.odysseyOnly && !state.endless) return null;
// A unit may only found a building whose category it's flagged for (engine/entities.js
// canBuildCategory) β€” Worker covers every category, so this only ever blocks a non-worker
// (e.g. a combat unit) from founding one.
if (!canBuildCategory(worker.type, def.category)) return null;
if (!canAfford(player.resources, def.cost)) return null;
if (!prereqsMet(state, worker.owner, def)) return null; // e.g. no founding a Foundry without a completed Barracks
if (!canPlaceBuilding(state, buildingType, x, y)) return null;
payCost(player.resources, def.cost);
const building = makeBuilding(buildingType, worker.owner, x, y, { constructing: true });
state.buildings.set(building.id, building);
setSquadLeader(worker, null); // a direct order releases the worker from any squad it was following
worker.order = { type: "build", buildingId: building.id };
return building.id;
}
// Sends more workers to help an already-founded construction site β€” no
// cost (already paid when it was placed), no new building. Extra hands
// speed the build up; see production.js's updateBuildingConstruction.
// `buildingType` resolves the site's category so only a unit eligible for THAT category can be
// sent to help, same rule as founding it (issueBuild) in the first place.
export function issueAssistBuild(units, buildingId, buildingType, queue = false) {
const category = BUILDINGS[buildingType]?.category;
units.forEach(u => { if (canBuildCategory(u.type, category)) dispatch(u, { type: "build", buildingId }, queue); });
}
// Halt: drop the active order and any queued waypoints so the unit stops
// where it stands. A combat unit still defends itself (it re-auto-acquires an
// enemy that wanders into range next tick), but it won't chase or resume a
// path β€” the standard RTS "stop" that pulls a unit out of a move or a chain.
// A direct order like this always releases the unit from a squad it was following.
// Also stands down an in-progress Recycle (engine/recycle.js) β€” Stop is the general "abort
// whatever this unit is doing" command, and a recycle left running with its order cleared would
// otherwise strand the unit: sim.js keeps skipping it every tick (it checks `recycling`, not
// `order`) with no way back short of the dedicated Cancel Recycle button.
export function issueStop(units) {
units.forEach(u => { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = false; u.recycling = null; });
}
// Recycle an OWNED, eligible unit or building for part of its cost back (engine/recycle.js β€”
// refund fraction and the in-place timer). A unit is released from its squad and any Hold stance,
// same as a direct order; a building just starts its timer where it stands, staying fully
// functional until the timer completes (see updateBuildingRecycle). Ineligible entries (a
// Command Center, something already constructing or already recycling) are silently skipped, so
// a mixed selection just recycles whatever in it actually can be.
export function issueRecycle(entities) {
entities.forEach(e => {
if (!canRecycle(e)) return;
if (e.kind === "unit") { setSquadLeader(e, null); e.hold = false; }
beginRecycle(e);
});
}
// Stand a recycle back down β€” free, since nothing was ever paid or disabled beyond refusing new
// orders (a building) or acting at all (a unit): no refund, the entity just resumes normal life.
export function issueCancelRecycle(entities) {
entities.forEach(e => { if (e.recycling) cancelRecycle(e); });
}
// Hold position: a combat unit stands its ground and fires on anything that
// wanders into weapon range, but never CHASES an auto-acquired target out of
// position (combat.js honours unit.hold) β€” the standard defensive stance for
// holding a line or a choke. Any move/attack order, or Stop, clears it.
export function issueHold(units) {
units.forEach(u => {
if (UNITS[u.type] && UNITS[u.type].role === "combat") { setSquadLeader(u, null); u.order = null; u.orderQueue = []; u.hold = true; }
});
}
// Patrol: a looping attack-move waypoint chain. Each point becomes an ordinary {type:
// 'attack-move', x, y} leg β€” engine/combat.js needs nothing extra, a patrol leg auto-engages
// anything encountered exactly like any other attack-move β€” except every leg also carries
// `patrol: true`, which engine/sim.js's pull-next-queued-order step reads to requeue a leg right
// back onto the tail the moment it's pulled off the front, so the chain cycles forever instead of
// draining to idle once the last point is reached.
// Built with the same per-unit `dispatch` plumbing every other order goes through (queue=false
// for the first point replaces whatever the unit was doing, same as a plain command; queue=true
// for the rest appends them behind it) so a fresh patrol command cleanly cancels an old one.
// Gated to combat/scout roles, the same UNITS[type].role check issueHold uses just above β€” a
// worker or freighter caught in the selection is silently skipped, like every other role-gated
// order in this file.
export function issuePatrol(units, points) {
if (!points.length) return;
units.forEach(u => {
if (!UNITS[u.type] || (UNITS[u.type].role !== "combat" && UNITS[u.type].role !== "scout")) return;
points.forEach((p, i) => dispatch(u, { type: "attack-move", x: p.x, y: p.y, patrol: true }, i > 0));
// The first point lands directly in `order` (dispatch's queue=false branch above, for i===0)
// rather than being pulled off orderQueue β€” the ONE place (engine/sim.js) that re-enqueues a
// patrol leg once it's activated. Give it that same "there's another copy of me waiting"
// treatment by hand here, or the loop would quietly drop the very first waypoint after one
// lap (leg two -> leg three -> leg two -> ... forever, leg one never revisited) instead of
// genuinely cycling through every point that was given.
u.orderQueue.push(u.order);
});
}
// Put a Ranger into autonomous scout mode (order type "scout"): it ranges the
// map on its own toward the nearest unexplored ground until re-ordered (see
// scout.js). Only scout-role units accept it, so issuing it to a mixed selection
// simply skips everything that isn't a Ranger. Clears any queued waypoints, like
// a plain command β€” the mode is persistent, not something to stack behind.
//
// A Ranger LEADING a formation keeps leading it here rather than abandoning it: its followers
// are left exactly as they are, still chasing it on their existing follow-leader order (the same
// "a new leader order never touches the followers' own orders" rule dispatchFormation's solo-move
// branch already relies on), and the scout order itself is capped to the group's speed
// (order.speedCap β€” the same group-minimum a move/attack-move/hold-formation leader gets, see
// groupSpeedCap/dispatchFormation above) so it doesn't outrun its own escorts β€” a protective scout
// leading the pack into the fog at ITS pace, not a lone unit racing off alone. A solo Ranger (no
// squadFollowers) scouts at its own full speed exactly as before β€” nothing to keep pace with.
export function issueScout(units) {
units.forEach(u => {
if (UNITS[u.type] && UNITS[u.type].role === "scout") {
setSquadLeader(u, null);
const order = { type: "scout" };
if (u.squadFollowers && u.squadFollowers.length) {
u.squadFollowers = u.squadFollowers.filter(f => f.hp > 0);
const cap = groupSpeedCap([u, ...u.squadFollowers]);
if (cap !== undefined) order.speedCap = cap;
}
u.order = order;
u.orderQueue = [];
}
});
}
// Every unit the building produces from now on walks to this point instead of
// wherever the old rally was β€” production.js reads building.rally fresh at spawn
// time, so this takes effect immediately with no other plumbing needed. If the
// rally was set ON a resource node (nodeId given), new workers spawn already
// gathering it instead of standing idle at the point β€” the standard "rally to
// minerals" convenience. Non-workers just walk to the point.
export function issueSetRally(building, x, y, nodeId = null) {
building.rally = { x, y, nodeId };
}