Almaatla commited on
Commit
4dfc2df
·
unverified ·
2 Parent(s): 000c8764ed9122

Merge pull request #61 from alma92350/claude/refinery-arsenal-research-gfzxyb

Browse files
CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. The format follows
4
  [Keep a Changelog](https://keepachangelog.com/), and the project uses
5
  [semantic versioning](https://semver.org/).
6
 
 
 
 
 
 
 
 
 
 
7
  ## [1.0.0] — 2026-07-22
8
 
9
  First tagged release. A complete, self-contained real-time strategy game plus an open-world
 
4
  [Keep a Changelog](https://keepachangelog.com/), and the project uses
5
  [semantic versioning](https://semver.org/).
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Changed
10
+
11
+ - The Refinery, Foundry, and Arsenal no longer double as forward resource drop-offs — they're
12
+ ore-tech buildings only now (Refinery for doctrine research, Foundry/Arsenal as Tier-2/Tier-3
13
+ gates). All gather and haul traffic runs straight to a Command Center; the AI no longer plants
14
+ additional Refineries out at far ore seams as decentralized collection points.
15
+
16
  ## [1.0.0] — 2026-07-22
17
 
18
  First tagged release. A complete, self-contained real-time strategy game plus an open-world
docs/logistics-design.md CHANGED
@@ -153,7 +153,12 @@ storage cascades to AI worker logic — the riskier half.
153
  **Balance note (both phases):** default caps are generous (80 / rig 120). Tune later —
154
  smaller = more logistics pressure, larger = less micro.
155
 
156
- **Phase C — Finite COLLECTION storage. ✅ DONE (player-only).**
 
 
 
 
 
157
  - The forward drop-offs (Refinery/Foundry/Arsenal — the pure `dropOff` buildings, via
158
  the new `isGatherDropOff`) get a finite `storeCap` intake buffer. A PLAYER gatherer
159
  banks its haul into that buffer (capped); a full one is skipped by `nearestGatherDrop`
@@ -319,9 +324,10 @@ Closes the two items Phase E deliberately left for later.
319
 
320
  ## Outcome
321
 
322
- All six phases shipped. Storage is finite end-to-end — collection (forward drop-offs),
323
- production output (rig + factories), and factory inputs — and workers move every good
324
- between them (gather → drop-off → haul → CC → supply → factory → haul → CC). A landed
 
325
  freighter now sits IN that chain too — a physical collection point workers can ferry to
326
  directly, or (once teched) a large-capacity autonomous hauler in its own right, paid for
327
  in AI Cores while it runs. Logistics is a standing demand on labour (and, for an automated
 
153
  **Balance note (both phases):** default caps are generous (80 / rig 120). Tune later —
154
  smaller = more logistics pressure, larger = less micro.
155
 
156
+ **Phase C — Finite COLLECTION storage. ⤺ REVERTED — Refinery/Foundry/Arsenal are no longer
157
+ drop-offs.** The forward-collection capability described below shipped, then was later
158
+ removed: the Refinery/Foundry/Arsenal no longer carry `dropOff`/`storeCap`, `isGatherDropOff`
159
+ now resolves to the Command Center alone, and the AI's forward-Refinery placement logic
160
+ (`aiEconomy.js`) was deleted along with it. Every gather/haul trip is a straight run to a
161
+ Command Center again. Left below for the historical record of what Phase C built and why.
162
  - The forward drop-offs (Refinery/Foundry/Arsenal — the pure `dropOff` buildings, via
163
  the new `isGatherDropOff`) get a finite `storeCap` intake buffer. A PLAYER gatherer
164
  banks its haul into that buffer (capped); a full one is skipped by `nearestGatherDrop`
 
324
 
325
  ## Outcome
326
 
327
+ All six phases shipped (Phase C was later reverted — see above: the Refinery/Foundry/Arsenal
328
+ are no longer drop-offs, and all collection now runs straight to the Command Center). Storage
329
+ is finite end-to-end — production output (rig + factories) and factory inputs — and workers
330
+ move every good between them (gather → CC → supply → factory → haul → CC). A landed
331
  freighter now sits IN that chain too — a physical collection point workers can ferry to
332
  directly, or (once teched) a large-capacity autonomous hauler in its own right, paid for
333
  in AI Cores while it runs. Logistics is a standing demand on labour (and, for an automated
docs/player-handbook.html CHANGED
@@ -337,7 +337,7 @@
337
  <ol class="tight">
338
  <li>Exactly one production building selected → set its rally point.</li>
339
  <li>Target is your own building under construction → assist-build it.</li>
340
- <li>Target is your own completed factory/Refinery/Foundry/Arsenal/Rig → assign a standing haul job.</li>
341
  <li>Target is your own damaged building (not caught above) → repair it.</li>
342
  <li>Target is your own undamaged Command Center → pin the selection's home base to it.</li>
343
  <li>Target is an enemy unit or building → attack it.</li>
 
337
  <ol class="tight">
338
  <li>Exactly one production building selected → set its rally point.</li>
339
  <li>Target is your own building under construction → assist-build it.</li>
340
+ <li>Target is your own completed factory/Rig → assign a standing haul job.</li>
341
  <li>Target is your own damaged building (not caught above) → repair it.</li>
342
  <li>Target is your own undamaged Command Center → pin the selection's home base to it.</li>
343
  <li>Target is an enemy unit or building → attack it.</li>
engine/ai.js CHANGED
@@ -7,11 +7,9 @@
7
  repeated waves, once each wave is big enough (or the game's dragged on
8
  long enough that it should commit anyway). Along the way it fortifies
9
  with Sentinel Turrets on the approach vector, expands to a second
10
- Command Center when its home ore runs thin, puts up a Refinery (and, on a
11
- big map, plants extra Refineries forward as resource drop-offs to shorten a
12
- long ore haul without a whole second CC), and researches both upgrades — so
13
- the player isn't the only side that gets to use crystals/radioactives,
14
- expansions, decentralized collection, or defenses.
15
 
16
  The AI plays under its own fog of war (state.fogAI) — it is NOT omniscient.
17
  It keeps one unit out scouting (updateScout), and its intel-dependent moves
 
7
  repeated waves, once each wave is big enough (or the game's dragged on
8
  long enough that it should commit anyway). Along the way it fortifies
9
  with Sentinel Turrets on the approach vector, expands to a second
10
+ Command Center when its home ore runs thin, puts up a Refinery, and
11
+ researches both upgrades — so the player isn't the only side that gets to
12
+ use crystals/radioactives, expansions, or defenses.
 
 
13
 
14
  The AI plays under its own fog of war (state.fogAI) — it is NOT omniscient.
15
  It keeps one unit out scouting (updateScout), and its intel-dependent moves
engine/aiEconomy.js CHANGED
@@ -1,7 +1,7 @@
1
  /* ============================================================
2
  AI — the economic build order: (re)seat a base from a colony ship, expand to
3
  fresh ore, raise workers/supply/Barracks and the Foundry+Arsenal tech gates,
4
- run the shared unit-production cycle with Turrets and forward Refineries, and
5
  research the doctrine. Depends on aiCommon (budget + affordability), aiWorkers
6
  (affordableOnSurface / aiDoctrine) and aiMilitary (pickNextUnitType); nothing
7
  depends back on this module, so the import graph stays acyclic.
@@ -12,7 +12,7 @@
12
  import { queueProduction, researchUpgrade } from "./production.js";
13
  import { issueBuild, issueMove } from "./commands.js";
14
  import { findPlacement } from "./colliders.js";
15
- import { BUILDINGS, UNITS, UPGRADES, canAfford, isDropOff } from "./entities.js";
16
  import { recipeOf } from "./industry.js";
17
  import { supplyUsed, supplyCap } from "./supply.js";
18
  import { isNodeDiscovered } from "./fog.js";
@@ -29,8 +29,6 @@ const CLAIM_RADIUS = 260; // a cluster with any CC this close is already
29
  const CLUSTER_RADIUS = 160; // nodes within this of an anchor sum into its cluster score
30
  const EXPANSION_STANDOFF = 70; // CC-to-anchor-node placement distance (26 CC radius + 16 node radius + clearance)
31
  const BARRACKS_BUFFER = 150; // bank kept when adding a barracks so the mix doesn't starve
32
- const FORWARD_DROP_MIN = 360; // ore worked this far from every drop-off is worth a forward Refinery drop-off
33
- const MAX_AI_REFINERIES = 3; // hard cap so forward drop-offs never run away with the AI's ore
34
  const COLONY_ARRIVE = 40; // Odyssey: an in-flight colony ship this close to its target deploys (ai.js expansion)
35
 
36
  // Odyssey found/survive: no Command Center but a colony ship in hand -> deploy in place. Budget-exempt so even a 1-APM neighbour always seats a base (else it mis-reads as pacified). Skirmish: colonyShip is null -> no-op.
@@ -263,7 +261,7 @@ function standingArmyCap(state, ctx) {
263
  return Infinity;
264
  }
265
 
266
- // The shared unit-production cycle across every idle Barracks (Foundry/Refinery reserves held back), Sentinel Turrets along the approach lane, a second Barracks, and the research Refinery plus forward drop-off Refineries out at far seams.
267
  /** @param {State} state @param {AiContext} ctx */
268
  export function aiProduceAndFortify(state, ctx) {
269
  const { allBarracks, ai, archetype, strategy, cc, barracks, workers, buildings, army } = ctx;
@@ -324,39 +322,15 @@ export function aiProduceAndFortify(state, ctx) {
324
  if (spot && canAct(state) && issueBuild(state, pickBuilder(workers, spot.x, spot.y).id, "barracks", spot.x, spot.y)) spend(state);
325
  }
326
 
327
- // REFINERY & FORWARD DROP-OFFS. A Refinery both researches the AI's doctrine
328
- // and doubles as a resource drop-off (entities.js isDropOff). So the AI builds
329
- // its FIRST near home for the research (reserve-aware, kept safe behind the
330
- // base) — then, once a macro AI is hauling ore a long way from every drop-off,
331
- // it plants ADDITIONAL Refineries out at those far seams: cheap, decentralized
332
- // collection points that shorten the haul without the cost of a whole second
333
- // Command Center. Forward drop-offs spend genuine surplus only (the expansion
334
- // reserve and a mix buffer stay untouched) and are capped, and no seam on a
335
- // small map is ever far enough to trigger one — so it fires exactly on the big
336
- // maps where the fixed home cluster can't reach the deposits. cc-guarded: cc is
337
- // the completed-only find, so a home CC lost mid-expansion leaves it undefined.
338
  const refineries = buildings.filter(b => b.type === "refinery");
339
- const dropoffs = buildings.filter(b => !b.constructing && isDropOff(b.type));
340
- const fwdAnchor = forwardDropoffAnchor(state, workers, dropoffs);
341
  const buildResearchRefinery = refineries.length === 0; // ungated by archetype, as before
342
- const buildForwardDropoff = archetype.wantsRefinery && refineries.length > 0
343
- && refineries.length < MAX_AI_REFINERIES && !refineries.some(r => r.constructing) && !!fwdAnchor;
344
- if ((buildResearchRefinery || buildForwardDropoff) && barracks && !barracks.constructing && cc && workers.length > 0) {
345
- // The research build banks behind an expansion; a forward drop-off spends
346
- // only genuine surplus (keeps the expansion reserve AND a mix buffer back).
347
- const keep = buildForwardDropoff ? ctx.oreReserve + BARRACKS_BUFFER : ctx.oreReserve;
348
- if (canAffordKeeping(ai.resources, BUILDINGS.refinery.cost, keep)) {
349
- let spot;
350
- if (buildForwardDropoff) {
351
- const toward = Math.atan2(cc.y - fwdAnchor.y, cc.x - fwdAnchor.x); // home side of the far cluster
352
- spot = findPlacement(state, "refinery",
353
- fwdAnchor.x + Math.cos(toward) * EXPANSION_STANDOFF,
354
- fwdAnchor.y + Math.sin(toward) * EXPANSION_STANDOFF);
355
- } else {
356
- spot = findPlacement(state, "refinery", cc.x - 90, cc.y - 90);
357
- }
358
- if (spot && canAct(state) && issueBuild(state, pickBuilder(workers, spot.x, spot.y).id, "refinery", spot.x, spot.y)) spend(state);
359
- }
360
  }
361
  }
362
 
@@ -430,31 +404,3 @@ function bestExpansionCluster(state, myCCs) {
430
  }
431
  return best;
432
  }
433
-
434
- // The ore seam worth a forward Refinery drop-off: the richest cluster the AI is
435
- // ACTIVELY hauling from that sits beyond FORWARD_DROP_MIN of every existing
436
- // drop-off. Keying it off ore workers are really mining (not just any charted
437
- // seam) keeps the drop-off on the AI's own side — workers pick the nearest ore,
438
- // so a "far" worked seam is far from home, never across the map at the enemy —
439
- // and only fires it when the round trip is genuinely long. Returns null when
440
- // every worked seam is already inside a drop-off's reach, which is always the
441
- // case on a small map, so the forward drop-off is a big-map behaviour only.
442
- function forwardDropoffAnchor(state, workers, dropoffs) {
443
- if (!dropoffs.length) return null;
444
- const nodeById = state.map.nodesById || new Map(state.map.nodes.map(n => [n.id, n]));
445
- const seen = new Set();
446
- let best = null, bestCluster = -Infinity;
447
- for (const w of workers) {
448
- if (!w.order || w.order.type !== "gather") continue;
449
- const n = nodeById.get(w.order.nodeId);
450
- if (!n || n.com !== "ore" || n.amount <= 0 || seen.has(n.id)) continue;
451
- seen.add(n.id);
452
- const dDrop = Math.min(...dropoffs.map(d => Math.hypot(d.x - n.x, d.y - n.y)));
453
- if (dDrop < FORWARD_DROP_MIN) continue; // already inside an existing drop-off's haul
454
- let cluster = 0;
455
- for (const m of state.map.nodes)
456
- if (m.com === "ore" && m.amount > 0 && Math.hypot(m.x - n.x, m.y - n.y) <= CLUSTER_RADIUS) cluster += m.amount;
457
- if (cluster > bestCluster) { bestCluster = cluster; best = n; }
458
- }
459
- return best;
460
- }
 
1
  /* ============================================================
2
  AI — the economic build order: (re)seat a base from a colony ship, expand to
3
  fresh ore, raise workers/supply/Barracks and the Foundry+Arsenal tech gates,
4
+ run the shared unit-production cycle with Turrets and the Refinery, and
5
  research the doctrine. Depends on aiCommon (budget + affordability), aiWorkers
6
  (affordableOnSurface / aiDoctrine) and aiMilitary (pickNextUnitType); nothing
7
  depends back on this module, so the import graph stays acyclic.
 
12
  import { queueProduction, researchUpgrade } from "./production.js";
13
  import { issueBuild, issueMove } from "./commands.js";
14
  import { findPlacement } from "./colliders.js";
15
+ import { BUILDINGS, UNITS, UPGRADES, canAfford } from "./entities.js";
16
  import { recipeOf } from "./industry.js";
17
  import { supplyUsed, supplyCap } from "./supply.js";
18
  import { isNodeDiscovered } from "./fog.js";
 
29
  const CLUSTER_RADIUS = 160; // nodes within this of an anchor sum into its cluster score
30
  const EXPANSION_STANDOFF = 70; // CC-to-anchor-node placement distance (26 CC radius + 16 node radius + clearance)
31
  const BARRACKS_BUFFER = 150; // bank kept when adding a barracks so the mix doesn't starve
 
 
32
  const COLONY_ARRIVE = 40; // Odyssey: an in-flight colony ship this close to its target deploys (ai.js expansion)
33
 
34
  // Odyssey found/survive: no Command Center but a colony ship in hand -> deploy in place. Budget-exempt so even a 1-APM neighbour always seats a base (else it mis-reads as pacified). Skirmish: colonyShip is null -> no-op.
 
261
  return Infinity;
262
  }
263
 
264
+ // The shared unit-production cycle across every idle Barracks (Foundry/Refinery reserves held back), Sentinel Turrets along the approach lane, a second Barracks, and the research Refinery.
265
  /** @param {State} state @param {AiContext} ctx */
266
  export function aiProduceAndFortify(state, ctx) {
267
  const { allBarracks, ai, archetype, strategy, cc, barracks, workers, buildings, army } = ctx;
 
322
  if (spot && canAct(state) && issueBuild(state, pickBuilder(workers, spot.x, spot.y).id, "barracks", spot.x, spot.y)) spend(state);
323
  }
324
 
325
+ // REFINERY. Hosts the AI's doctrine research, so it builds exactly one, near
326
+ // home (reserve-aware, kept safe behind the base). cc-guarded: cc is the
327
+ // completed-only find, so a home CC lost mid-expansion leaves it undefined.
 
 
 
 
 
 
 
 
328
  const refineries = buildings.filter(b => b.type === "refinery");
 
 
329
  const buildResearchRefinery = refineries.length === 0; // ungated by archetype, as before
330
+ if (buildResearchRefinery && barracks && !barracks.constructing && cc && workers.length > 0
331
+ && canAffordKeeping(ai.resources, BUILDINGS.refinery.cost, ctx.oreReserve)) {
332
+ const spot = findPlacement(state, "refinery", cc.x - 90, cc.y - 90);
333
+ if (spot && canAct(state) && issueBuild(state, pickBuilder(workers, spot.x, spot.y).id, "refinery", spot.x, spot.y)) spend(state);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  }
335
  }
336
 
 
404
  }
405
  return best;
406
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/entities.js CHANGED
@@ -33,8 +33,6 @@ export const BUILDINGS = {
33
  id: "refinery", name: "Refinery", hp: 400, radius: 18,
34
  cost: { ore: 200 }, buildTime: 16,
35
  sight: 140,
36
- dropOff: true, // doubles as a resource drop-off — see the dropOff note below
37
- storeCap: 100, // a PLAYER forward drop-off's finite intake buffer (engine/haul.js) — a full one reroutes gatherers
38
  // No `produces` — it researches upgrades instead of building units,
39
  // which is also what keeps it out of the rally-point UI (input.js
40
  // only offers that for buildings with a `produces` list).
@@ -63,28 +61,24 @@ export const BUILDINGS = {
63
  foundry: {
64
  id: "foundry", name: "Foundry", hp: 450, radius: 18,
65
  cost: { ore: 175 }, buildTime: 22, sight: 140,
66
- dropOff: true, // an industrial building doubles as a resource drop-off
67
- storeCap: 100, // a PLAYER forward drop-off's finite intake buffer (engine/haul.js)
68
  // The military tech gate: a pure prerequisite building (no `produces`, so
69
  // it stays out of the rally UI). It unlocks the Tier-2 combat units
70
  // (Lancer, Breacher) — the Barracks still trains them. Ore-only on purpose,
71
  // so the Tier-2 units stay reachable on every world (ore is guaranteed near
72
  // every base) rather than being walled off on a crystal-poor map.
73
  requires: ["barracks"],
74
- // Category is "industrial" (not "military") despite gating Tier-2 combat units: it's also a
75
- // forward drop-off (dropOff+storeCap above), same as the Refinery/Arsenal.
76
  category: "industrial",
77
  },
78
  arsenal: {
79
  id: "arsenal", name: "Arsenal", hp: 550, radius: 18,
80
  cost: { ore: 220 }, buildTime: 26, sight: 140,
81
- dropOff: true, // an industrial building doubles as a resource drop-off
82
- storeCap: 100, // a PLAYER forward drop-off's finite intake buffer (engine/haul.js)
83
  // The Tier-3 gate, one step past the Foundry: unlocks the Dreadnought
84
  // capital unit. Also a pure gate (no `produces`), ore-only so the tech path
85
  // stays reachable on every world.
86
  requires: ["foundry"],
87
- category: "industrial", // same reasoning as the Foundry above — dropOff+storeCap, not just a tech gate
88
  },
89
  spaceport: {
90
  id: "spaceport", name: "Spaceport", hp: 600, radius: 22,
@@ -304,22 +298,21 @@ export function prereqsMet(state, owner, def) {
304
  });
305
  }
306
 
307
- // A building is a resource drop-off if it's the Command Center or an industrial
308
- // building flagged `dropOff` (Refinery, Foundry, Arsenal). Workers deposit their
309
- // haul at the NEAREST drop-off (see gather.js), so planting one of these cheaper,
310
- // faster industrial buildings forward shortens a distant mining run without the
311
- // cost of a whole second Command Center — a decentralized economy where the CC
312
- // still keeps its unique roles (training workers, granting supply). Single source
313
- // of truth for the routing, tests, and any future UI.
314
  export function isDropOff(type) {
315
  const def = BUILDINGS[type];
316
  return !!(def && (def.isCommandCenter || def.dropOff));
317
  }
318
 
319
- // Where a gatherer may DEPOSIT a raw haul: the Command Center or a pure forward drop-off
320
- // (Refinery/Foundry/Arsenal) — NOT a factory, which drops its raws-as-inputs elsewhere and
321
- // whose `store` is its refined OUTPUT buffer, not a raw-intake bin. So the gather loop and
322
- // the finite-intake logic (engine/gather.js) share one definition of a "collection point".
 
323
  export function isGatherDropOff(type) {
324
  const def = BUILDINGS[type];
325
  return !!(def && (def.isCommandCenter || (def.dropOff && !def.recipe)));
@@ -359,8 +352,8 @@ export function canLogisticsType(unitType) {
359
  // building that doesn't ALREADY live on the power economy (no recipe to run, no Power to grant,
360
  // no rig/wonder draw) and has something worth boosting (`produces` a unit, or `supplyGrants`).
361
  // So the factories (already power-hungry), Reactors/Generators (they GRANT power), turrets and
362
- // pure drop-offs are all excluded. Pure def predicate — deterministic, shared by the draw math,
363
- // the boost application, and the HUD toggle.
364
  export function isElectrifiable(type) {
365
  const def = BUILDINGS[type];
366
  if (!def) return false;
 
33
  id: "refinery", name: "Refinery", hp: 400, radius: 18,
34
  cost: { ore: 200 }, buildTime: 16,
35
  sight: 140,
 
 
36
  // No `produces` — it researches upgrades instead of building units,
37
  // which is also what keeps it out of the rally-point UI (input.js
38
  // only offers that for buildings with a `produces` list).
 
61
  foundry: {
62
  id: "foundry", name: "Foundry", hp: 450, radius: 18,
63
  cost: { ore: 175 }, buildTime: 22, sight: 140,
 
 
64
  // The military tech gate: a pure prerequisite building (no `produces`, so
65
  // it stays out of the rally UI). It unlocks the Tier-2 combat units
66
  // (Lancer, Breacher) — the Barracks still trains them. Ore-only on purpose,
67
  // so the Tier-2 units stay reachable on every world (ore is guaranteed near
68
  // every base) rather than being walled off on a crystal-poor map.
69
  requires: ["barracks"],
70
+ // Category is "industrial" (not "military") despite gating Tier-2 combat units —
71
+ // it groups with the other production-tech buildings in the build menu.
72
  category: "industrial",
73
  },
74
  arsenal: {
75
  id: "arsenal", name: "Arsenal", hp: 550, radius: 18,
76
  cost: { ore: 220 }, buildTime: 26, sight: 140,
 
 
77
  // The Tier-3 gate, one step past the Foundry: unlocks the Dreadnought
78
  // capital unit. Also a pure gate (no `produces`), ore-only so the tech path
79
  // stays reachable on every world.
80
  requires: ["foundry"],
81
+ category: "industrial", // same reasoning as the Foundry above
82
  },
83
  spaceport: {
84
  id: "spaceport", name: "Spaceport", hp: 600, radius: 22,
 
298
  });
299
  }
300
 
301
+ // A building is a resource drop-off if it's the Command Center or a building flagged
302
+ // `dropOff` in its def (the Odyssey-only factory chain — smelter, assembler, chipfab,
303
+ // etc. — flags this; no skirmish building besides the Command Center does). Workers
304
+ // deposit their haul at the NEAREST drop-off (see gather.js). Single source of truth
305
+ // for the routing, tests, and any future UI.
 
 
306
  export function isDropOff(type) {
307
  const def = BUILDINGS[type];
308
  return !!(def && (def.isCommandCenter || def.dropOff));
309
  }
310
 
311
+ // Where a gatherer may DEPOSIT a raw haul: the Command Center, or a `dropOff` building
312
+ // with no `recipe` — NOT a factory, which drops its raws-as-inputs elsewhere and whose
313
+ // `store` is its refined OUTPUT buffer, not a raw-intake bin. In practice every `dropOff`
314
+ // building in the roster carries a `recipe` (the Odyssey factory chain), so this resolves
315
+ // to the Command Center alone — there is no forward/decentralized collection point.
316
  export function isGatherDropOff(type) {
317
  const def = BUILDINGS[type];
318
  return !!(def && (def.isCommandCenter || (def.dropOff && !def.recipe)));
 
352
  // building that doesn't ALREADY live on the power economy (no recipe to run, no Power to grant,
353
  // no rig/wonder draw) and has something worth boosting (`produces` a unit, or `supplyGrants`).
354
  // So the factories (already power-hungry), Reactors/Generators (they GRANT power), turrets and
355
+ // other non-producers (Refinery, Foundry, Arsenal, Datacenter) are all excluded. Pure def
356
+ // predicate — deterministic, shared by the draw math, the boost application, and the HUD toggle.
357
  export function isElectrifiable(type) {
358
  const def = BUILDINGS[type];
359
  if (!def) return false;
engine/gather.js CHANGED
@@ -2,15 +2,15 @@
2
  /* ============================================================
3
  Worker gather/deposit loop: walk to node -> mine into cargo -> walk to
4
  the nearest completed drop-off -> deposit -> repeat until the node runs
5
- dry. A drop-off is the Command Center OR any industrial building that
6
- proxies it (Refinery, Foundry, Arsenal — see entities.js isDropOff), so
7
- a forward industrial building shortens a distant haul without a full CC.
8
  ============================================================ */
9
 
10
  "use strict";
11
 
12
  import { stepToward } from "./movement.js";
13
- import { UNITS, BUILDINGS, isGatherDropOff, storeRoom, upgradeMult } from "./entities.js";
14
  import { sideMod } from "./map.js";
15
  import { hashStr } from "./rng.js";
16
 
@@ -77,7 +77,7 @@ export function updateGather(state, unit, dt) {
77
 
78
  if (order.phase === "toDrop") {
79
  const drop = nearestGatherDrop(state, unit.owner, unit.x, unit.y);
80
- if (!drop) { unit.order = null; return; } // no collection point with room (and no CC) → hold the load, idle
81
  const dist = Math.hypot(drop.x - unit.x, drop.y - unit.y);
82
  if (dist <= DROP_REACH) {
83
  const player = state.players[unit.owner];
@@ -87,49 +87,28 @@ export function updateGather(state, unit, dt) {
87
  const banked = unit.cargo.qty
88
  * sideMod(state, unit.owner, "gatherMult", 1)
89
  * upgradeMult(player.upgrades, "gatherYieldMult");
90
- // A PLAYER forward drop-off (Refinery/Foundry/Arsenal) has a FINITE intake buffer that
91
- // workers must haul to the Command Center (engine/haul.js). Bank what fits into it; the
92
- // Command Center (and every AI drop-off) is the bottomless treasury as before. Any
93
- // overflow rides on in the cargo — the gatherer reroutes to another drop-off next tick.
94
- if (unit.owner === "player" && !BUILDINGS[drop.type].isCommandCenter) {
95
- const put = Math.min(banked, storeRoom(drop));
96
- drop.store = drop.store || {};
97
- drop.store[unit.cargo.com] = (drop.store[unit.cargo.com] || 0) + put;
98
- unit.cargo.qty -= banked > 0 ? unit.cargo.qty * (put / banked) : unit.cargo.qty;
99
- if (unit.cargo.qty <= 1e-6) unit.cargo.qty = 0;
100
- } else {
101
- player.resources[unit.cargo.com] = (player.resources[unit.cargo.com] || 0) + banked;
102
- unit.cargo.qty = 0;
103
- }
104
- if (unit.cargo.qty <= 1e-6) {
105
- order.phase = node.amount > 0 ? "toNode" : null;
106
- if (!order.phase) unit.order = null;
107
- }
108
- // else: a partial deposit left cargo on board → stay in toDrop and reroute next tick.
109
  } else {
110
  stepToward(state, unit, drop.x, drop.y, def.speed, dt);
111
  }
112
  }
113
  }
114
 
115
- // The nearest COMPLETED collection point a gatherer may bank a raw haul at: its own Command
116
- // Center or a pure forward drop-off (Refinery/Foundry/Arsenal — engine/entities.js
117
- // isGatherDropOff). A PLAYER's forward drop-off has a finite intake buffer, so a FULL one is
118
- // skipped and the gatherer reroutes to the next-nearest with room (the Command Center never
119
- // fills). AI drop-offs are the bottomless treasury as before — so AI gather routing is
120
- // byte-identical. Closest wins, deterministic Map order breaks ties. `excludeId`, when given,
121
- // skips that one building — a HAUL job's own source is ALSO a valid drop-off (a Foundry/Refinery/
122
- // Arsenal is both a haul-able collection point AND a forward drop-off), so without this a worker
123
- // standing right on it after loading would just find itself the nearest "drop", unload straight
124
- // back into the pile it just took the load from, and reload — never actually leaving (engine/
125
- // haul.js updateHaul passes its own source id here).
126
  /** @param {State} state @param {string} owner @param {number} x @param {number} y @param {string} [excludeId] @returns {Building|null} */
127
  export function nearestGatherDrop(state, owner, x, y, excludeId) {
128
  let best = null, bestD = Infinity;
129
  for (const b of state.buildings.values()) {
130
  if (b.owner !== owner || b.constructing || !isGatherDropOff(b.type)) continue;
131
  if (excludeId && b.id === excludeId) continue;
132
- if (owner === "player" && !BUILDINGS[b.type].isCommandCenter && storeRoom(b) <= 0) continue; // full forward buffer → skip
133
  const d = Math.hypot(b.x - x, b.y - y);
134
  if (d < bestD) { bestD = d; best = b; }
135
  }
@@ -137,8 +116,7 @@ export function nearestGatherDrop(state, owner, x, y, excludeId) {
137
  }
138
 
139
  // The nearest COMPLETED Command Center — the treasury/warehouse. Haulage delivers to it and
140
- // supply runs pick up from it (engine/haul.js), so goods flow through the CC, not sideways
141
- // between forward drop-offs. Null if the owner has no standing Command Center.
142
  /** @param {State} state @param {string} owner @param {number} x @param {number} y @returns {Building|null} */
143
  export function nearestCommandCenter(state, owner, x, y) {
144
  let best = null, bestD = Infinity;
 
2
  /* ============================================================
3
  Worker gather/deposit loop: walk to node -> mine into cargo -> walk to
4
  the nearest completed drop-off -> deposit -> repeat until the node runs
5
+ dry. A drop-off is the Command Center (see entities.js isGatherDropOff) —
6
+ there is no forward/decentralized collection point, so every haul goes
7
+ all the way back to a CC.
8
  ============================================================ */
9
 
10
  "use strict";
11
 
12
  import { stepToward } from "./movement.js";
13
+ import { UNITS, BUILDINGS, isGatherDropOff, upgradeMult } from "./entities.js";
14
  import { sideMod } from "./map.js";
15
  import { hashStr } from "./rng.js";
16
 
 
77
 
78
  if (order.phase === "toDrop") {
79
  const drop = nearestGatherDrop(state, unit.owner, unit.x, unit.y);
80
+ if (!drop) { unit.order = null; return; } // no Command Center → hold the load, idle
81
  const dist = Math.hypot(drop.x - unit.x, drop.y - unit.y);
82
  if (dist <= DROP_REACH) {
83
  const player = state.players[unit.owner];
 
87
  const banked = unit.cargo.qty
88
  * sideMod(state, unit.owner, "gatherMult", 1)
89
  * upgradeMult(player.upgrades, "gatherYieldMult");
90
+ player.resources[unit.cargo.com] = (player.resources[unit.cargo.com] || 0) + banked;
91
+ unit.cargo.qty = 0;
92
+ order.phase = node.amount > 0 ? "toNode" : null;
93
+ if (!order.phase) unit.order = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  } else {
95
  stepToward(state, unit, drop.x, drop.y, def.speed, dt);
96
  }
97
  }
98
  }
99
 
100
+ // The nearest COMPLETED collection point a gatherer may bank a raw haul at (engine/entities.js
101
+ // isGatherDropOff — today, always the Command Center; there is no forward/decentralized
102
+ // collection point). Closest wins, deterministic Map order breaks ties. `excludeId`, when given,
103
+ // skips that one building — a defensive guard against a worker finding its own HAUL source as its
104
+ // drop target and looping (engine/haul.js updateHaul passes its own source id here); harmless
105
+ // no-op while a Command Center can never itself be a HAUL source.
 
 
 
 
 
106
  /** @param {State} state @param {string} owner @param {number} x @param {number} y @param {string} [excludeId] @returns {Building|null} */
107
  export function nearestGatherDrop(state, owner, x, y, excludeId) {
108
  let best = null, bestD = Infinity;
109
  for (const b of state.buildings.values()) {
110
  if (b.owner !== owner || b.constructing || !isGatherDropOff(b.type)) continue;
111
  if (excludeId && b.id === excludeId) continue;
 
112
  const d = Math.hypot(b.x - x, b.y - y);
113
  if (d < bestD) { bestD = d; best = b; }
114
  }
 
116
  }
117
 
118
  // The nearest COMPLETED Command Center — the treasury/warehouse. Haulage delivers to it and
119
+ // supply runs pick up from it (engine/haul.js). Null if the owner has no standing Command Center.
 
120
  /** @param {State} state @param {string} owner @param {number} x @param {number} y @returns {Building|null} */
121
  export function nearestCommandCenter(state, owner, x, y) {
122
  let best = null, bestD = Infinity;
engine/haul.js CHANGED
@@ -5,12 +5,11 @@
5
  Buildings buffer goods locally (engine/entities.js store/input) and workers move
6
  them. Three jobs:
7
 
8
- - HAUL: a one-way run for a PURE producer (the Plasma Rig; the forward drop-offs) —
9
- buildings that only OUTPUT. The worker walks there, loads a cargo, and carries it to
10
- whichever is nearer RIGHT NOW — its own Command Center, or a closer forward drop-off
11
- (Refinery/Foundry/Arsenal) — the same "closest drop point at the moment" pick a raw
12
- gatherer already makes (gather.js nearestGatherDrop), re-checked every tick of the
13
- walk so a nearer option that appears or frees up room mid-trip is taken immediately.
14
 
15
  - SERVICE: a ROUND TRIP for a FACTORY, or a one-way INPUT-ONLY run for a fuel-burning
16
  power station (the Combustion Generator, the Biomass Reactor — def.combust). A factory
@@ -78,7 +77,7 @@
78
  "use strict";
79
 
80
  import { stepToward } from "./movement.js";
81
- import { UNITS, BUILDINGS, storeTotal, storeCapOf, storeRoom, inputRoom, freightUsed, freightRoom } from "./entities.js";
82
  import { nearestCommandCenter, nearestGatherDrop, zoneFirst } from "./gather.js";
83
  import { recipeOf } from "./industry.js";
84
 
@@ -237,8 +236,8 @@ function neededInput(building, needs, res) {
237
  return want;
238
  }
239
 
240
- // The nearest own PURE producer (an output buffer, no recipe: the Plasma Rig, the forward
241
- // drop-offs) whose buffer is at least `minFraction` full and isn't already served by MAX_HAULERS —
242
  // shared by assignHaul's auto-assignment (minFraction defaults to ASSIGN_FRACTION: don't pull a
243
  // fresh worker off other work for a trivial backlog) and a FERRY worker's "plan" phase (which
244
  // passes 0: a worker already dedicated to a freighter, with nothing else to do, should pick up
@@ -350,14 +349,11 @@ export function assignFerry(state, unit) {
350
  }
351
 
352
  /**
353
- * Advance a HAUL job: walk to the producer → load a cargo → carry it to the NEAREST place that'll
354
- * take it right now — its own Command Center, or a closer forward drop-off (Refinery/Foundry/
355
- * Arsenal) planted specifically to shorten a distant run, exactly like a raw gatherer already
356
- * picks (gather.js nearestGatherDrop) — and bank it there (1:1 — the goods were already
357
- * extracted). Re-picked fresh every tick of the walk, so a nearer option that appears (or frees up
358
- * room) mid-trip is taken immediately, not just at the moment the load first filled. Repeats while
359
- * the producer has a backlog, else idle. Salvages gracefully if the producer is razed or there's
360
- * nowhere to deliver to.
361
  * @param {State} state @param {Unit} unit @param {number} dt
362
  */
363
  export function updateHaul(state, unit, dt) {
@@ -380,35 +376,19 @@ export function updateHaul(state, unit, dt) {
380
  return;
381
  }
382
  if (order.phase === "toDrop") {
383
- // Exclude the producer this load just came FROM: a Foundry/Refinery/Arsenal is both a valid
384
- // haul source AND a valid forward drop-off, so without this a worker standing right on it
385
- // after loading would find itself the nearest "drop", unload straight back into the same
386
- // pile, then reload — an infinite loop that never actually leaves (see nearestGatherDrop).
387
  const drop = nearestGatherDrop(state, unit.owner, unit.x, unit.y, order.buildingId);
388
- if (!drop) { unit.order = null; return; } // nowhere with room to deliver (and no CC) → hold the load, idle
389
  if (reached(unit, drop)) {
390
- depositHaul(state, unit, drop);
391
- if (unit.cargo && unit.cargo.qty > 0) return; // a full forward drop-off took only part of it — reroute next tick
392
  order.phase = (src && !src.constructing && storeTotal(src) > 0) ? "toSource" : null;
393
  if (!order.phase) unit.order = null;
394
  } else stepToward(state, unit, drop.x, drop.y, def.speed, dt);
395
  }
396
  }
397
 
398
- // Deposit a hauler's cargo at `drop`: the bottomless treasury if it's a Command Center (bankCargo,
399
- // 1:1, always takes the whole load), or — a forward drop-off (Refinery/Foundry/Arsenal) planted
400
- // closer to the producer specifically to shorten the run — into ITS OWN finite `store` buffer,
401
- // clamped to whatever room is left (same finite-intake handling gather.js's raw gatherers already
402
- // get; a partial deposit just rides home on the cargo, and updateHaul above reroutes it next tick).
403
- function depositHaul(state, unit, drop) {
404
- if (BUILDINGS[drop.type].isCommandCenter) { bankCargo(state, unit); return; }
405
- const put = Math.min(unit.cargo.qty, storeRoom(drop));
406
- drop.store = drop.store || {};
407
- drop.store[unit.cargo.com] = (drop.store[unit.cargo.com] || 0) + put;
408
- unit.cargo.qty -= put;
409
- if (unit.cargo.qty <= 1e-6) { unit.cargo.qty = 0; unit.cargo.com = null; }
410
- }
411
-
412
  // Bank a worker's whole cargo into the owner's treasury (1:1) and empty it.
413
  function bankCargo(state, unit) {
414
  if (!unit.cargo || unit.cargo.qty <= 0) return;
 
5
  Buildings buffer goods locally (engine/entities.js store/input) and workers move
6
  them. Three jobs:
7
 
8
+ - HAUL: a one-way run for a PURE producer (the Plasma Rig) — a building that only
9
+ OUTPUTs. The worker walks there, loads a cargo, and carries it to its own Command
10
+ Center — the same pick a raw gatherer already makes (gather.js nearestGatherDrop),
11
+ re-checked every tick of the walk. There is no forward/decentralized collection
12
+ point, so this is always a straight run home.
 
13
 
14
  - SERVICE: a ROUND TRIP for a FACTORY, or a one-way INPUT-ONLY run for a fuel-burning
15
  power station (the Combustion Generator, the Biomass Reactor — def.combust). A factory
 
77
  "use strict";
78
 
79
  import { stepToward } from "./movement.js";
80
+ import { UNITS, BUILDINGS, storeTotal, storeCapOf, inputRoom, freightUsed, freightRoom } from "./entities.js";
81
  import { nearestCommandCenter, nearestGatherDrop, zoneFirst } from "./gather.js";
82
  import { recipeOf } from "./industry.js";
83
 
 
236
  return want;
237
  }
238
 
239
+ // The nearest own PURE producer (an output buffer, no recipe: the Plasma Rig) whose buffer
240
+ // is at least `minFraction` full and isn't already served by MAX_HAULERS —
241
  // shared by assignHaul's auto-assignment (minFraction defaults to ASSIGN_FRACTION: don't pull a
242
  // fresh worker off other work for a trivial backlog) and a FERRY worker's "plan" phase (which
243
  // passes 0: a worker already dedicated to a freighter, with nothing else to do, should pick up
 
349
  }
350
 
351
  /**
352
+ * Advance a HAUL job: walk to the producer → load a cargo → carry it to its own Command Center,
353
+ * exactly like a raw gatherer already picks (gather.js nearestGatherDrop) — and bank it there (1:1
354
+ * — the goods were already extracted). Re-picked fresh every tick of the walk. Repeats while the
355
+ * producer has a backlog, else idle. Salvages gracefully if the producer is razed or there's no
356
+ * Command Center to deliver to.
 
 
 
357
  * @param {State} state @param {Unit} unit @param {number} dt
358
  */
359
  export function updateHaul(state, unit, dt) {
 
376
  return;
377
  }
378
  if (order.phase === "toDrop") {
379
+ // excludeId guards against a worker finding its own HAUL source as its drop target and
380
+ // looping (see nearestGatherDrop) — a harmless no-op today, since a Command Center (the only
381
+ // drop target) can never itself be a HAUL source.
 
382
  const drop = nearestGatherDrop(state, unit.owner, unit.x, unit.y, order.buildingId);
383
+ if (!drop) { unit.order = null; return; } // no Command Center → hold the load, idle
384
  if (reached(unit, drop)) {
385
+ bankCargo(state, unit);
 
386
  order.phase = (src && !src.constructing && storeTotal(src) > 0) ? "toSource" : null;
387
  if (!order.phase) unit.order = null;
388
  } else stepToward(state, unit, drop.x, drop.y, def.speed, dt);
389
  }
390
  }
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  // Bank a worker's whole cargo into the owner's treasury (1:1) and empty it.
393
  function bankCargo(state, unit) {
394
  if (!unit.cargo || unit.cargo.qty <= 0) return;
hudSelection.js CHANGED
@@ -250,7 +250,7 @@ export function renderSelectionPanel() {
250
  const info = rigInfo(state, rig);
251
  return `${!!rig.paused}:${info.nuclearOk}:${Math.round(info.throttle * 10)}:${rig.digCount || 0}:${Math.round(info.progress * 4)}:${powerEfficiency(state, rig.owner, rig.x, rig.y).name}:${info.storeFull}:${Math.round((info.stored / (info.storeCap || 1)) * 10)}:${iceCoolantMult(state, rig.owner) < 1}`;
252
  })()
253
- // Rebuild a selected forward drop-off's intake line as gatherers fill it and workers clear it.
254
  + "|" + (() => {
255
  const d = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
256
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
@@ -1147,9 +1147,9 @@ function rebuildSelectionPanel(sel) {
1147
  { tip: gen.paused ? "Bring it back online, feeding the grid again" : "Take it off the grid until resumed, without demolishing it" }));
1148
  }
1149
 
1150
- // Forward drop-off (Refinery / Foundry / Arsenal): a finite INTAKE buffer gatherers bank
1151
- // raw hauls into (engine/gather.js). When it fills, gatherers reroute elsewhere until a
1152
- // worker clears it to the Command Center — so surface how full it is and whether it's full.
1153
  const drop = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
1154
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
1155
  if (drop) {
@@ -1158,8 +1158,8 @@ function rebuildSelectionPanel(sel) {
1158
  const full = storeRoom(drop) <= 1e-6;
1159
  const row = document.createElement("div");
1160
  row.className = "sel-note " + (full ? "bad" : pct >= 66 ? "warn" : "");
1161
- row.textContent = `Intake buffer ${Math.round(have)}/${cap} (${pct}%)`
1162
- + (full ? " — FULL: gatherers reroute until it's hauled off" : " — workers haul it to a Command Center");
1163
  panelEl.appendChild(row);
1164
  }
1165
 
 
250
  const info = rigInfo(state, rig);
251
  return `${!!rig.paused}:${info.nuclearOk}:${Math.round(info.throttle * 10)}:${rig.digCount || 0}:${Math.round(info.progress * 4)}:${powerEfficiency(state, rig.owner, rig.x, rig.y).name}:${info.storeFull}:${Math.round((info.stored / (info.storeCap || 1)) * 10)}:${iceCoolantMult(state, rig.owner) < 1}`;
252
  })()
253
+ // Rebuild a selected non-recipe output buffer's (the Plasma Rig) intake line as workers clear it.
254
  + "|" + (() => {
255
  const d = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
256
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
 
1147
  { tip: gen.paused ? "Bring it back online, feeding the grid again" : "Take it off the grid until resumed, without demolishing it" }));
1148
  }
1149
 
1150
+ // Any non-recipe building with a finite output buffer (storeCap, engine/entities.js) that
1151
+ // isn't the Command Center: surface how full it is and whether it's full, same finite-storage
1152
+ // idiom as the factory/Rig panels above.
1153
  const drop = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
1154
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
1155
  if (drop) {
 
1158
  const full = storeRoom(drop) <= 1e-6;
1159
  const row = document.createElement("div");
1160
  row.className = "sel-note " + (full ? "bad" : pct >= 66 ? "warn" : "");
1161
+ row.textContent = `Output buffer ${Math.round(have)}/${cap} (${pct}%)`
1162
+ + (full ? " — FULL: production stalls until it's hauled off" : " — workers haul it to a Command Center");
1163
  panelEl.appendChild(row);
1164
  }
1165
 
input.js CHANGED
@@ -239,9 +239,9 @@ export function attachInput(canvas, state, onChange) {
239
  if (workers.length) { issueAssistBuild(workers, target.id, target.type, queue); sound.playOrder(); onChange(); }
240
  return;
241
  }
242
- // A completed friendly building with logistics buffers (a factory, the Rig, a forward drop-off):
243
- // selected workers are ASSIGNED to service it — a standing round trip carrying its inputs in and
244
- // its output out (engine/haul.js), until re-ordered elsewhere.
245
  if (target && target.owner === "player" && target.kind === "building" && !target.constructing
246
  && (recipeOf(target) || storeCapOf(target.type) > 0)) {
247
  const workers = selected.filter(u => canLogisticsType(u.type));
 
239
  if (workers.length) { issueAssistBuild(workers, target.id, target.type, queue); sound.playOrder(); onChange(); }
240
  return;
241
  }
242
+ // A completed friendly building with logistics buffers (a factory, the Rig): selected workers
243
+ // are ASSIGNED to service it — a standing round trip carrying its inputs in and its output out
244
+ // (engine/haul.js), until re-ordered elsewhere.
245
  if (target && target.owner === "player" && target.kind === "building" && !target.constructing
246
  && (recipeOf(target) || storeCapOf(target.type) > 0)) {
247
  const workers = selected.filter(u => canLogisticsType(u.type));
test/ai.test.js CHANGED
@@ -717,19 +717,19 @@ test("a macro AI whose archetype prefers Assault researches Bulwark instead, onc
717
  "…not Assault's, despite that being the archetype's own stated preference");
718
  });
719
 
720
- test("on a big map the macro AI plants a forward Refinery as a drop-off to shorten a long ore haul", () => {
721
- // On a Gigantic map the deposit seams sit far from the base, so a long haul is
722
- // exactly what the drop-off mechanic is for: the AI should plant a Refinery out
723
- // by the far ore it's working, not tuck another one behind the CC.
 
 
724
  const state = createGameState({ planetId: "ferros", rng: () => 0.5, sizeMult: 3 });
725
  fundAll(state);
726
- const barracks = stockedBarracks(state); // completed Barracks at the AI base
727
  const cc = aiBuildings(state, "command")[0];
728
- // Its home research Refinery already stands, so the new path under test is the
729
- // FORWARD drop-off (the block only adds forward ones past the first).
730
  const home = makeBuilding("refinery", "ai", cc.x - 90, cc.y - 90);
731
  state.buildings.set(home.id, home);
732
- // A far AI-side ore seam — well beyond any drop-off's haul — that workers mine.
733
  const farOre = state.map.nodes
734
  .filter(n => n.com === "ore" && n.amount > 0 && n.x >= state.map.width / 2)
735
  .filter(n => Math.hypot(n.x - cc.x, n.y - cc.y) > 500)
@@ -744,20 +744,11 @@ test("on a big map the macro AI plants a forward Refinery as a drop-off to short
744
  for (let i = 0; i < 4; i++) runAI(state, THINK_INTERVAL);
745
 
746
  const refineries = aiBuildings(state, "refinery");
747
- assert.ok(refineries.length >= 2, "the AI added a forward Refinery on top of its home research one");
748
- const forward = refineries.find(r => r.id !== home.id);
749
- assert.ok(forward, "a forward Refinery exists");
750
- const dNode = Math.hypot(forward.x - farOre.x, forward.y - farOre.y);
751
- const dCC = Math.hypot(forward.x - cc.x, forward.y - cc.y);
752
- assert.ok(dNode < dCC, "the forward Refinery sits out by the far ore, not back at the base");
753
- assert.ok(dNode <= 120, "and close enough to the seam to actually serve as its drop-off");
754
- // The cap holds: no runaway third Refinery while the forward one is still going up.
755
- assert.ok(refineries.length <= 3, "forward drop-offs stay capped");
756
  });
757
 
758
- test("the forward drop-off never fires on a small map — every home seam is already in reach", () => {
759
- // The same setup on a 1x map: no seam is far enough from a drop-off, so the AI
760
- // keeps its single home Refinery and doesn't scatter forward ones.
761
  const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
762
  fundAll(state);
763
  stockedBarracks(state);
@@ -767,7 +758,7 @@ test("the forward drop-off never fires on a small map — every home seam is alr
767
  // Put workers on the AI's own home ore (as they would be) — all within reach.
768
  const oreSeams = state.map.nodes.filter(n => n.com === "ore" && n.amount > 0 &&
769
  Math.hypot(n.x - cc.x, n.y - cc.y) < 360);
770
- assert.ok(oreSeams.length > 0, "fixture: home ore is within a drop-off's reach on a small map");
771
  for (const seam of oreSeams) {
772
  const w = makeUnit("worker", "ai", seam.x, seam.y);
773
  w.order = { type: "gather", nodeId: seam.id, phase: "mining" };
@@ -776,7 +767,7 @@ test("the forward drop-off never fires on a small map — every home seam is alr
776
 
777
  for (let i = 0; i < 4; i++) runAI(state, THINK_INTERVAL);
778
 
779
- assert.equal(aiBuildings(state, "refinery").length, 1, "no forward Refinery — the home one already reaches every worked seam");
780
  });
781
 
782
  test("the AI spreads workers across ore nodes instead of over-piling one under saturation", () => {
 
717
  "…not Assault's, despite that being the archetype's own stated preference");
718
  });
719
 
720
+ test("even on a big map with ore worked far from home, the AI never plants a second Refinery", () => {
721
+ // Forward-Refinery placement (decentralized collection) was removed along with the
722
+ // Refinery's drop-off capability — the AI now only ever builds its one home research
723
+ // Refinery, regardless of map size or how far its workers are mining from it. This
724
+ // used to be exactly the scenario (a Gigantic map, ore worked far from the base) that
725
+ // triggered a second, forward-planted Refinery.
726
  const state = createGameState({ planetId: "ferros", rng: () => 0.5, sizeMult: 3 });
727
  fundAll(state);
728
+ stockedBarracks(state); // completed Barracks at the AI base
729
  const cc = aiBuildings(state, "command")[0];
 
 
730
  const home = makeBuilding("refinery", "ai", cc.x - 90, cc.y - 90);
731
  state.buildings.set(home.id, home);
732
+ // A far AI-side ore seam — well beyond the old FORWARD_DROP_MIN threshold — that workers mine.
733
  const farOre = state.map.nodes
734
  .filter(n => n.com === "ore" && n.amount > 0 && n.x >= state.map.width / 2)
735
  .filter(n => Math.hypot(n.x - cc.x, n.y - cc.y) > 500)
 
744
  for (let i = 0; i < 4; i++) runAI(state, THINK_INTERVAL);
745
 
746
  const refineries = aiBuildings(state, "refinery");
747
+ assert.equal(refineries.length, 1, "no forward Refinery — the AI only ever builds its one home Refinery");
748
+ assert.equal(refineries[0].id, home.id);
 
 
 
 
 
 
 
749
  });
750
 
751
+ test("on a small map the AI likewise keeps just its single home Refinery", () => {
 
 
752
  const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
753
  fundAll(state);
754
  stockedBarracks(state);
 
758
  // Put workers on the AI's own home ore (as they would be) — all within reach.
759
  const oreSeams = state.map.nodes.filter(n => n.com === "ore" && n.amount > 0 &&
760
  Math.hypot(n.x - cc.x, n.y - cc.y) < 360);
761
+ assert.ok(oreSeams.length > 0, "fixture: home ore is within reach on a small map");
762
  for (const seam of oreSeams) {
763
  const w = makeUnit("worker", "ai", seam.x, seam.y);
764
  w.order = { type: "gather", nodeId: seam.id, phase: "mining" };
 
767
 
768
  for (let i = 0; i < 4; i++) runAI(state, THINK_INTERVAL);
769
 
770
+ assert.equal(aiBuildings(state, "refinery").length, 1, "still just the one home Refinery");
771
  });
772
 
773
  test("the AI spreads workers across ore nodes instead of over-piling one under saturation", () => {
test/entities.test.js CHANGED
@@ -157,21 +157,19 @@ test("the Command Center is buildable but steep: the priciest, slowest structure
157
  assert.ok(BUILDINGS.command.cost.ore > BUILDINGS.refinery.cost.ore, "an expansion should out-price every other building");
158
  });
159
 
160
- test("resource drop-offs are the Command Center and the industrial buildings that proxy it", () => {
161
- // The CC and the industrial line (Refinery, Foundry, Arsenal) all bank hauls,
162
- // so a forward industrial building shortens a distant mining run without a
163
- // whole second Command Center.
164
  assert.equal(isDropOff("command"), true, "the CC is always a drop-off");
165
- assert.equal(isDropOff("refinery"), true);
166
- assert.equal(isDropOff("foundry"), true);
167
- assert.equal(isDropOff("arsenal"), true);
168
- // Troop, defense, and housing buildings are not collection points.
169
  assert.equal(isDropOff("barracks"), false, "the Barracks trains troops, it doesn't collect");
170
  assert.equal(isDropOff("turret"), false);
171
  assert.equal(isDropOff("habitat"), false);
172
  assert.equal(isDropOff("not-a-building"), false, "an unknown type is never a drop-off");
173
  // The flag is what the routing reads — keep def and predicate in sync.
174
- for (const t of ["refinery", "foundry", "arsenal"]) assert.equal(BUILDINGS[t].dropOff, true, `${t} carries the dropOff flag`);
175
  });
176
 
177
  test("the Ranger is a Command-Center recon unit: cheap, fragile, all-terrain, far-sighted", () => {
@@ -308,12 +306,11 @@ test("canBuildCategory/canBuildType: Worker builds everything, a non-worker buil
308
  });
309
 
310
  test("Foundry and Arsenal are Industrial, not Military, despite gating combat tech", () => {
311
- // Both double as forward drop-offs (dropOff+storeCap, same as Refinery) — they already
312
- // participate in the haul/service logistics system, same as Refinery.
313
  assert.equal(BUILDINGS.foundry.category, "industrial");
314
  assert.equal(BUILDINGS.arsenal.category, "industrial");
315
  assert.equal(BUILDINGS.refinery.category, "industrial");
316
- assert.ok(BUILDINGS.foundry.dropOff && BUILDINGS.arsenal.dropOff);
317
  assert.equal(BUILDINGS.barracks.category, "military");
318
  assert.equal(BUILDINGS.turret.category, "military");
319
  assert.equal(BUILDINGS.stardock.category, "military");
 
157
  assert.ok(BUILDINGS.command.cost.ore > BUILDINGS.refinery.cost.ore, "an expansion should out-price every other building");
158
  });
159
 
160
+ test("the Command Center is the only resource drop-off in the skirmish roster", () => {
161
+ // No forward/decentralized collection point: every haul goes all the way back to a CC.
 
 
162
  assert.equal(isDropOff("command"), true, "the CC is always a drop-off");
163
+ assert.equal(isDropOff("refinery"), false, "the Refinery is a research building only, not a collection point");
164
+ assert.equal(isDropOff("foundry"), false, "the Foundry is a tech gate only, not a collection point");
165
+ assert.equal(isDropOff("arsenal"), false, "the Arsenal is a tech gate only, not a collection point");
166
+ // Troop, defense, and housing buildings are not collection points either.
167
  assert.equal(isDropOff("barracks"), false, "the Barracks trains troops, it doesn't collect");
168
  assert.equal(isDropOff("turret"), false);
169
  assert.equal(isDropOff("habitat"), false);
170
  assert.equal(isDropOff("not-a-building"), false, "an unknown type is never a drop-off");
171
  // The flag is what the routing reads — keep def and predicate in sync.
172
+ for (const t of ["refinery", "foundry", "arsenal"]) assert.equal(BUILDINGS[t].dropOff, undefined, `${t} no longer carries the dropOff flag`);
173
  });
174
 
175
  test("the Ranger is a Command-Center recon unit: cheap, fragile, all-terrain, far-sighted", () => {
 
306
  });
307
 
308
  test("Foundry and Arsenal are Industrial, not Military, despite gating combat tech", () => {
309
+ // They group with the Refinery in the build menu as production-tech buildings —
310
+ // neither trains nor fights, unlike the Barracks/Turret/Star Dock roster below.
311
  assert.equal(BUILDINGS.foundry.category, "industrial");
312
  assert.equal(BUILDINGS.arsenal.category, "industrial");
313
  assert.equal(BUILDINGS.refinery.category, "industrial");
 
314
  assert.equal(BUILDINGS.barracks.category, "military");
315
  assert.equal(BUILDINGS.turret.category, "military");
316
  assert.equal(BUILDINGS.stardock.category, "military");
test/gather.test.js CHANGED
@@ -129,62 +129,29 @@ test("a constructing expansion Command Center only becomes a dropoff once it com
129
  assert.ok(state.players.player.resources.ore > before, "the finished expansion is the nearest dropoff");
130
  });
131
 
132
- test("a forward Refinery is a drop-off with a FINITE intake buffer — the haul lands there, not the treasury", () => {
133
  const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
134
  const worker = [...state.units.values()].find(u => u.owner === "player" && u.type === "worker");
135
  const node = firstNode(state, "ore");
136
  // A completed Refinery planted right on the node, far from the seeded base CC.
137
  const refinery = makeBuilding("refinery", "player", node.x + 12, node.y, { constructing: false });
138
  state.buildings.set(refinery.id, refinery);
139
- // The worker is full and standing at the node — well beyond DROP_REACH of the
140
- // base CC, so a deposit this tick can ONLY be the nearby Refinery.
141
- worker.x = node.x; worker.y = node.y;
142
- worker.cargo = { com: "ore", qty: 10 };
143
- worker.order = { type: "gather", nodeId: node.id, phase: "toDrop" };
144
- const before = state.players.player.resources.ore;
145
  const cc = [...state.buildings.values()].find(b => b.type === "command");
146
- assert.ok(Math.hypot(cc.x - worker.x, cc.y - worker.y) > 30, "the base CC is out of drop reach — only the Refinery is close");
147
-
148
- updateGather(state, worker, 0.05);
149
- assert.ok(storeTotal(refinery) > 0, "the Refinery's finite intake buffer accepted the haul");
150
- assert.equal(state.players.player.resources.ore, before, "…and it does NOT reach the treasury until a worker hauls it to the CC");
151
- assert.equal(worker.cargo.qty, 0, "cargo emptied at the forward Refinery");
152
- });
153
-
154
- test("a FULL forward drop-off reroutes the gatherer instead of accepting more", () => {
155
- const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
156
- const worker = [...state.units.values()].find(u => u.owner === "player" && u.type === "worker");
157
- const node = firstNode(state, "ore");
158
- const cc = [...state.buildings.values()].find(b => b.type === "command" && b.owner === "player");
159
- const refinery = makeBuilding("refinery", "player", node.x + 12, node.y, { constructing: false });
160
- refinery.store = { ore: 100 }; // brimming (storeCap 100) → can't take more
161
- state.buildings.set(refinery.id, refinery);
162
- worker.x = node.x; worker.y = node.y;
163
- worker.cargo = { com: "ore", qty: 10 };
164
- worker.order = { type: "gather", nodeId: node.id, phase: "toDrop" };
165
-
166
- updateGather(state, worker, 0.05);
167
- assert.equal(worker.cargo.qty, 10, "the full Refinery took nothing — the load stays aboard");
168
- assert.ok(storeTotal(refinery) <= 100, "…and its buffer didn't exceed capacity");
169
- // With the Refinery full, the gatherer is now routing to the CC (the bottomless warehouse).
170
- const toCC = Math.hypot(cc.x - worker.x, cc.y - worker.y);
171
- for (let i = 0; i < 4000 && state.players.player.resources.ore === 0; i++) updateGather(state, worker, 0.05);
172
- assert.ok(state.players.player.resources.ore > 0, "it rerouted to the Command Center and banked there");
173
- });
174
-
175
- test("a still-constructing Refinery is not yet a drop-off", () => {
176
- const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
177
- const worker = [...state.units.values()].find(u => u.owner === "player" && u.type === "worker");
178
- const node = firstNode(state, "ore");
179
- const refinery = makeBuilding("refinery", "player", node.x + 12, node.y, { constructing: true });
180
- state.buildings.set(refinery.id, refinery);
181
  worker.x = node.x; worker.y = node.y;
182
  worker.cargo = { com: "ore", qty: 10 };
183
  worker.order = { type: "gather", nodeId: node.id, phase: "toDrop" };
184
  const before = state.players.player.resources.ore;
 
185
 
186
  updateGather(state, worker, 0.05);
187
- assert.equal(state.players.player.resources.ore, before, "an unfinished Refinery banks nothing — the worker heads for the distant CC");
 
 
 
 
 
 
 
188
  });
189
 
190
  test("Logistics Network (+25% yield) banks more per haul", () => {
 
129
  assert.ok(state.players.player.resources.ore > before, "the finished expansion is the nearest dropoff");
130
  });
131
 
132
+ test("a Refinery planted right on a node is NOT a drop-off — the gatherer walks past it to the Command Center", () => {
133
  const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
134
  const worker = [...state.units.values()].find(u => u.owner === "player" && u.type === "worker");
135
  const node = firstNode(state, "ore");
136
  // A completed Refinery planted right on the node, far from the seeded base CC.
137
  const refinery = makeBuilding("refinery", "player", node.x + 12, node.y, { constructing: false });
138
  state.buildings.set(refinery.id, refinery);
 
 
 
 
 
 
139
  const cc = [...state.buildings.values()].find(b => b.type === "command");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  worker.x = node.x; worker.y = node.y;
141
  worker.cargo = { com: "ore", qty: 10 };
142
  worker.order = { type: "gather", nodeId: node.id, phase: "toDrop" };
143
  const before = state.players.player.resources.ore;
144
+ assert.ok(Math.hypot(cc.x - worker.x, cc.y - worker.y) > 30, "the base CC is out of drop reach — the Refinery sits right next to the worker");
145
 
146
  updateGather(state, worker, 0.05);
147
+ assert.equal(storeTotal(refinery), 0, "the Refinery has no intake buffer — it never banks a raw haul");
148
+ assert.equal(state.players.player.resources.ore, before, "nothing reaches the treasury yet — the worker is still walking to the CC");
149
+ assert.equal(worker.cargo.qty, 10, "cargo stays aboard, unaffected by standing next to the Refinery");
150
+
151
+ // Walk it all the way home and confirm it banks at the CC, never at the Refinery.
152
+ for (let i = 0; i < 4000 && state.players.player.resources.ore === before; i++) updateGather(state, worker, 0.05);
153
+ assert.ok(state.players.player.resources.ore > before, "it reached the Command Center and banked there");
154
+ assert.equal(storeTotal(refinery), 0, "the Refinery still received nothing");
155
  });
156
 
157
  test("Logistics Network (+25% yield) banks more per haul", () => {
test/haul.test.js CHANGED
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
  import { createGameState, makeBuilding, makeUnit } from "../engine/state.js";
4
  import { tick } from "../engine/sim.js";
5
  import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "../engine/haul.js";
6
- import { storeTotal, storeCapOf, inputTotal, inputRoom, inputCapOf } from "../engine/entities.js";
7
  import { mulberry32 } from "./_helpers.js";
8
 
9
  const near = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
@@ -41,70 +41,27 @@ test("a hauler carries a producer's buffered output to the Command Center and ba
41
  "the treasury gains exactly what the buffer held — no gather multiplier double-dips a rig's yield");
42
  });
43
 
44
- test("a hauler prefers a forward drop-off (Refinery/Foundry/Arsenal) over the Command Center when it's the closer place to deposit right now", () => {
45
  const { s, cc, workers } = base(1);
46
- // The rig sits far from the CC, with a Refinery planted right next to it — the documented
47
- // "forward drop-off shortens a distant mining run" pattern, now extended to a HAUL job.
48
- const rig = makeBuilding("plasmarig", "player", cc.x + 900, cc.y);
49
- rig.store = { ore: 60 };
50
  rig.paused = true;
51
- s.buildings.set(rig.id, rig);
 
52
  const refinery = makeBuilding("refinery", "player", rig.x + 20, rig.y);
53
  s.buildings.set(refinery.id, refinery);
54
  const w = workers[0];
55
  w.x = rig.x; w.y = rig.y;
56
  w.order = { type: "haul", buildingId: rig.id };
57
- const ccOreBefore = s.players.player.resources.ore || 0;
58
-
59
- for (let i = 0; i < 2000 && (storeTotal(rig) > 0 || (w.cargo && w.cargo.qty > 0)); i++) updateHaul(s, w, 0.05);
60
-
61
- assert.equal(storeTotal(rig), 0, "the rig's whole buffer was hauled away");
62
- assert.ok(storeTotal(refinery) > 0, "it landed in the much-closer Refinery's own buffer");
63
- assert.equal(s.players.player.resources.ore || 0, ccOreBefore,
64
- "not one trip detoured all the way to the distant Command Center while the Refinery had room");
65
- });
66
-
67
- test("a hauler whose SOURCE is itself a forward drop-off (Foundry/Refinery/Arsenal) doesn't loop delivering back to itself", () => {
68
- // Regression: a Foundry/Refinery/Arsenal is both a haul-able collection point (workers dump raw
69
- // gathers there) AND a valid drop-off in its own right. Once a worker loads a cargo right there,
70
- // it used to be the NEAREST drop-off too (distance ~0, closer than the far-off CC) — so it
71
- // "delivered" straight back into the very pile it just took from, then reloaded, forever, never
72
- // making any progress toward the Command Center.
73
- const { s, cc, workers } = base(1);
74
- const foundry = makeBuilding("foundry", "player", cc.x + 900, cc.y);
75
- foundry.store = { ore: 60 };
76
- s.buildings.set(foundry.id, foundry);
77
- const w = workers[0];
78
- w.x = foundry.x; w.y = foundry.y;
79
- w.order = { type: "haul", buildingId: foundry.id };
80
  const before = s.players.player.resources.ore || 0;
81
 
82
- for (let i = 0; i < 6000 && (storeTotal(foundry) > 0 || (w.cargo && w.cargo.qty > 0)); i++) updateHaul(s, w, 0.05);
83
-
84
- assert.equal(storeTotal(foundry), 0, "the foundry's whole buffer was hauled away instead of endlessly recycling");
85
- assert.ok(near((s.players.player.resources.ore || 0) - before, 60, 1e-3), "and it actually reached the treasury");
86
- });
87
-
88
- test("a hauler falls back to the Command Center once the closer forward drop-off's buffer is full", () => {
89
- const { s, cc, workers } = base(1);
90
- const rig = makeBuilding("plasmarig", "player", cc.x + 900, cc.y);
91
- rig.store = { ore: storeCapOf("refinery") + 40 }; // more than the Refinery can ever hold
92
- rig.paused = true;
93
- s.buildings.set(rig.id, rig);
94
- const refinery = makeBuilding("refinery", "player", rig.x + 20, rig.y);
95
- s.buildings.set(refinery.id, refinery);
96
- const w = workers[0];
97
- w.x = rig.x; w.y = rig.y;
98
- w.order = { type: "haul", buildingId: rig.id };
99
-
100
  for (let i = 0; i < 6000 && (storeTotal(rig) > 0 || (w.cargo && w.cargo.qty > 0)); i++) updateHaul(s, w, 0.05);
101
 
102
- assert.equal(storeTotal(rig), 0, "the whole (oversized) buffer eventually clears");
103
- assert.equal(storeTotal(refinery), storeCapOf("refinery"), "the Refinery filled all the way up");
104
- assert.ok((s.players.player.resources.ore || 0) > 0, "and the overflow still reached the treasury via the Command Center");
105
  });
106
 
107
- test("mid-trip re-route: a hauler already walking toward the Command Center switches to a nearer drop-off that completes along the way", () => {
108
  const { s, cc, workers } = base(1);
109
  const rig = makeBuilding("plasmarig", "player", cc.x + 900, cc.y);
110
  rig.store = { ore: 30 };
@@ -119,14 +76,14 @@ test("mid-trip re-route: a hauler already walking toward the Command Center swit
119
  assert.equal(w.order.phase, "toDrop");
120
  const midX = w.x;
121
 
122
- // ...then a Refinery completes right where the worker already is, much closer than the CC.
123
  const refinery = makeBuilding("refinery", "player", w.x + 5, w.y);
124
  s.buildings.set(refinery.id, refinery);
125
 
126
- for (let i = 0; i < 40 && w.order; i++) updateHaul(s, w, 0.05);
127
 
128
- assert.ok(storeTotal(refinery) > 0, "it re-routed to the newly-completed, much closer Refinery instead of continuing on to the CC");
129
- assert.ok(Math.abs(w.x - midX) < 50, "it didn't keep marching toward the distant CC first");
130
  });
131
 
132
  test("auto haul/service offers go to Worker, never to a non-logistics unit", () => {
@@ -396,15 +353,15 @@ test("a manually-assigned worker keeps servicing its one building", () => {
396
  assert.ok((s.players.player.resources.metals || 0) > 0, "…and keeps the metals flowing back");
397
  });
398
 
399
- test("an AI-owned forward drop-off banks straight to the treasury — no buffer, no logistics (determinism)", () => {
400
  const { s, cc } = base(7);
401
  const aiCC = [...s.buildings.values()].find(b => b.owner === "ai" && b.type === "command");
402
  const ref = makeBuilding("refinery", "ai", aiCC.x + 30, aiCC.y);
403
  s.buildings.set(ref.id, ref);
404
  for (let i = 0; i < 300; i++) tick(s, 0.1);
405
- assert.equal(storeTotal(ref), 0, "the AI's Refinery buffer stays empty — AI banks to the treasury as before");
406
  const aiLogi = [...s.units.values()].some(u => u.owner === "ai" && u.order && (u.order.type === "haul" || u.order.type === "service"));
407
- assert.equal(aiLogi, false, "and no AI worker ever hauls or services");
408
  });
409
 
410
  test("assignService is owner-scoped and skips a well-stocked, cleared factory", () => {
 
3
  import { createGameState, makeBuilding, makeUnit } from "../engine/state.js";
4
  import { tick } from "../engine/sim.js";
5
  import { updateHaul, assignHaul, updateService, assignService, countLogistics } from "../engine/haul.js";
6
+ import { storeTotal, inputTotal, inputRoom, inputCapOf } from "../engine/entities.js";
7
  import { mulberry32 } from "./_helpers.js";
8
 
9
  const near = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
 
41
  "the treasury gains exactly what the buffer held — no gather multiplier double-dips a rig's yield");
42
  });
43
 
44
+ test("a hauler ignores a nearby Refinery/Foundry/Arsenal — the load always goes to the Command Center", () => {
45
  const { s, cc, workers } = base(1);
46
+ const rig = plantRig(s, cc, { ore: 55 });
 
 
 
47
  rig.paused = true;
48
+ // A Refinery planted right next to the rig — this used to be the "forward drop-off
49
+ // shortens a distant mining run" pattern; it no longer collects anything, for any owner.
50
  const refinery = makeBuilding("refinery", "player", rig.x + 20, rig.y);
51
  s.buildings.set(refinery.id, refinery);
52
  const w = workers[0];
53
  w.x = rig.x; w.y = rig.y;
54
  w.order = { type: "haul", buildingId: rig.id };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  const before = s.players.player.resources.ore || 0;
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  for (let i = 0; i < 6000 && (storeTotal(rig) > 0 || (w.cargo && w.cargo.qty > 0)); i++) updateHaul(s, w, 0.05);
58
 
59
+ assert.equal(storeTotal(rig), 0, "the rig's whole buffer was hauled away");
60
+ assert.equal(storeTotal(refinery), 0, "the Refinery has no intake buffer — nothing landed there");
61
+ assert.ok(near((s.players.player.resources.ore || 0) - before, 55, 1e-3), "the full load reached the Command Center instead");
62
  });
63
 
64
+ test("mid-trip: a hauler already walking toward the Command Center is unaffected by a Refinery completing nearby", () => {
65
  const { s, cc, workers } = base(1);
66
  const rig = makeBuilding("plasmarig", "player", cc.x + 900, cc.y);
67
  rig.store = { ore: 30 };
 
76
  assert.equal(w.order.phase, "toDrop");
77
  const midX = w.x;
78
 
79
+ // ...then a Refinery completes right where the worker already is.
80
  const refinery = makeBuilding("refinery", "player", w.x + 5, w.y);
81
  s.buildings.set(refinery.id, refinery);
82
 
83
+ for (let i = 0; i < 40; i++) updateHaul(s, w, 0.05);
84
 
85
+ assert.equal(storeTotal(refinery), 0, "the newly-completed Refinery still isn't a valid drop — nothing lands there");
86
+ assert.ok(w.x < midX, "the worker kept walking toward the distant Command Center (lower x), unaffected");
87
  });
88
 
89
  test("auto haul/service offers go to Worker, never to a non-logistics unit", () => {
 
353
  assert.ok((s.players.player.resources.metals || 0) > 0, "…and keeps the metals flowing back");
354
  });
355
 
356
+ test("a Refinery near the AI's Command Center accumulates no buffer, and the AI never hauls or services (determinism)", () => {
357
  const { s, cc } = base(7);
358
  const aiCC = [...s.buildings.values()].find(b => b.owner === "ai" && b.type === "command");
359
  const ref = makeBuilding("refinery", "ai", aiCC.x + 30, aiCC.y);
360
  s.buildings.set(ref.id, ref);
361
  for (let i = 0; i < 300; i++) tick(s, 0.1);
362
+ assert.equal(storeTotal(ref), 0, "a Refinery has no intake buffer for any owner — it's a pure tech/research building now");
363
  const aiLogi = [...s.units.values()].some(u => u.owner === "ai" && u.order && (u.order.type === "haul" || u.order.type === "service"));
364
+ assert.equal(aiLogi, false, "the AI builds no producers, so its workers never haul or service — determinism holds");
365
  });
366
 
367
  test("assignService is owner-scoped and skips a well-stocked, cleared factory", () => {