File size: 12,265 Bytes
17641e3
ac7d42f
 
 
 
916c3ad
c769ac5
 
 
4ed9122
916c3ad
 
c769ac5
916c3ad
 
 
 
 
 
 
 
 
ac7d42f
 
 
916c3ad
 
 
 
240c1d3
916c3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be1f1f9
240c1d3
 
 
916c3ad
be1f1f9
 
 
b02b34f
be1f1f9
 
240c1d3
 
17641e3
 
 
 
240c1d3
17641e3
916c3ad
240c1d3
 
b02b34f
42d791d
be1f1f9
 
5d60e36
 
17641e3
021c865
 
 
 
 
 
 
ac7d42f
916c3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
17641e3
916c3ad
3553703
 
916c3ad
3553703
 
916c3ad
 
3553703
 
 
 
42d791d
3553703
 
916c3ad
a271172
3553703
be1f1f9
3553703
 
 
 
916c3ad
3553703
 
916c3ad
 
 
 
3553703
916c3ad
 
 
 
 
 
206e424
 
916c3ad
 
 
 
be1f1f9
916c3ad
be1f1f9
 
 
 
916c3ad
be1f1f9
916c3ad
 
2991d58
 
 
 
5d60e36
 
 
 
3553703
916c3ad
be1f1f9
2991d58
 
 
916c3ad
 
 
 
 
3553703
 
916c3ad
3553703
 
 
be1f1f9
42d791d
3553703
 
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
/* ============================================================
   Scripted AI opponent: keep workers on the nearest spendable node, keep
   population growing, put up a Barracks once it can afford one, then cycle
   through its archetype's unit mix (Skiff/Bastion/Lancer/Breacher β€” see
   entities.js for the rock-paper-scissors relationship) across every
   Barracks it owns, and throw its home army at the enemy base in
   repeated waves, once each wave is big enough (or the game's dragged on
   long enough that it should commit anyway). Along the way it fortifies
   with Sentinel Turrets on the approach vector, expands to a second
   Command Center when its home ore runs thin, puts up a Refinery, and
   researches both upgrades β€” so its opponent isn't the only side that gets
   to use crystals/radioactives, expansions, or defenses.

   The AI plays under its own fog of war (state.fogs[owner], never a
   hardcoded state.fogAI) β€” it is NOT omniscient. It keeps one unit out
   scouting (updateScout), and its intel-dependent moves are gated by what
   it has actually seen: every few units it breaks from the mix to counter
   whatever the enemy fields most, but only counting enemy units currently
   in its vision (counterToPlayerArmy); and it only mines or expands to
   nodes it has discovered β€” charted surface deposits always, hidden caches
   once scouted. The rest of the cycle follows the archetype's own flavor,
   so a Rusher doesn't turn into a pure reactive counter-picker.

   How aggressively vs. how patiently it plays β€” worker/army targets, attack
   timing, unit mix, how many turrets and barracks, when to expand β€” all comes
   from its controller's `archetype` (see engine/aiArchetypes.js), which is
   picked by which planet the match is on. This file just executes whatever
   profile it's handed; every Tier 4 field is read with a use-site default so
   a legacy profile that predates them still runs.

   Layered on top of the archetype is the controller's `strategy` (engine/
   aiStrategy.js) β€” a player-picked overlay (setup.js "AI Strategy"), orthogonal
   to which planet/archetype it's facing: Aggressive presses the attack earlier
   and forces the issue in Odyssey; Economic keeps a minimal standing army until
   it's actually attacked, then surges; Force Parity continuously matches
   whatever it's seen of the enemy's army. "default" (unset) reproduces today's
   pure archetype-driven play byte-for-byte β€” every multiplier defaults to 1 and
   every flag to falsy.

   TIER 1 SELF-PLAY (tools/selfplay.js β€” a headless bench, never the shipped
   game): runAI(state, dt, owner) drives EITHER side. owner defaults to "ai",
   so every pre-existing call site (engine/sim.js's per-tick runAI(state, dt),
   every one of the ~26 test files that touch state.ai) gets exactly today's
   behavior, unchanged. Passing owner="player" instead drives a SECOND,
   independently-configured controller for the human seat β€” state.playerAi,
   null unless a caller has populated it β€” with its own archetype/strategy/
   difficulty/APM/action budget, reading state.fogs.player (never state.fogAI)
   everywhere it looks at the world. state.ai and its exact meaning for owner
   "ai" are completely untouched by any of this. See aiCommon.js's
   controllerFor/otherOwner for how a phase resolves "my controller" and "my
   opponent" from `owner` alone.

   This file is the ORCHESTRATOR: runAI runs the think-cycle and threads one
   world snapshot (aiContext) through the decision phases, which live in cohesive
   sibling modules so no single file is a 1000-line god object β€”
     β€’ aiCommon.js     β€” owner resolution + the APM action budget + reserve-aware affordability + builder pick
     β€’ aiStrategy.js   β€” the player-picked strategy table (Aggressive/Economic/Force Parity)
     β€’ aiWorkers.js    β€” idle-worker logistics/gather steering + the unit-mix filters
     β€’ aiMilitary.js   β€” defend/attack waves, focus-fire, the scout, target/mix picks
     β€’ aiEconomy.js    β€” base build-out, expansion, tech gates, production, research, market barter
     β€’ aiIndustry.js   β€” the Odyssey factory chain / power / capital path / rig
     β€’ aiSuperweapon.js β€” arms/delivers a built Helium Bomb
   The phase order in runAI is load-bearing (shared action budget + ore reserves),
   so it stays here where the whole sequence reads top-to-bottom.
   ============================================================ */

