SpaceCities / engine /aiCommon.js
Claude
Tier 3c: the AI's found-a-building protocol, and its decision predicates
ca9c751 unverified
Raw History Blame
9.25 kB
// @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;
}