Spaces:
Paused
Paused
Download engine/aiWorkers.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/aiWorkers.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/engine/aiWorkers.js
-
curl -L -o aiWorkers.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/aiWorkers.js
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. | |
| ============================================================ */ | |
| ; | |
| 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"; | |
| } | |