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);
}