"use strict";

import { UNITS } from "./entities.js";
import { playerBuildings, playerUnits } from "./state.js";
import { accrueActionBudget, controllerFor, otherOwner } from "./aiCommon.js";
import { assignIdleWorkers } from "./aiWorkers.js";
import { updateScout, aiMilitary, applyFocusFire, visibleThreatsNearHome } from "./aiMilitary.js";
import { aiFoundOrSurvive, aiExpand, aiBaseAndTech, aiProduceAndFortify, aiResearch, aiMarketBarter } from "./aiEconomy.js";
import { aiIndustry, aiIndustryReserve } from "./aiIndustry.js";
import { aiSuperweapon } from "./aiSuperweapon.js";
import { strategyFor } from "./aiStrategy.js";
import { updateIntel, readEnemy, updateAdaptMode } from "./aiIntel.js";
import { adaptivityFor } from "./aiDifficulty.js";

// Sim seconds between one controller's think cycles. EXPORTED so a test can drive an AI function
// at the cadence the game really uses instead of hardcoding its own guess β€” the gap that let
// aiIntel.js's fade ship a decay two hundred times too steep: its tests jumped state.time in one
// hop, which is correct for a single call and says nothing about the dozens runAI actually makes
// between two sightings. test/_helpers.js's advanceThinkCycles is built on this constant so the
// two can never drift apart.
export const THINK_INTERVAL = 1.5;

/**
 * @param {State} state
 * @param {number} dt
 * @param {string} [owner]   which side to drive β€” "ai" (default, today's only configuration) or
 *   "player" (Tier 1 self-play only, and only once state.playerAi has been populated β€” see
 *   tools/selfplay.js). A "player" call with no state.playerAi is a safe, silent no-op.
 */
export function runAI(state, dt, owner = "ai") {
  accrueActionBudget(state, dt, owner);   // every tick, so credits build up between think cycles
  const controller = controllerFor(state, owner);
  if (!controller) return;   // self-play inert: owner "player" with no state.playerAi configured yet
  controller.think = (controller.think || 0) - dt;
  if (controller.think > 0) return;
  controller.think = THINK_INTERVAL;

  // One snapshot of this controller's world, threaded through every decision phase below. Order is
  // load-bearing: each phase can spend from a shared per-cycle action budget (canAct/spend),
  // and an earlier phase can bank ore (ctx.*Reserve) that a later one must leave alone.
  const ctx = aiContext(state, owner);

  // Scout + worker assignment run first β€” a worker turned scout mustn't then be re-tasked to gather.
  updateScout(state, ctx, ctx.threats.length > 0);
  assignIdleWorkers(state, ctx.workers, ctx.fog);

  aiFoundOrSurvive(state, ctx);    // Odyssey: (re)seat a razed / opening base from a colony ship
  aiExpand(state, ctx);            // scout Ranger + found a second base once home ore thins (sets ctx.oreReserve)
  aiBaseAndTech(state, ctx);       // workers, supply, Barracks, the Foundry/Arsenal tech gates, the Mender
  aiIndustryReserve(state, ctx);   // Odyssey: bank the grid + bootstrap chain BEFORE production can spend it (sets ctx.industryReserve)
  aiProduceAndFortify(state, ctx); // the shared unit-production cycle, Turrets, a 2nd Barracks, Refineries
  aiResearch(state, ctx);          // one doctrine upgrade per think cycle
  aiMarketBarter(state, ctx.owner); // Odyssey, Medium+/Hard only: convert a surplus into this cycle's bottleneck (owner "ai" only β€” Odyssey/market is out of Tier 1's scope)
  aiIndustry(state, ctx);          // Odyssey: power the base + electrify it (deeper phases: the factory chain)
  aiMilitary(state, ctx);          // defend a pressed base, else muster and commit the next wave
  aiSuperweapon(state, ctx);       // Odyssey: arm/deliver a built Helium Bomb (engine/aiSuperweapon.js)

  // TACTICAL micro (opt-in via aiMicro): concentrate the army's fire on one target so kills land
  // faster and incoming damage drops sooner β€” layered on top of the wave logic above without
  // touching it. A no-op unless real enemy combat is in sight, so the resolve guarantee holds.
  if (controller.micro) applyFocusFire(state, ctx);
}

