Spaces:
Paused
Paused
Claude
Claude Opus 4.8
Get CI green, and make the AI's opponent belief fade the way it says it does
15f3a03 unverified Download engine/state.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 21.4 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/state.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/engine/state.js
-
curl -L -o state.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/state.js
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. | |
| ============================================================ */ | |
| ; | |
| 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); | |
| } | |