Spaces:
Paused
Paused
File size: 17,715 Bytes
240c1d3 729f7b4 240c1d3 97f3bdf 240c1d3 32d2559 240c1d3 729f7b4 240c1d3 b02b34f 240c1d3 32d2559 240c1d3 32d2559 240c1d3 32d2559 240c1d3 32d2559 240c1d3 84220dc 240c1d3 84220dc 240c1d3 916c3ad 84220dc 916c3ad 84220dc 729f7b4 5853645 729f7b4 5853645 729f7b4 5853645 729f7b4 240c1d3 729f7b4 916c3ad 5853645 240c1d3 916c3ad 240c1d3 42d791d 916c3ad 42d791d 916c3ad 42d791d 916c3ad 42d791d 240c1d3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | /* ============================================================
AI β worker assignment and the unit-mix filters. Idle-worker logistics +
gather steering (assignAiLogistics / assignIdleWorkers), and the map-aware
mix helpers the production phases read: which unit types this world can
actually sustain (effectiveMix / affordableOnSurface), whether a developing
AI's PLANNED mix should extend past its archetype's own tiers (plannedMix /
wantsDeepIndustry β the shared signal aiEconomy's Foundry/Arsenal gates and
aiIndustry's factory climb both key off), and which upgrade doctrine its
economy favours (aiDoctrine). A leaf module for the other AI PHASE modules
(aiMilitary/aiEconomy/aiIndustry import from it freely, never the reverse)
β it only reaches sideways into two pure-data tables of its own,
aiStrategy.js and aiDifficulty.js, neither of which imports anything back.
============================================================ */
"use strict";
import { BUILDINGS, UNITS, prereqsMet, SPENDABLE } from "./entities.js";
import { assignService, assignHaul, countLogistics } from "./haul.js";
import { assignRepair, countRepairJobs } from "./repair.js";
import { isNodeDiscovered } from "./fog.js";
import { strategyFor } from "./aiStrategy.js";
import { difficultyFor } from "./aiDifficulty.js";
const SATURATION_STEER = 250; // distance-equivalent penalty per worker a node is over the soft cap
// SPENDABLE (entities.js): every commodity that anything the AI builds actually costs.
// assignIdleWorkers prefers nodes of these types so a poor-economy world's AI
// (Glacius: ice/gas it can never spend) doesn't mine dead-end commodities.
// Give a BOUNDED share of the AI's idle workers real logistics jobs β servicing factories (carry
// inputs in, outputs out), hauling pure producers (the Plasma Rig) to a Command Center, and
// repairing a damaged own building β reusing the exact owner-generic, zone-first machinery the
// player's workers use (engine/haul.js, engine/repair.js). Capped at HALF the worker pool so
// gathering never starves: a factory with no raws mined is no better off than one with no servers,
// so the AI must keep miners on the field too. Deterministic β countLogistics/countRepairJobs
// freeze the committed slots first, then assignService/assignHaul/assignRepair claim the nearest
// free slot in the AI's own Command Center zone first (ties by id). Odyssey-only (industry is
// odysseyOnly); the player's own auto-haul path (engine/sim.js) is untouched.
export function assignAiLogistics(state, workers) {
if (!workers.length) return;
countLogistics(state); // fresh committed haul/service tallies before we claim any new slots this cycle
countRepairJobs(state); // β¦and repair tallies too
let budget = Math.floor(workers.length / 2)
- workers.filter(w => w.order && (w.order.type === "service" || w.order.type === "haul" || w.order.type === "repair")).length;
if (budget <= 0) return;
for (const w of workers) {
if (budget <= 0) break;
if (w.order) continue;
assignService(state, w); // sets w.order to a factory service round-trip if one needs it
if (!w.order) assignHaul(state, w); // β¦else haul a backed-up pure producer (the rig)
if (!w.order) assignRepair(state, w); // β¦else patch a damaged own building
if (w.order) budget--;
}
}
// The node-picking half of idle-gather assignment, generalised over OWNER (the AI's own fog,
// state.fogAI, vs the player's, state.fog) β see assignIdleWorkers (AI) and assignIdlePlayerGather
// (a background colony's worker-sustain policy, engine/colonyPolicy.js) below, the two thin
// owner-specific wrappers. Kept as one function so the node-picking logic (soft-cap spreading,
// the secondary-commodity trickle cap) can never drift between the two callers.
function idleGatherAssign(state, fog, workers) {
// Only nodes this owner actually knows about: charted surface deposits (always) plus any
// hidden cache they've scouted.
const live = state.map.nodes.filter(n => n.amount > 0 && isNodeDiscovered(fog, n));
if (!live.length) return;
const oreLive = live.filter(n => n.com === "ore");
const otherLive = live.filter(n => n.com !== "ore" && SPENDABLE.has(n.com));
const nodeById = state.map.nodesById || new Map(state.map.nodes.map(n => [n.id, n]));
let secondaryMiners = 0;
for (const w of workers) {
const n = w.order && w.order.type === "gather" ? nodeById.get(w.order.nodeId) : null;
if (n && n.com !== "ore" && SPENDABLE.has(n.com)) secondaryMiners++;
}
// Crystals/radioactives buy only optional extras (a couple of turrets, the
// one-time upgrades, the occasional Breacher), so a small trickle funds them
// β everyone else stays on ore, the currency the whole army and every
// building actually run on. A flat "half on ore" split would over-divert on
// an ore-rich world and under-fund ore on an ore-poor one; capping the
// secondary crew keeps ore primary everywhere while still reaching the
// extras. On a crystal-heavy map (Helix) this is what stops workers piling
// onto crystals and starving the ore the army needs.
const secondaryCap = Math.min(2, Math.floor(workers.length / 3));
// Projected miner tally per node, so the assignment fills a node to the soft cap and
// then hops to the next-nearest instead of piling everyone on one seam (which
// saturation would drop to ~0.7 efficiency, slowing the tuned economy). Seeds
// from workers already on a gather order, and counts each assignment made in
// this same pass so consecutive idle workers spread across nodes.
const softCap = UNITS.worker.minerSoftCap ?? Infinity;
const projected = new Map();
for (const w of workers) {
if (w.order && w.order.type === "gather") projected.set(w.order.nodeId, (projected.get(w.order.nodeId) || 0) + 1);
}
workers.forEach(w => {
if (w.order) return;
let pool;
if (otherLive.length && oreLive.length && secondaryMiners < secondaryCap) {
pool = otherLive; secondaryMiners++; // fund the extras with a small crew...
} else if (oreLive.length) {
pool = oreLive; // ...but keep the bulk of workers on ore
} else {
pool = otherLive.length ? otherLive : live; // ore's gone β take spendable, else any live node
}
let best = null, bestScore = Infinity;
for (const n of pool) {
const m = projected.get(n.id) || 0;
const over = Math.max(0, m + 1 - softCap); // penalty kicks in once the node is already at the cap
const score = Math.hypot(n.x - w.x, n.y - w.y) + over * SATURATION_STEER;
if (score < bestScore) { bestScore = score; best = n; }
}
if (best) {
w.order = { type: "gather", nodeId: best.id };
projected.set(best.id, (projected.get(best.id) || 0) + 1);
}
});
}
// `fog` defaults to state.fogAI so the pre-existing single-owner call site (engine/ai.js, owner
// "ai") reads exactly as before; a self-play "player" controller passes its own ctx.fog
// (state.fogs.player) instead, so it plans logistics off ITS OWN discovered nodes, never the "ai"
// owner's β see the fog-fairness discipline this whole module's header comment already documents.
export function assignIdleWorkers(state, workers, fog = state.fogAI) {
// ODYSSEY: the AI runs REAL logistics like the player β dedicate a bounded share of workers to
// feeding/clearing its factories and rig (finite buffers, stalls and all) before the gather pass, so
// its industry pays the same labour cost the player's does. Skirmish has no factories β no-op there.
if (state.endless) assignAiLogistics(state, workers);
idleGatherAssign(state, fog, workers);
}
// The PLAYER-side twin of assignIdleWorkers' gather half β reused by a background colony's
// worker-sustain standing order (engine/colonyPolicy.js), which re-tasks idle player workers on a
// world nobody's driving. Reads the PLAYER's own fog (state.fog), not the AI's, and deliberately
// skips assignAiLogistics (haul/service/repair): sustaining workers just means putting idle hands
// back on a node, not opting a colony into the AI's own factory-servicing behaviour.
export function assignIdlePlayerGather(state, workers) {
idleGatherAssign(state, state.fog, workers);
}
// Tier 4 (engine/aiDifficulty.js rusherGraduates, Hard only): how long into an Odyssey world a
// non-developing archetype's opening rush has clearly either succeeded or become a non-factor β
// past this, on Hard, it graduates into a patient developer rather than staying a permanent
// flatline for the rest of what can be an hours-long session. Pure time, no wave-outcome
// tracking needed: if the game is still running this far in, "wait for the rush to resolve, then
// judge it" and "just check the clock" converge on the same answer.
const RUSHER_GRADUATE_TIME = 1200; // 20 minutes
// Does this AI climb past power+electrify into the deep chain (engine/aiIndustry.js) β and, by the
// same signal, extend its PLANNED unit mix past its archetype's own tiers (plannedMix, below)? The
// patient-developer signal (archetype.wantsRefinery), the Economic strategy's override, or Hard's
// rusher graduation. Lives here (moved from aiIndustry.js) rather than there, so this file's own
// plannedMix β the unit-mix half of "does this AI climb the deep chain" β and aiIndustry's factory
// climb + industry reserve read the exact same test; aiIndustry now imports this instead of
// defining it (see its own header comment). Leaf-safe: aiStrategy.js and aiDifficulty.js are both
// pure data tables with no imports of their own, so reaching into them here creates no cycle.
export function wantsDeepIndustry(state, archetype, strategy, owner = "ai") {
// `owner` matters: difficultyFor defaults to the "ai" controller, so in self-play the asking
// controller's graduation used to be decided by the OTHER side's difficulty. Defaulting to "ai"
// keeps every single-owner caller byte-identical.
const graduated = !!difficultyFor(state, owner).rusherGraduates && state.time > RUSHER_GRADUATE_TIME;
return !!(archetype.wantsRefinery || strategy.wantsIndustryAlways || graduated);
}
// GRADUATE EXTENSION (docs/improvement-proposals.md "Graduation reaches the army"): a developing AI
// (wantsDeepIndustry) climbs aiIndustry's Foundry/Arsenal chain, but a pure Tier-1 mix β today, only
// the Rusher's β never mentions a single gated unit, so aiEconomy's wantsFoundry/wantsArsenal
// (computed off the mix) stayed false even for a graduated Hard Rusher: it could reach a Star Dock
// and field Leviathans while its Barracks cycled Skiff/Bastion forever. Appended ONCE β and only
// when the base mix has no Foundry-gated entry of its own β so an Economist/Balanced mix (which
// already carries a Lancer or Breacher) is a genuine no-op here; only a pure Tier-1 profile picks
// this up.
const GRADUATE_EXTENSION = ["lancer", "lancer", "dreadnought"];
// The archetype's PLANNED unit mix β its base unitMix, extended with GRADUATE_EXTENSION for a
// developing/graduated AI (see above) β BEFORE effectiveMix's prereq/afford filtering, which runs
// on top of this unchanged. That keeps the extension deadlock-free: a planned Lancer only ever
// enters the AI's ACTUAL cycle once the Foundry it now motivates aiEconomy to build has completed
// (prereqsMet), exactly like every other gated entry already works. Skirmish is untouched: the
// state.endless gate means a skirmish's planned mix is always exactly archetype.unitMix, whatever
// the difficulty or elapsed time β the short game stays byte-identical.
export function plannedMix(state, archetype, owner = "ai") {
const base = archetype.unitMix || [];
if (!state.endless) return base;
if (!wantsDeepIndustry(state, archetype, strategyFor(state, owner), owner)) return base;
if (base.some(t => (UNITS[t]?.requires || []).includes("foundry"))) return base; // already reaches the Foundry on its own
return [...base, ...GRADUATE_EXTENSION];
}
// The AI's planned unit mix (plannedMix, above) with entries this map can never pay for dropped β
// a cost commodity no SURFACE deposit produces (Vesper's surface has no
// radioactives, so its Breacher entry is skipped, leaving today's exact
// three-unit cycle). Hidden caches are deliberately excluded: they can hold a
// commodity the surface lacks, but they're far, contested, and may never be
// mined, so planning the whole cycle around one would just re-stall the mix on
// a unit the AI has no steady income for. Surface EXISTENCE, not remaining
// amount, is checked, so the surviving cycle is constant for the whole match
// (nodes drain, they never vanish) and the sequence stays deterministic. Falls
// back to plain Skiffs if the filter empties the mix entirely.
// Also drops any unit whose TECH prereqs aren't met yet (Lancer/Breacher before
// the Foundry is up), so the cycle runs Skiff/Bastion only until then and never
// stalls on a locked entry β pickNextUnitType can only ever return an unlocked,
// affordable unit. This makes the mix change ONCE, deterministically, the tick
// the Foundry completes; both the base cycle and the counter-pick (which only
// adopts a counter that mix.includes) are prereq-safe through this one filter.
// A graduated Rusher's Lancer/Dreadnought graduate entries are no exception β they're filtered by
// this exact same prereqsMet check, so they enter the cycle only once their own gate is built.
// `owner` defaults to "ai" so every pre-existing call site (this file's own maxSupplyDemand, every
// test, tools/ailab.js) reads exactly as before. A self-play "player" controller passes its own
// ctx.owner instead β prereqsMet must check THAT owner's own completed buildings (its own Foundry/
// Arsenal), never the "ai" owner's: without this, the second controller's mix would silently gate
// on the FIRST controller's tech, either locking it out of units it can actually build or (worse)
// "unlocking" ones it can't, a correctness bug on top of the fairness one.
export function effectiveMix(state, archetype, owner = "ai") {
const mix = plannedMix(state, archetype, owner).filter(t =>
UNITS[t]
&& BUILDINGS.barracks.produces?.includes(t)
&& prereqsMet(state, owner, UNITS[t])
&& affordableOnSurface(state, t));
return mix.length ? mix : ["skiff"];
}
// The biggest SUPPLY cost the AI could actually try to pay next: the largest entry in this
// world's effective mix, plus anything a completed Star Dock can build (Odyssey's 8-supply
// Leviathan). aiEconomy's Habitat trigger is sized from this rather than from a flat 2, because
// pickNextUnitType retries the SAME mix entry until it succeeds β so a free-supply gap that's
// wide enough to skip the old `cap - 2` trigger but too narrow for a 4- or 8-supply unit is a
// hard deadlock: production can't queue, and the Habitat that would unblock it never fires.
// (Measured on a 50-minute Odyssey world β docs/odyssey-ai-review.md Β§2.2.) Floored at 2, so a
// world whose mix is all 1- and 2-supply units keeps exactly its old timing.
export function maxSupplyDemand(state, archetype, owner = "ai") {
let max = 2;
for (const t of effectiveMix(state, archetype, owner)) max = Math.max(max, UNITS[t].supplyCost || 0);
// A Star Dock's capital ships are queued by aiIndustry, which SKIPS rather than retrying when
// they don't fit β no deadlock there, but the headroom still has to arrive eventually or the
// deep-industry payoff is unreachable. Only counted once the Star Dock actually stands.
for (const b of state.buildings.values()) {
if (b.owner !== owner || b.type !== "stardock" || b.constructing) continue;
for (const t of BUILDINGS.stardock.produces || []) max = Math.max(max, UNITS[t]?.supplyCost || 0);
break;
}
return max;
}
// Can the AI pay for `unitType` from this world's STEADY income β i.e. does every
// cost commodity have a non-hidden (surface) deposit somewhere on the map? Shared
// by effectiveMix (which unit types actually cycle) and the Foundry/Arsenal
// "wants" gates below. Deliberately NOT prereq-aware: the gates use it to decide
// whether teching up would ever pay off, so folding in the building prereq would
// deadlock (you'd never build the Arsenal a gas-Wraith needs because the Wraith
// is prereq-locked until the Arsenal exists). ore/crystals/radioactives are
// seeded near every base (map.js MIN_GUARANTEE), so only the specialty commodities
// (gas/ice/relics) actually gate anything here.
export function affordableOnSurface(state, unitType) {
const def = UNITS[unitType];
return !!def && Object.keys(def.cost).every(com => state.map.nodes.some(n => n.com === com && !n.hidden));
}
// Which upgrade doctrine the AI commits to. It prefers its archetype's flavour
// (rusher/balanced Assault, economist Bulwark), but follows the world's economy
// when that world is clearly richer in the OTHER doctrine's commodity β Assault
// runs on radioactives, Bulwark on crystals β so it doesn't build a Refinery it
// can't actually research on a world short of its preferred commodity. Surface
// deposits only (mirrors effectiveMix): steady income, not a contested cache.
export function aiDoctrine(state, archetype) {
const surfaceTotal = com => state.map.nodes
.filter(n => n.com === com && !n.hidden).reduce((s, n) => s + n.max, 0);
const rad = surfaceTotal("radioactives"), cry = surfaceTotal("crystals");
const pref = archetype.doctrine || "assault";
if (pref === "assault") return rad >= cry * 0.6 ? "assault" : "bulwark";
return cry >= rad * 0.6 ? "bulwark" : "assault";
}
|