// Snapshot `owner`'s world once per think cycle: the archetype (+ an `arch` reader that lets its
// Odyssey overlay win), this controller's own units bucketed by role, its buildings and the key
// ones by type, and the enemy combat pressing home β€” all read off `owner`'s OWN fog
// (state.fogs[owner]), never a hardcoded side. The three *Reserve fields start at 0 and are the
// running ore holdbacks the phases pass forward. See the AiContext typedef in engine/types.js.
/** @param {State} state @param {string} owner @returns {AiContext} */
function aiContext(state, owner = "ai") {
  const controller = controllerFor(state, owner);
  const enemyOwner = otherOwner(owner);
  const fog = state.fogs[owner];
  const archetype = controller.archetype;
  const arch = field => (state.diplomacy && archetype.odyssey && archetype.odyssey[field] != null)
    ? archetype.odyssey[field] : archetype[field];
  const strategy = strategyFor(state, owner);
  const buildings = playerBuildings(state, owner);
  const threats = visibleThreatsNearHome(state, owner);
  // WAR FOOTING (engine/aiStrategy.js Economic strategy): the instant this controller can SEE
  // combat units pressing its base, timestamp it β€” then for warFootingTime seconds
  // afterward, aiEconomy's standing-army throttle treats it as "recently attacked"
  // and lifts its cap, so being attacked is what turns a minimal economy into a real
  // production push. Updated here (not left to a later phase) so it's consistent for
  // every phase reading ctx.warFooting this same cycle. A strategy without
  // warFootingTime (every strategy but Economic) reads warFooting as permanently false.
  if (threats.length > 0) controller.lastThreatAt = state.time;
  const warFooting = !!strategy.warFootingTime
    && controller.lastThreatAt != null
    && (state.time - controller.lastThreatAt) < strategy.warFootingTime;
  // OPPONENT BELIEF (engine/aiIntel.js): fold this cycle's sighting into the persistent read BEFORE
  // any phase consults it, exactly like warFooting above β€” so every phase in one cycle reasons
  // about the same picture of the enemy rather than each re-deriving it at a different moment.
  updateIntel(state, owner);
  // …then step the damped STANCE off that belief (aiIntel.js updateAdaptMode). Order matters: the
  // stance must be derived from THIS cycle's sighting, not last cycle's. adaptivity 0 (Easy) pins
  // it at neutral, which makes every consumer below byte-identical to having no stance at all.
  updateAdaptMode(state, owner, adaptivityFor(state, owner));
  return {
    owner, enemyOwner, fog, controller,
    archetype, arch, strategy, warFooting,
    // The read itself, snapshotted once per cycle. `posture` is null until something has actually
    // been seen β€” a consumer must treat that as "I don't know", never as "they are peaceful".
    enemy: readEnemy(state, owner),
    ai: state.players[owner],
    workers: playerUnits(state, owner).filter(u => u.type === "worker"),
    army: playerUnits(state, owner).filter(u => UNITS[u.type].role === "combat"),
    rangers: playerUnits(state, owner).filter(u => u.type === "ranger"),
    bombs: playerUnits(state, owner).filter(u => u.type === "heliumbomb"),
    buildings,
    cc: buildings.find(b => b.type === "command" && !b.constructing),
    colonyShip: state.endless ? (playerUnits(state, owner).find(u => u.type === "colonyship") || null) : null,
    barracks: buildings.find(b => b.type === "barracks"),
    refinery: buildings.find(b => b.type === "refinery"),
    allBarracks: buildings.filter(b => b.type === "barracks"),
    threats,
    oreReserve: 0, foundryReserve: 0, refineryReserve: 0, industryReserve: 0,
  };
}