Spaces:
Paused
Paused
File size: 15,764 Bytes
e3658fa 17641e3 eb17bae e5f7f16 17641e3 e5f7f16 d228ca7 8583c67 6fcb8ac 17641e3 6fe5e5b 17641e3 6fcb8ac 1b1f280 e3658fa 1b1f280 6fe5e5b e3658fa 6fe5e5b 8583c67 6fe5e5b 6fcb8ac e3658fa 17641e3 8583c67 17641e3 6d7e8fa 6fcb8ac 17641e3 6fe5e5b 9175b1f 17641e3 430c532 17641e3 1b1f280 17641e3 89beffa 4ed9122 17641e3 d228ca7 3195ab6 e5f7f16 2b50b54 e5f7f16 2b50b54 e5f7f16 6fcb8ac 17641e3 9175b1f 17641e3 e5f7f16 fca5d94 17641e3 89beffa fca5d94 89beffa e847942 89beffa e5f7f16 e847942 e5f7f16 89beffa 4ed9122 89beffa 17641e3 e847942 17641e3 32d2559 06de929 ee2f17b 32d2559 06de929 ee2f17b 32d2559 ee2f17b 32d2559 | 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 | // @ts-check
/* ============================================================
Worker gather/deposit loop: walk to node -> mine into cargo -> walk to
the nearest completed drop-off -> deposit -> repeat until the node runs
dry. A drop-off is the Command Center (see entities.js isGatherDropOff),
or a landed, player-toggled COLLECTION-POINT Hauler/Freighter closer than
any CC (unit.collectPoint β see nearestGatherDrop below) β no OTHER
building ever collects a raw haul, forward or otherwise.
============================================================ */
"use strict";
import { stepToward } from "./movement.js";
import { UNITS, BUILDINGS, isGatherDropOff, upgradeMult, freightRoom } from "./entities.js";
import { sideMod } from "./map.js";
import { hashStr } from "./rng.js";
import { isNodeDiscovered } from "./fog.js";
const ORBIT_RADIUS = 16; // workers ring the node instead of stacking on its exact center
const ARRIVE_REACH = 4;
const DROP_REACH = 30;
// How far a gatherer will look for a fresh seam of the SAME commodity once its current node runs
// dry β far enough to reach a sibling deposit in the same home cluster (UNITS.worker's own
// "~3 home seams" comment below) without sending a worker on a cross-map trek that should really
// be a deliberate player order.
const RETARGET_RADIUS = 600;
// Saturation: with `m` workers assigned to a node, the first `minerSoftCap`
// each mine at full rate and every extra at `minerFalloff` of a share, so the
// node's per-worker efficiency is the average. Floors above 0 (never softlocks
// a lone remaining seam). No cap field on the def (or no miner count, as in the
// direct-call unit tests) means no penalty β full rate, exactly as before.
/** @param {ResourceNode} node @param {*} def @returns {number} */
function miningEfficiency(node, def) {
const cap = def.minerSoftCap ?? Infinity;
const m = node.miners || 0;
if (m <= cap) return 1;
const extra = def.minerFalloff ?? 1;
return (cap + (m - cap) * extra) / m;
}
// Stable per-worker angle around the node, so a group sent to the same
// node spreads out around it instead of converging on one point.
/** @param {ResourceNode} node @param {string} unitId @returns {{x:number, y:number}} */
function orbitSpot(node, unitId) {
const angle = (hashStr(unitId) % 360) * (Math.PI / 180);
return { x: node.x + Math.cos(angle) * ORBIT_RADIUS, y: node.y + Math.sin(angle) * ORBIT_RADIUS };
}
// Called from updateGather's two depletion exits (the node is already dry when its order is
// picked up, and the deposit that drains the last of it) so a gatherer keeps working instead of
// idling at every drained seam β the AI already self-heals this way every think (aiWorkers.js
// assignIdleWorkers); this gives the player the same outcome inline, without waiting for a
// separate pass. Retargets `unit` to the NEAREST same-commodity node its OWNER has discovered
// (fog-gated via isNodeDiscovered, so a hidden cache stays hidden until scouted β same rule the
// AI's own search already follows) within RETARGET_RADIUS of the drained node, preferring one
// still under the miner soft cap so the crew doesn't just pile onto the next-nearest node that's
// already saturated. Ties (and the "prefer under-cap" partition itself) break on id, never on Map/
// array iteration order, so two same-seed runs can't diverge. Idles (unit.order = null), exactly
// as before, when nothing qualifies.
/** @param {State} state @param {Unit} unit @param {ResourceNode} node */
function nextNodeAfterDepletion(state, unit, node) {
const fog = unit.owner === "player" ? state.fog : state.fogAI;
const cap = UNITS[unit.type].minerSoftCap ?? Infinity;
let best = null, bestUnderCap = false, bestDist = Infinity;
for (const n of state.map.nodes) {
if (n.com !== node.com || n.amount <= 0 || !isNodeDiscovered(fog, n)) continue;
const dist = Math.hypot(n.x - node.x, n.y - node.y);
if (dist > RETARGET_RADIUS) continue;
const underCap = (n.miners || 0) < cap;
const better = !best
|| (underCap && !bestUnderCap)
|| (underCap === bestUnderCap && (dist < bestDist || (dist === bestDist && n.id < best.id)));
if (better) { best = n; bestUnderCap = underCap; bestDist = dist; }
}
unit.order = best ? { type: "gather", nodeId: best.id, phase: "toNode" } : null;
}
/** @param {State} state @param {Unit} unit @param {number} dt */
export function updateGather(state, unit, dt) {
const def = UNITS[unit.type];
const order = unit.order;
const node = state.map.nodesById
? state.map.nodesById.get(order.nodeId)
: state.map.nodes.find(n => n.id === order.nodeId);
if (!node) { unit.order = null; return; }
if (node.amount <= 0) { nextNodeAfterDepletion(state, unit, node); return; }
if (!order.phase) order.phase = "toNode";
if (order.phase === "toNode") {
const spot = orbitSpot(node, unit.id);
const dist = Math.hypot(spot.x - unit.x, spot.y - unit.y);
if (dist <= ARRIVE_REACH) order.phase = "mining";
else stepToward(state, unit, spot.x, spot.y, def.speed, dt);
return;
}
if (order.phase === "mining") {
// Re-tasked mid-carry to a node of a DIFFERENT commodity: don't throw the
// load away β haul it home and deposit it first, then come back to mine
// the new node. (Same commodity just tops off the existing cargo.)
if (unit.cargo.qty > 0 && unit.cargo.com && unit.cargo.com !== node.com) {
order.phase = "toDrop";
return;
}
unit.cargo.com = node.com;
const room = def.cargoCap - unit.cargo.qty;
const take = Math.min(def.gatherRate * miningEfficiency(node, def) * dt, node.amount, room);
unit.cargo.qty += take;
node.amount -= take;
if (unit.cargo.qty >= def.cargoCap - 1e-6 || node.amount <= 0) order.phase = "toDrop";
return;
}
if (order.phase === "toDrop") {
const drop = nearestGatherDrop(state, unit.owner, unit.x, unit.y);
if (!drop) { unit.order = null; return; } // no Command Center β hold the load, idle
const dist = Math.hypot(drop.x - unit.x, drop.y - unit.y);
if (dist <= DROP_REACH) {
const player = state.players[unit.owner];
// Per-side economy modifier for an asymmetric world (default 1 elsewhere):
// a richer claim banks more per haul. The Logistics doctrine's yield upgrade
// stacks on top (upgradeMult reads the researched upgrades). Applies identically
// whichever kind of drop this is β the bonus is earned at the point of harvest,
// not by however it later gets to the treasury.
const mult = sideMod(state, unit.owner, "gatherMult", 1) * upgradeMult(player.upgrades, "gatherYieldMult");
if (drop.kind === "unit") {
// A collection-point Hauler/Freighter (unit.collectPoint β see nearestGatherDrop below):
// bank into its freight hold instead of the treasury, clamped to its remaining room β
// same partial-deposit shape engine/haul.js's own depositToFreighter uses for factory
// backlog. Clamp on the RAW quantity moved (a physical hold-space constraint, exactly
// like factory backlog fills it), THEN apply the yield bonus to what actually gets
// credited β so a full hold caps how much cargo leaves the worker, not how much value it's
// worth. Whatever doesn't fit stays aboard the worker; the next "toDrop" tick re-picks the
// nearest drop and tries again (this same Hauler if it's freed up room, another one, or the
// Command Center) β no special-casing needed, nearestGatherDrop already re-runs every tick.
// Clamp the CREDITED amount, then derive how much cargo that consumes. Clamping the raw
// quantity instead and applying the bonus afterwards let a partial deposit land more than
// fits (measured: 251 into a 250 hold at the Logistics Network's 1.25x), which breaks the
// hold's capacity AND "a save round-trips identically" β persist.js truncates the excess on
// load, so continuing a game stopped being the same as saving and continuing it. That
// second half is invisible to every replay test: both runs agree until someone saves.
const credited = Math.min(unit.cargo.qty * mult, freightRoom(drop));
const rawMove = credited / mult;
if (rawMove > 0) {
drop.freight[unit.cargo.com] = (drop.freight[unit.cargo.com] || 0) + credited;
unit.cargo.qty -= rawMove;
if (unit.cargo.qty <= 1e-9) { unit.cargo.qty = 0; unit.cargo.com = null; }
}
} else {
const banked = unit.cargo.qty * mult;
player.resources[unit.cargo.com] = (player.resources[unit.cargo.com] || 0) + banked;
unit.cargo.qty = 0;
}
if (node.amount > 0) order.phase = "toNode";
else nextNodeAfterDepletion(state, unit, node); // this deposit drained the last of it β roll to the next seam or idle
} else {
stepToward(state, unit, drop.x, drop.y, def.speed, dt);
}
}
}
// The nearest COMPLETED collection point a gatherer may bank a raw haul at: a Command Center (or
// dropOff building, engine/entities.js isGatherDropOff β today that's the Command Center alone),
// OR a landed, player-toggled COLLECTION-POINT freighter (unit.collectPoint β engine/haul.js's
// assignFerry/assignShuttle idiom, HUD-toggled, no research needed) with room to spare. The
// freighter option mirrors assignFerry's own eligibility check exactly (owned, collectPoint,
// cargo-capable, actual room) β it already knows how to shuttle its own hold home
// (updateFreighterShuttle), so a gatherer or wreck-salvager banking into one instead of trekking
// all the way back to a Command Center is just another leg of the same logistics chain, not a new
// one. Closest wins, whichever kind it is β deterministic Map order breaks ties within each kind,
// and buildings are scanned before units so a building exactly as close (bestD strictly less-than)
// always wins that tie. `excludeId`, when given, skips that one BUILDING β a defensive guard
// against a worker finding its own HAUL source as its drop target and looping (engine/haul.js
// updateHaul passes its own source id here); harmless no-op while a Command Center can never
// itself be a HAUL source, and never applies to the unit scan (a Hauler is never a HAUL source).
/** @param {State} state @param {string} owner @param {number} x @param {number} y @param {string} [excludeId] @returns {Building|Unit|null} */
export function nearestGatherDrop(state, owner, x, y, excludeId) {
let best = null, bestD = Infinity;
for (const b of state.buildings.values()) {
if (b.owner !== owner || b.constructing || !isGatherDropOff(b.type)) continue;
if (excludeId && b.id === excludeId) continue;
const d = Math.hypot(b.x - x, b.y - y);
// Explicit id tie-break, matching haul.js/wreckage.js/colonyPolicy.js/wonder.js. This used to
// rely on Map insertion order β correct today, but a different KIND of guarantee from every
// neighbouring scan: one resting on a data-structure incident rather than a stated rule. It
// matters here because zoneFirst resolves zone membership by IDENTITY against this result.
if (d < bestD || (d === bestD && best && b.id < best.id)) { bestD = d; best = b; }
}
for (const u of state.units.values()) {
if (u.owner !== owner || !u.collectPoint || !UNITS[u.type]?.cargoHold) continue;
if (freightRoom(u) <= 0) continue;
const d = Math.hypot(u.x - x, u.y - y);
if (d < bestD || (d === bestD && best && u.id < best.id)) { bestD = d; best = u; }
}
return best;
}
// The nearest COMPLETED Command Center β the treasury/warehouse. Haulage delivers to it and
// supply runs pick up from it (engine/haul.js). Null if the owner has no standing Command Center.
/** @param {State} state @param {string} owner @param {number} x @param {number} y @returns {Building|null} */
export function nearestCommandCenter(state, owner, x, y) {
let best = null, bestD = Infinity;
for (const b of state.buildings.values()) {
if (b.owner !== owner || b.constructing || !BUILDINGS[b.type].isCommandCenter) continue;
const d = Math.hypot(b.x - x, b.y - y);
if (d < bestD || (d === bestD && best && b.id < best.id)) { bestD = d; best = b; }
}
return best;
}
// A Command Center's "zone" is simply whichever of an owner's CCs sits nearest to a point β no
// stored/cached assignment, so founding or losing a CC instantly redraws every boundary on the
// very next call, with nothing to invalidate. `zoneFirst` runs a caller-supplied scan TWICE: once
// restricted to the searcher's OWN zone (candidates whose nearest CC matches the searcher's), and β
// only if that comes back empty β once more with no restriction at all, today's plain global
// search. This is how haulers/servers/ferriers (engine/haul.js) and the auto-repair Mender/worker
// repair job (engine/repair.js) all stay loyal to their own base first and only "commute" to
// another one when their own genuinely has nothing queued β the fix for a multi-base empire
// where a saturated home base used to send idle labour on long, arbitrary cross-map treks the
// instant its own β€2-per-target caps filled up.
//
// A one-CC game (nearly every test, and most real matches before a player expands) has exactly one
// zone, so the FIRST pass's candidate set is identical to the plain global one β `scan(inZone)` and
// `scan(null)` return the exact same answer, and behaviour is byte-identical to before this existed.
// `scan(inZone)` receives either a same-zone predicate `(x,y) => boolean` or `null` (no restriction);
// it does its own candidate loop/tie-break and returns its best match or null either way.
//
// `homeId`, when given, is a PLAYER-ASSIGNED override (`unit.homeCC`, engine/commands.js
// issueSetHomeBase β a right-click on a Command Center with eligible units selected) that wins over
// the usual "nearest CC by distance" guess: the player decides which base's territory a worker/
// Mender/freighter stays loyal to, not just raw distance. A stale override (its CC destroyed, or
// never valid) is ignored and this falls straight back to the distance guess β self-healing, same
// "never cache, recompute from state" shape as everything else here.
//
// The two cases behave differently once the home zone comes up EMPTY, by design:
// - No override (or a stale one): the distance guess is just a heuristic, not a commitment, so an
// empty home zone still widens to the whole empire β today's plain global search, a last resort.
// - A VALID explicit override: the player deliberately assigned this base, so it's a hard
// boundary, not a suggestion. An empty zone returns null (no job) rather than reaching into
// another base's territory β the unit just waits for its OWN zone to need something, until the
// player reassigns it to a different Command Center or clears the override outright.
/** @param {State} state @param {string} owner @param {number} x @param {number} y
* @param {(inZone: ((ex:number, ey:number) => boolean)|null) => *} scan @param {string} [homeId]
* @returns {*} */
export function zoneFirst(state, owner, x, y, scan, homeId) {
const overrideCC = homeId ? state.buildings.get(homeId) : null;
const pinned = !!(overrideCC && overrideCC.owner === owner && !overrideCC.constructing && BUILDINGS[overrideCC.type]?.isCommandCenter);
const home = pinned ? overrideCC : nearestCommandCenter(state, owner, x, y);
if (!home) return scan(null);
const inZone = (ex, ey) => nearestCommandCenter(state, owner, ex, ey) === home;
const result = scan(inZone);
if (result != null) return result;
return pinned ? null : scan(null);
}
|