Spaces:
Paused
Paused
File size: 32,005 Bytes
17641e3 340ed0f 1c88bda 17641e3 cdae5f9 b383918 79c7af7 3ea9b20 845de25 3f927e2 6648f62 845de25 6648f62 845de25 fea25dd 845de25 6648f62 7836482 f71d721 7836482 f71d721 b383918 22c31e3 b383918 22c31e3 b383918 cdae5f9 22c31e3 b383918 cdae5f9 b383918 cdae5f9 b383918 6648f62 f71d721 6648f62 b383918 6648f62 0da037f 6648f62 f71d721 6648f62 17641e3 845de25 340ed0f 17641e3 d443e60 340ed0f d443e60 6f39bc5 340ed0f 6f39bc5 06de929 32d2559 06de929 32d2559 6f39bc5 7a847ac b383918 a4bbde9 845de25 a4bbde9 17641e3 4c8f450 6648f62 17641e3 b1fda0e 4c8f450 6648f62 4c8f450 6648f62 4c8f450 17641e3 cae3dc6 c769ac5 cae3dc6 17641e3 a4bbde9 53ddc42 340ed0f a4bbde9 e3fdeef 1c88bda 17641e3 6648f62 17641e3 e735d2f 53ddc42 340ed0f e735d2f 9eded26 4e300be 6648f62 79c7af7 4e300be 79c7af7 fea25dd 6648f62 fea25dd 4e300be 9f7bfe7 c310233 5485eb0 f71d721 c310233 5485eb0 f71d721 5485eb0 f71d721 5485eb0 f71d721 5485eb0 c310233 4f1cb54 9eded26 | 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 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 | /* ============================================================
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 };
}
|