Spaces:
Paused
Paused
File size: 21,399 Bytes
e3658fa 17641e3 e3658fa 17641e3 34ea8bc 925767d b28c2f0 17641e3 430c532 17641e3 dfed415 6db3533 dfed415 e3658fa dfed415 e3658fa 17641e3 6325132 17641e3 845de25 3f927e2 17641e3 1882245 17641e3 6325132 17641e3 e3658fa 17641e3 e6d8701 17641e3 916c3ad 925767d 916c3ad 2991d58 15f3a03 5d60e36 925767d c0f45f5 916c3ad e3658fa dadc0b2 5bffdf0 628afc1 e3658fa 17641e3 430c532 17641e3 4f1cb54 19ca109 8338353 19ca109 17641e3 b2772b8 17641e3 5bffdf0 4f1cb54 dfed415 8338353 5bffdf0 dadc0b2 cae0952 17641e3 b2772b8 17641e3 b2772b8 f1048a5 916c3ad 628afc1 916c3ad 9eded26 5330c74 3620efb 17641e3 b2772b8 17641e3 b28c2f0 916c3ad b28c2f0 17641e3 916c3ad e3658fa 17641e3 e3658fa 17641e3 24f7ec5 17641e3 e3658fa 17641e3 e3658fa 17641e3 e3658fa 17641e3 e3658fa 17641e3 | 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 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 | // @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);
}
|