SpaceCities / engine /state.js
Claude
Claude Opus 4.8
Get CI green, and make the AI's opponent belief fade the way it says it does
15f3a03 unverified
Raw History Blame Contribute Delete
21.4 kB
// @ts-check
/* ============================================================
Game state: the mutable simulation world. No rendering, no input,
no DOM β€” engine/sim.js mutates this each fixed tick, render.js only
reads it. Core shapes (State/Unit/Building/…) are defined in
engine/types.js; this file is `// @ts-check`ed against them.
============================================================ */
"use strict";
import { generateMap } from "./map.js";
import { BUILDINGS, UNITS } from "./entities.js";
import { createFog, updateFog } from "./fog.js";
import { archetypeFor, ARCHETYPES } from "./aiArchetypes.js";
import { difficultyFor } from "./aiDifficulty.js";
// Entity-id counter. Reset to 1 at the start of every createGameState (below)
// so a fresh game is a pure function of its seed: two same-seed runs mint the
// same ids, and since ids feed the deterministic tie-breaks in movement /
// separation / gather, the whole sim replays identically. IDs are only ever
// compared within one state's own Maps, so two live games sharing id strings
// is harmless.
let nextEntityId = 1;
function newId(prefix) { return `${prefix}${nextEntityId++}`; }
// Save/load (engine/persist.js) needs to snapshot and restore the id counter so a
// loaded game keeps minting fresh, non-colliding ids from where it left off.
/** @returns {number} */
export function peekEntityId() { return nextEntityId; }
/** @param {number} n */
export function restoreEntityId(n) { nextEntityId = n; }
/**
* @param {string} type a key of UNITS (engine/entities.js)
* @param {string} owner "player" | "ai"
* @param {number} x
* @param {number} y
* @returns {Unit}
*/
export function makeUnit(type, owner, x, y) {
const def = UNITS[type];
/** @type {Unit} */
const u = {
kind: "unit", id: newId("u"), type, owner,
x, y, hp: def.hp, maxHp: def.hp,
order: null, // { type: 'move'|'gather'|'attack'|'attack-move'|'build', ... } β€” the active order
orderQueue: [], // queued waypoints (Ctrl+command); sim.js pulls the next in whenever `order` clears
cargo: def.role === "worker" ? { com: null, qty: 0 } : null,
attackTimer: 0,
autoTarget: null, // sticky auto-acquired target id (combat.js) β€” commit to a foe, don't re-dogpile the nearest each tick
};
// A freighter (Odyssey cargo ship) carries `freight` β€” a player-managed, multi-commodity hold,
// filled and emptied by hand at a world (engine/galaxy.js load/unloadFreighter) and shipped on a
// jump. Named `freight`, not `hold`, to stay clear of the combat hold-stance flag (unit.hold).
if (def.cargoHold) u.freight = {};
return u;
}
/**
* @param {string} type a key of BUILDINGS (engine/entities.js)
* @param {string} owner "player" | "ai"
* @param {number} x
* @param {number} y
* @param {{ hp?: number, constructing?: boolean }} [opts]
* @returns {Building}
*/
export function makeBuilding(type, owner, x, y, opts = {}) {
const def = BUILDINGS[type];
return {
kind: "building", id: newId("b"), type, owner,
x, y, radius: def.radius, hp: opts.hp ?? def.hp, maxHp: def.hp,
constructing: !!opts.constructing, buildProgress: opts.constructing ? 0 : 1,
queue: [], // [{ unitType, progress }]
attackTimer: 0, // combat.js decrements this for buildings with an attack stat (turret)
targetId: null, // current auto-acquired target; render.js reads it to aim the turret barrel
rally: { x: x + 60, y: y + 60 },
};
}
/**
* Build a fresh AI controller's runtime bookkeeping β€” the exact shape shared by state.ai (owner
* "ai", always present) and state.playerAi (owner "player", present only when self-play is active
* β€” see tools/selfplay.js). Kept as one factory so the two controllers can never structurally
* drift apart; engine/ai.js's runAI(state, dt, owner) and engine/aiCommon.js's controllerFor read
* either one the same way. Every field here matches state.ai's own inline literal below field for
* field, in the same order, so createGameState's construction of state.ai stays byte-identical.
* @param {string} planetId
* @param {{ apm?: number, micro?: boolean, strategy?: string, difficulty?: string, archetype?: string }} [opts]
*/
export function createAiController(planetId, opts = {}) {
return {
think: 0, // countdown to this controller's next decision pass (engine/ai.js THINK_INTERVAL)
scoutId: null, // the unit currently out scouting for this controller, if any
colonyTarget: null, // Odyssey: the committed {x,y} deploy spot of this controller's in-flight colony ship (ai.js)
apm: opts.apm ?? null, // actions-per-minute cap; null = unthrottled (default/tests)
micro: opts.micro ?? false, // Tactical AI: unit-level micro (focus-fire, kiting). Off by default (and in tests).
strategy: opts.strategy || "default", // player-picked AI strategy (engine/aiStrategy.js) β€” "default" β‡’ byte-identical to today
difficulty: opts.difficulty || "medium", // splash-screen Easy/Medium/Hard pick (engine/aiDifficulty.js) β€” read via difficultyFor(state, owner)
lastThreatAt: null, // sim-time of the last seen threat near home β€” drives the Economic strategy's war-footing window (engine/ai.js)
actionBudget: 0, // accumulated action credits (see engine/aiCommon.js's accrueActionBudget)
attackForce: 0, // size of the current committed attack at its peak β€” drives the retreat check (ai.js)
attackDesperate: false, // whether the current attack is a fight-to-death timeout commit (never retreats)
nextAttackAt: null, // scheduled time of the next attack commit; null β‡’ use the archetype timeout
unitsBuilt: 0, // total combat units this controller has produced (drives its build cadence)
waveCount: 0, // committed-wave counter β€” drives the economy-raid cadence (waveCount % RAID_EVERY)
nextWaveAt: null, // Odyssey: scheduled time of the next offensive wave; null β‡’ wave-ready
// OPPONENT BELIEF (engine/aiIntel.js) β€” ore-value of the enemy's military/economic assets as
// far as this controller has SEEN, plus when it last saw anything. A fading high-water mark,
// not a live read, and the first AI state that can be wrong: it is what the AI thinks, not what
// is true, which is what makes scouting worth its cost and killing the scout a real counter.
// Null/0 until something is actually sighted β€” "I have seen nothing" must stay distinguishable
// from "I have seen an empty base" (see aiIntel's header).
// intelMil/intelEco are PEAKS, each with its own stamp saying when it was set β€” the current
// belief is that peak faded by its own elapsed time, computed at read (aiIntel.js
// channelValue), never accumulated. Separate clocks because a worker still in view refreshes
// what the AI knows about the economy and nothing about the army that left.
intelMil: 0, intelMilAt: null, intelEco: 0, intelEcoAt: null,
// …and intelAt answers the different question confidence needs: when did it last see the
// enemy at all. Null until something is actually sighted.
intelAt: null,
// The damped STANCE derived from that belief (engine/aiIntel.js updateAdaptMode): 0 = the
// enemy is playing economy, 1 = they are massing, 0.5 = no opinion. Null until a first think
// cycle sets it, and pinned at neutral forever on Easy (adaptivity 0).
adaptMode: null,
// This world's opponent temperament (engine/aiArchetypes.js) β€” UNLESS opts.archetype names a
// real ARCHETYPES key, in which case that per-entrant pick wins instead (docs/
// competitions-and-elo.md D3: a competition entrant carries its own doctrine, so "Rusher vs
// Turtle" is a real matchup rather than both seats sharing whatever temperament the world
// hands out). opts.archetype is a STRING KEY, never an archetype object β€” the caller names a
// doctrine, this resolves it, mirroring archetypeFor's own ARCHETYPES[key] lookup. Absent, or
// naming a key that isn't in ARCHETYPES, falls back to archetypeFor(planetId) exactly as
// before this option existed β€” byte-identical for every call site today, none of which pass
// opts.archetype yet. Object.hasOwn, not a truthy `ARCHETYPES[opts.archetype]` bracket-access:
// a plain object's inherited keys (e.g. "constructor") or the specially-handled "__proto__"
// accessor would otherwise resolve to something that isn't a real archetype at all β€” this
// module has no user-facing input today (the only two callers, competitionLedger.js's roster
// guard and the Archetype picker, already restrict to real keys), but this is an exported,
// reusable function and should refuse a hostile/reserved key on its own rather than depend on
// every future caller re-deriving that guard.
archetype: (typeof opts.archetype === "string" && Object.hasOwn(ARCHETYPES, opts.archetype))
? ARCHETYPES[opts.archetype] : archetypeFor(planetId),
};
}
/**
* Build a fresh simulation world. A pure function of its inputs (seed + options): the map
* regenerates deterministically from the seed, so two same-option runs are identical.
* @param {{ planetId?: string, rng?: () => number, seed?: number, sizeMult?: number,
* resourceMult?: number, swapAsym?: boolean, matchTimeLimit?: number, popCap?: number, endless?: boolean,
* aiApm?: number, aiMicro?: boolean,
* aiStrategy?: string, difficulty?: string, aiArchetype?: string, playerFaction?: string, aiFaction?: string }} [opts]
* @returns {State}
*/
export function createGameState(opts = {}) {
nextEntityId = 1; // fresh game -> deterministic ids from the seed (see newId above)
const planetId = opts.planetId || "ferros";
// The one sanctioned fallback: an UNSEEDED caller (a direct test, or a call
// that predates seeding) uses the platform PRNG for map generation only.
// Production always passes a seeded rng (see main.js), so this branch never
// runs in a real match β€” the engine-purity guard whitelists the marked line.
const map = generateMap(planetId, opts.rng || Math.random, { // deterministic-exempt: unseeded default rng
sizeMult: opts.sizeMult || 1,
resourceMult: opts.resourceMult || 1,
swapAsym: !!opts.swapAsym,
});
// The sides in this world. Today always exactly the human "player" and the AI
// opponent, but the SCAFFOLD is owner-generic: state.owners is the canonical
// side list, and the player map, per-owner fog, seeding, persistence and the
// victory check are all driven by ITERATING it β€” not by two hardcoded names.
// So a future N-faction world is a change to this list, not a sweep across the
// engine. (state.fog / state.fogAI stay as aliases into state.fogs so the many
// existing fog consumers keep working unchanged.)
const ownerDefs = [
// Faction is a passive-trait bundle (engine/factions.js). It defaults to
// "neutral" (no traits) so a bare createGameState β€” every engine test β€”
// behaves exactly as before; the setup screen (main.js) passes the real
// pick for the player and the archetype's faction for the AI.
{ id: "player", faction: opts.playerFaction || "neutral", isAI: false, color: "#4fd1ff" },
{ id: "ai", faction: opts.aiFaction || "neutral", isAI: true, color: "#f87171" },
];
const owners = ownerDefs.map(d => d.id);
/** @type {Object.<string, Player>} */
const players = {};
for (const d of ownerDefs)
players[d.id] = { id: d.id, faction: d.faction, isAI: d.isAI, resources: startingResources(), color: d.color, upgrades: {} };
/** @type {Object.<string, Fog>} */
const fogs = {};
for (const id of owners) fogs[id] = createFog(map); // one fog grid per side β€” the AI scouts for its own intel too (engine/ai.js)
const state = {
time: 0,
tick: 0,
over: false,
winner: null,
winReason: null, // set by engine/victory.js finish() β€” why the match ended, once it does
seed: opts.seed ?? null, // the match seed, if one was supplied β€” reproduces this whole game
// The generation inputs, kept so a save can regenerate the (deterministic)
// map from the seed instead of serialising the whole terrain/node table.
planetId,
sizeMult: opts.sizeMult || 1,
resourceMult: opts.resourceMult || 1,
// Pick your side of an asymmetric matchup (Oort, Nimbus, engine/map.js): additive, next to
// sizeMult/resourceMult β€” a generation input kept so a save can regenerate the map with the
// same swap honored, not just replay the bare boolean. Defaults false (unswapped, today's
// long-standing assignment).
swapAsym: !!opts.swapAsym,
// setup.js's Match length row (Quick 20 / Standard 40 / Marathon 60 β€” never "unlimited"):
// an explicit override of engine/victory.js's DEFAULT_MATCH_TIME_LIMIT, in seconds. Defaults
// to null (not DEFAULT_MATCH_TIME_LIMIT's own 2400) so checkWinCondition's own `??` fallback
// stays the single source of truth for "no override requested" β€” this field only ever carries
// a REAL, deliberately-chosen override, never a copy of the default it would fall back to
// anyway.
matchTimeLimit: opts.matchTimeLimit ?? null,
// setup.js's Population cap row (200 / 250 / 300 / Max): a hard ceiling supplyCap
// (engine/supply.js) clamps the building-derived total to, shared by both owners alike (it's
// a match rule, not a per-side or AI-temperament dial like difficulty/aiStrategy β€” see
// engine/state.js's `ai:` block below for that distinction). Defaults to null β€” Max, i.e.
// today's always-uncapped-by-anything-but-buildings behaviour, byte-identical to before this
// setting existed.
popCap: opts.popCap ?? null,
// Odyssey (open-world) mode: no skirmish victory β€” the match never ends by
// razing the enemy, only when the player loses their single Command Center
// (see engine/victory.js checkEndlessLoss + engine/galaxy.js).
endless: !!opts.endless,
map,
owners, // the world's side ids, in canonical iteration order (["player","ai"])
players,
units: new Map(),
buildings: new Map(),
selection: [], // unit/building ids currently selected by the human player
fogs, // per-owner fog of war, keyed by owner id β€” see engine/fog.js
fog: fogs.player, // alias: the human player's fog (=== state.fogs.player)
fogAI: fogs.ai, // alias: the AI's own fog, no longer omniscient (=== state.fogs.ai)
// The AI OPPONENT's runtime bookkeeping, grouped under one key so it doesn't clutter the
// top-level state (which is the shared sim world). This is the AI *controller's* scratch
// state β€” distinct from state.players.ai, which is the AI's economy/faction. Serialized under
// the save's `ai:` key (engine/persist.js). The think/wave/attack-schedule fields used to be
// set lazily by ai.js on first tick; initialising them here keeps the shape complete and
// self-documenting, and is behaviourally identical (they were read `|| 0` / `?? …` anyway).
ai: createAiController(planetId, {
apm: opts.aiApm, micro: opts.aiMicro, strategy: opts.aiStrategy, difficulty: opts.difficulty,
// docs/competitions-and-elo.md D3, one layer up from createAiController's own opts.archetype
// (which this just forwards verbatim, including its own null/unknown-key fallback to
// archetypeFor(planetId)) β€” absent on every call site before this stage, so byte-identical
// until a caller actually passes it (Quick Duel's own per-entrant archetype pick, via
// tools/duelCore.js's runDuelMatch -> tools/selfplay.js's createSelfPlayState -> here).
archetype: opts.aiArchetype,
}),
// A second, parallel AI controller for owner "player" β€” same shape as `ai` above (see
// createAiController), but null for every match that exists today. Tier 1 self-play
// (tools/selfplay.js, a headless bench β€” never setup.js/boot.js/the shipped game) is the only
// thing that ever populates it, by assigning a controller object here directly; every other
// caller leaves it null, so playerBuildings/playerUnits/runAI's single-owner default path is
// completely untouched. Kept OUTSIDE `players` (state.players.player is the human's economy/
// faction, unrelated) and outside `ai` itself, exactly parallel to it β€” see engine/ai.js's
// runAI(state, dt, owner) and engine/aiCommon.js's controllerFor.
playerAi: null,
events: [], // sim events this tick (unitSpawned/attackHit/entityKilled/buildingComplete) β€” pushed by
// production.js/combat.js, drained and turned into sound by main.js each render frame
craters: [], // pending Helium Bomb craters awaiting maturity into a real node (engine/bomb.js)
wrecks: [], // pending battle-wreckage sites awaiting maturity into real nodes (engine/wreckage.js)
};
// Seed each side's opening (a colony ship in Odyssey, a Command Center + workers in
// skirmish) and prime its vision before the first render β€” both by iterating owners,
// so the id-minting order and fog state are byte-identical to the old player-then-ai
// pair. map.bases is keyed by owner id (engine/map.js).
for (const id of owners) seedPlayer(state, id, map.bases[id]);
for (const id of owners) updateFog(state, state.fogs[id], id);
// Hard difficulty's economic edge (engine/aiDifficulty.js): seed the synthetic hardEdge
// upgrade (entities.js) straight onto the AI's own upgrades, once, at creation β€” never
// researched, so it needs no Refinery/Datacenter and survives save/load via the ordinary
// player.upgrades round-trip (engine/persist.js) with no special-casing. state.playerAi is
// always null at this point (Tier 1 self-play populates it AFTER createGameState returns β€” see
// tools/selfplay.js), so seedDifficultyEdge(state, "player") only ever fires there, once
// self-play has actually configured a difficulty for it β€” see that call site.
seedDifficultyEdge(state, "ai");
return state;
}
/**
* Seed Hard difficulty's synthetic `hardEdge` economic-edge upgrade (engine/aiDifficulty.js
* economicEdge β€” +10% gather yield, -10% production time) onto `owner`'s own upgrades, once, if
* that owner's OWN configured difficulty (difficultyFor(state, owner)) grants it. Exported so a
* caller can apply the exact same rule to a second controller after the fact β€” Tier 1 self-play
* (tools/selfplay.js's createSelfPlayState) populates state.playerAi only AFTER createGameState
* has already returned, so owner "player" can never be seeded inline above; without this, a
* self-play "player" controller configured with Hard difficulty would fight at a permanent,
* un-researchable economic disadvantage against a Hard "ai" β€” the fairness guarantee (independent
* fog, independent action budgets, no double-spend) this whole tier otherwise holds to. A no-op
* for any owner whose difficulty doesn't grant the edge (Easy/Medium, or no controller at all yet
* β€” difficultyFor falls back to Medium's defaults either way).
* @param {State} state
* @param {string} owner
*/
export function seedDifficultyEdge(state, owner) {
if (difficultyFor(state, owner).economicEdge) state.players[owner].upgrades.hardEdge = true;
}
/** @returns {Resources} */
function startingResources() {
return { ore: 300, crystals: 0, radioactives: 0 };
}
/**
* @param {State} state
* @param {string} ownerId
* @param {{ x: number, y: number }} basePos
*/
function seedPlayer(state, ownerId, basePos) {
if (state.endless) {
// Odyssey: both sides START with a mobile colony ship instead of a built base β€”
// deploy it (engine/colony.js) to found the first Command Center; the colonists
// (opening workers) disembark then. Seeding workers now would strand them: with
// no drop-off yet they can't bank ore (engine/gather.js).
const ship = makeUnit("colonyship", ownerId, basePos.x, basePos.y);
state.units.set(ship.id, ship);
return;
}
// Skirmish β€” BYTE-IDENTICAL to before: a finished Command Center + 3 workers.
const cc = makeBuilding("command", ownerId, basePos.x, basePos.y);
state.buildings.set(cc.id, cc);
for (let i = 0; i < 3; i++) {
const w = makeUnit("worker", ownerId, basePos.x + 40 + i * 14, basePos.y + 40);
state.units.set(w.id, w);
}
}
/**
* @param {State} state
* @param {string} id
* @returns {Unit|Building|undefined}
*/
export function getEntity(state, id) {
return state.units.get(id) || state.buildings.get(id);
}
/**
* @param {State} state
* @param {string} id
*/
export function removeEntity(state, id) {
state.units.delete(id) || state.buildings.delete(id);
state.selection = state.selection.filter(sid => sid !== id);
}
/**
* @param {State} state
* @param {string} owner
* @returns {Building[]}
*/
export function playerBuildings(state, owner) {
return [...state.buildings.values()].filter(b => b.owner === owner);
}
/**
* @param {State} state
* @param {string} owner
* @returns {Unit[]}
*/
export function playerUnits(state, owner) {
return [...state.units.values()].filter(u => u.owner === owner);
}