Spaces:
Paused
Paused
File size: 9,248 Bytes
7e764b0 240c1d3 ca9c751 240c1d3 ca9c751 240c1d3 916c3ad 7e764b0 916c3ad 7e764b0 916c3ad 240c1d3 916c3ad e818247 916c3ad 7e764b0 916c3ad e818247 916c3ad 240c1d3 7e764b0 916c3ad 240c1d3 7e764b0 916c3ad 240c1d3 7e764b0 240c1d3 7e764b0 240c1d3 ca9c751 | 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 | // @ts-check
/* ============================================================
AI β shared primitives used by every decision phase (engine/ai.js and the
aiEconomy / aiMilitary / aiIndustry phase modules): owner resolution
(controllerFor/otherOwner), the APM action budget (the configurable AI
speed), the reserve-aware affordability check, the "which idle worker founds
this building" pick, and the found-a-building protocol those picks feed.
Imports only DOWNWARD, into commands/colliders β never back into an AI phase
module β so the phase modules can all share it without a cycle. That is the
invariant, not "no engine imports": commands.js and colliders.js have no
route back to the AI layer, and test/static-integrity.test.js's SCC check
holds the whole engine to it.
============================================================ */
"use strict";
import { issueBuild } from "./commands.js";
import { findPlacement } from "./colliders.js";
const APM_BURST_FRAC = 1 / 15; // a busy AI can bank at most ~4 seconds' worth of unspent actions
/* ---------- owner resolution (Tier 1 self-play) ---------- */
// Which controller object is driving `owner` this match β state.ai for "ai" (always present,
// every match ever created), or state.playerAi for "player" (present only when self-play β
// tools/selfplay.js β has populated it; null otherwise). Every AI phase module threads its
// ctx.owner through this instead of ever reaching for state.ai directly, so the two controllers'
// action budgets, scout ids, wave timers etc. can never collide or leak into one shared object β
// see the header comment on engine/ai.js's runAI(state, dt, owner) for why that matters.
/** @param {State} state @param {string} owner @returns {AiState|undefined} */
export function controllerFor(state, owner) {
if (owner === "ai") return state.ai;
if (owner === "player") return state.playerAi;
return null;
}
// The other side in a two-owner skirmish/self-play match β the only two owners this engine models
// today (state.owners). Centralised here so every AI phase module resolves "my opponent" the same
// way, instead of each hardcoding "ai"/"player" itself.
/** @param {string} owner @returns {string} */
export function otherOwner(owner) {
return owner === "ai" ? "player" : "ai";
}
/* ---------- action budget (the configurable AI speed / APM) ---------- */
// This controller's "speed" is an actions-per-minute allowance, set from the splash screen
// (state.ai.apm β or, for a self-play "player" controller, state.playerAi.apm). Every command it
// issues β produce, build, expand, research, send the scout β costs one action; the attack commit
// is the one exemption, so a slow AI still throws whatever it has at you and the game always
// resolves. Credits accrue continuously and cap at a few seconds' worth, so a busy AI can't hoard a
// giant burst. When apm is null (the default, and every headless test) the AI is unthrottled β
// behaviour is exactly as before. `owner` defaults to "ai" so every pre-existing call site
// (accrueActionBudget(state, dt), canAct(state), spend(state)) keeps reading/writing state.ai
// exactly as before; a self-play caller passes ctx.owner explicitly so the "player" controller
// spends from ITS OWN budget, never state.ai's β see engine/ai.js/aiMilitary.js/aiEconomy.js.
//
// A BACKGROUND colony (state.background, engine/galaxy.js β a world that isn't the player's
// current seat) accrues at HALF its configured apm: nobody's actively watching it, so it manages
// its economy at half pace rather than as briskly as the seat you're actually on. This halves only
// the accrual rate (and, since the burst cap below is derived from the same value, its burst
// reserve too) β never the controller's apm itself, which stays exactly what difficulty picked for
// every other reader (display, save/load, the neighbour-profile tests) β and never the attack/
// defense commit exemption (canAct/spend below), so a background world under attack still always
// throws whatever it has and the game still always resolves.
/** @param {State} state @param {number} dt @param {string} [owner] @returns {void} */
export function accrueActionBudget(state, dt, owner = "ai") {
const controller = controllerFor(state, owner);
if (!controller || controller.apm == null) return;
const apm = state.background ? controller.apm / 2 : controller.apm;
const cap = Math.max(2, apm * APM_BURST_FRAC);
controller.actionBudget = Math.min((controller.actionBudget || 0) + (apm / 60) * dt, cap);
}
/** @param {State} state @param {string} [owner] @returns {boolean} */
export function canAct(state, owner = "ai") {
const controller = controllerFor(state, owner);
return !controller || controller.apm == null || (controller.actionBudget || 0) >= 1;
}
/** @param {State} state @param {string} [owner] @returns {void} */
export function spend(state, owner = "ai") {
const controller = controllerFor(state, owner);
if (controller && controller.apm != null) controller.actionBudget -= 1;
}
// canAfford, but treating `oreReserve` ore as untouchable β used to bank for
// an expansion Command Center without letting the unit mix or a second
// barracks spend the ore out from under it.
/** @param {Resources} resources @param {Resources} cost @param {number} oreReserve @returns {boolean} */
export function canAffordKeeping(resources, cost, oreReserve) {
return Object.entries(cost).every(([com, qty]) =>
(resources[com] || 0) - (com === "ore" ? oreReserve : 0) >= qty);
}
// Nearest free worker to (x, y) to found a building, skipping any already mid-build (so an in-progress
// site keeps its founder) AND any on a logistics run (service/haul) β pulling a worker off feeding a
// factory to lay a foundation would thrash the industry it's trying to grow. Prefers a gatherer/idle
// worker; falls back to workers[0] only if every worker is busy building or hauling β buildings
// self-construct at rate 1 even with nobody on-site, so a slightly-worse pick is never a stall.
/** @param {Unit[]} workers @param {number} x @param {number} y @returns {Unit|null} */
export function pickBuilder(workers, x, y) {
let best = null, bestD = Infinity;
for (const w of workers) {
if (w.order && (w.order.type === "build" || w.order.type === "service" || w.order.type === "haul")) continue;
const d = Math.hypot(w.x - x, w.y - y);
if (d < bestD) { bestD = d; best = w; }
}
return best || workers[0];
}
/* ---------- founding a building ---------- */
// The AI's found-a-building protocol, in one place: slide the request to valid ground,
// check the action budget, issue the order, and charge the budget for it. Thirteen sites
// across aiEconomy.js and aiIndustry.js hand-rolled these four steps, and two of the four
// fail SILENTLY when dropped β omit `spend` and the AI founds buildings for free (its APM
// dial quietly stops throttling it), omit `canAct` and it ignores the budget outright.
// Neither shows up as a crash, and neither shows up in a determinism fingerprint on the
// default archetype, because the default AI has `apm == null` and both calls are then
// no-ops. Only the APM-limited difficulties and self-play can see the difference.
//
// findPlacement is what makes the fixed build offsets safe: a node or an earlier building
// squatting on the nominal spot would make issueBuild reject the identical coordinates
// every think cycle and stall the order forever, so the search slides it to the nearest
// valid ground instead.
//
// `workers` is a candidate list, not a single unit, because the builder is chosen by
// distance to the spot that placement actually settled on β which isn't known until after
// the search. Two sites (the Habitat and the first Barracks) historically founded with
// workers[0] rather than the nearest free worker; they pass [workers[0]] so the pick is
// forced to that same unit, since pickBuilder returns a lone candidate either way. Widening
// them to the full list would change which unit walks to the site, and with it the
// determinism fingerprint β a behaviour change, not a refactor.
/** @param {State} state @param {string} owner @param {Unit[]} workers @param {string} type @param {number} x @param {number} y @param {number} [maxRadius] @returns {boolean} */
export function tryBuild(state, owner, workers, type, x, y, maxRadius) {
return tryBuildAt(state, owner, workers, type, findPlacement(state, type, x, y, maxRadius));
}
// tryBuild for a caller that already searched for its own spot (the Habitat scans every
// completed Command Center and takes the first that yields one). `spot` may be null β
// "nowhere to put it" is an ordinary outcome, not an error.
/** @param {State} state @param {string} owner @param {Unit[]} workers @param {string} type @param {{x:number,y:number}|null} spot @returns {boolean} */
export function tryBuildAt(state, owner, workers, type, spot) {
if (!spot || !workers.length || !canAct(state, owner)) return false;
const builder = pickBuilder(workers, spot.x, spot.y);
if (!builder || !issueBuild(state, builder.id, type, spot.x, spot.y)) return false;
spend(state, owner);
return true;
}
|