SpaceCities / engine /aiWorkers.js
Claude
Tier 3f: split the selection panel's derived state out of its DOM
97f3bdf unverified
Raw History Blame
17.7 kB
/* ============================================================
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";
}