Spaces:
Paused
Paused
Merge pull request #61 from alma92350/claude/refinery-arsenal-research-gfzxyb
Browse files- CHANGELOG.md +9 -0
- docs/logistics-design.md +10 -4
- docs/player-handbook.html +1 -1
- engine/ai.js +3 -5
- engine/aiEconomy.js +10 -64
- engine/entities.js +15 -22
- engine/gather.js +16 -38
- engine/haul.js +18 -38
- hudSelection.js +6 -6
- input.js +3 -3
- test/ai.test.js +13 -22
- test/entities.test.js +9 -12
- test/gather.test.js +10 -43
- test/haul.test.js +16 -59
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 323 |
-
|
| 324 |
-
|
|
|
|
| 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/
|
| 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
|
| 11 |
-
|
| 12 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 328 |
-
//
|
| 329 |
-
//
|
| 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 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 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
|
| 75 |
-
//
|
| 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
|
| 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
|
| 308 |
-
//
|
| 309 |
-
//
|
| 310 |
-
//
|
| 311 |
-
//
|
| 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
|
| 320 |
-
//
|
| 321 |
-
//
|
| 322 |
-
// the
|
|
|
|
| 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 |
-
//
|
| 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
|
| 6 |
-
|
| 7 |
-
|
| 8 |
============================================================ */
|
| 9 |
|
| 10 |
"use strict";
|
| 11 |
|
| 12 |
import { stepToward } from "./movement.js";
|
| 13 |
-
import { UNITS, BUILDINGS, isGatherDropOff,
|
| 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
|
| 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 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 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
|
| 116 |
-
//
|
| 117 |
-
//
|
| 118 |
-
//
|
| 119 |
-
//
|
| 120 |
-
//
|
| 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)
|
| 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
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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,
|
| 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
|
| 241 |
-
//
|
| 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
|
| 354 |
-
*
|
| 355 |
-
*
|
| 356 |
-
*
|
| 357 |
-
*
|
| 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 |
-
//
|
| 384 |
-
//
|
| 385 |
-
//
|
| 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; } //
|
| 389 |
if (reached(unit, drop)) {
|
| 390 |
-
|
| 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
|
| 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 |
-
//
|
| 1151 |
-
//
|
| 1152 |
-
//
|
| 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 = `
|
| 1162 |
-
+ (full ? " — FULL:
|
| 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
|
| 243 |
-
//
|
| 244 |
-
//
|
| 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
|
| 721 |
-
//
|
| 722 |
-
//
|
| 723 |
-
//
|
|
|
|
|
|
|
| 724 |
const state = createGameState({ planetId: "ferros", rng: () => 0.5, sizeMult: 3 });
|
| 725 |
fundAll(state);
|
| 726 |
-
|
| 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
|
| 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.
|
| 748 |
-
|
| 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("
|
| 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
|
| 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, "
|
| 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("
|
| 161 |
-
//
|
| 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"),
|
| 166 |
-
assert.equal(isDropOff("foundry"),
|
| 167 |
-
assert.equal(isDropOff("arsenal"),
|
| 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,
|
| 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 |
-
//
|
| 312 |
-
//
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 45 |
const { s, cc, workers } = base(1);
|
| 46 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 103 |
-
assert.equal(storeTotal(refinery),
|
| 104 |
-
assert.ok((s.players.player.resources.ore || 0)
|
| 105 |
});
|
| 106 |
|
| 107 |
-
test("mid-trip
|
| 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
|
| 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
|
| 127 |
|
| 128 |
-
assert.
|
| 129 |
-
assert.ok(
|
| 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("
|
| 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, "
|
| 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, "
|
| 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", () => {
|