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);
}