File size: 17,715 Bytes
240c1d3
 
 
 
729f7b4
 
 
 
 
 
 
 
240c1d3
 
 
 
97f3bdf
240c1d3
32d2559
240c1d3
729f7b4
 
240c1d3
 
 
b02b34f
240c1d3
 
 
 
32d2559
 
 
 
 
 
 
 
240c1d3
 
32d2559
 
240c1d3
32d2559
240c1d3
 
 
 
32d2559
 
 
240c1d3
 
 
 
84220dc
 
 
 
 
 
 
 
 
240c1d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84220dc
240c1d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916c3ad
 
 
 
 
84220dc
 
 
 
916c3ad
84220dc
 
 
 
 
 
 
 
 
 
 
729f7b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5853645
 
 
 
 
729f7b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5853645
729f7b4
 
5853645
729f7b4
 
 
 
 
240c1d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729f7b4
 
916c3ad
 
 
 
 
 
 
5853645
240c1d3
 
916c3ad
240c1d3
 
 
 
42d791d
 
 
 
 
 
 
 
916c3ad
42d791d
916c3ad
42d791d
 
 
 
916c3ad
42d791d
 
 
 
 
 
240c1d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* ============================================================
   AI β€” worker assignment and the unit-mix filters. Idle-worker logistics +
   gather steering (assignAiLogistics / assignIdleWorkers), and the map-aware
   mix helpers the production phases read: which unit types this world can
   actually sustain (effectiveMix / affordableOnSurface), whether a developing
   AI's PLANNED mix should extend past its archetype's own tiers (plannedMix /
   wantsDeepIndustry β€” the shared signal aiEconomy's Foundry/Arsenal gates and
   aiIndustry's factory climb both key off), and which upgrade doctrine its
   economy favours (aiDoctrine). A leaf module for the other AI PHASE modules
   (aiMilitary/aiEconomy/aiIndustry import from it freely, never the reverse)
   β€” it only reaches sideways into two pure-data tables of its own,
   aiStrategy.js and aiDifficulty.js, neither of which imports anything back.
   ============================================================ */

"use strict";

import { BUILDINGS, UNITS, prereqsMet, SPENDABLE } from "./entities.js";
import { assignService, assignHaul, countLogistics } from "./haul.js";
import { assignRepair, countRepairJobs } from "./repair.js";
import { isNodeDiscovered } from "./fog.js";
import { strategyFor } from "./aiStrategy.js";
import { difficultyFor } from "./aiDifficulty.js";

const SATURATION_STEER = 250;     // distance-equivalent penalty per worker a node is over the soft cap

// SPENDABLE (entities.js): every commodity that anything the AI builds actually costs.
// assignIdleWorkers prefers nodes of these types so a poor-economy world's AI
// (Glacius: ice/gas it can never spend) doesn't mine dead-end commodities.

// Give a BOUNDED share of the AI's idle workers real logistics jobs β€” servicing factories (carry
// inputs in, outputs out), hauling pure producers (the Plasma Rig) to a Command Center, and
// repairing a damaged own building β€” reusing the exact owner-generic, zone-first machinery the
// player's workers use (engine/haul.js, engine/repair.js). Capped at HALF the worker pool so
// gathering never starves: a factory with no raws mined is no better off than one with no servers,
// so the AI must keep miners on the field too. Deterministic β€” countLogistics/countRepairJobs
// freeze the committed slots first, then assignService/assignHaul/assignRepair claim the nearest
// free slot in the AI's own Command Center zone first (ties by id). Odyssey-only (industry is
// odysseyOnly); the player's own auto-haul path (engine/sim.js) is untouched.
export function assignAiLogistics(state, workers) {
  if (!workers.length) return;
  countLogistics(state);     // fresh committed haul/service tallies before we claim any new slots this cycle
  countRepairJobs(state);    // …and repair tallies too
  let budget = Math.floor(workers.length / 2)
    - workers.filter(w => w.order && (w.order.type === "service" || w.order.type === "haul" || w.order.type === "repair")).length;
  if (budget <= 0) return;
  for (const w of workers) {
    if (budget <= 0) break;
    if (w.order) continue;
    assignService(state, w);              // sets w.order to a factory service round-trip if one needs it
    if (!w.order) assignHaul(state, w);   // …else haul a backed-up pure producer (the rig)
    if (!w.order) assignRepair(state, w); // …else patch a damaged own building
    if (w.order) budget--;
  }
}

// The node-picking half of idle-gather assignment, generalised over OWNER (the AI's own fog,
// state.fogAI, vs the player's, state.fog) β€” see assignIdleWorkers (AI) and assignIdlePlayerGather
// (a background colony's worker-sustain policy, engine/colonyPolicy.js) below, the two thin
// owner-specific wrappers. Kept as one function so the node-picking logic (soft-cap spreading,
// the secondary-commodity trickle cap) can never drift between the two callers.
function idleGatherAssign(state, fog, workers) {
  // Only nodes this owner actually knows about: charted surface deposits (always) plus any
  // hidden cache they've scouted.
  const live = state.map.nodes.filter(n => n.amount > 0 && isNodeDiscovered(fog, n));
  if (!live.length) return;
  const oreLive = live.filter(n => n.com === "ore");
  const otherLive = live.filter(n => n.com !== "ore" && SPENDABLE.has(n.com));
  const nodeById = state.map.nodesById || new Map(state.map.nodes.map(n => [n.id, n]));
  let secondaryMiners = 0;
  for (const w of workers) {
    const n = w.order && w.order.type === "gather" ? nodeById.get(w.order.nodeId) : null;
    if (n && n.com !== "ore" && SPENDABLE.has(n.com)) secondaryMiners++;
  }
  // Crystals/radioactives buy only optional extras (a couple of turrets, the
  // one-time upgrades, the occasional Breacher), so a small trickle funds them
  // β€” everyone else stays on ore, the currency the whole army and every
  // building actually run on. A flat "half on ore" split would over-divert on
  // an ore-rich world and under-fund ore on an ore-poor one; capping the
  // secondary crew keeps ore primary everywhere while still reaching the
  // extras. On a crystal-heavy map (Helix) this is what stops workers piling
  // onto crystals and starving the ore the army needs.
  const secondaryCap = Math.min(2, Math.floor(workers.length / 3));

  // Projected miner tally per node, so the assignment fills a node to the soft cap and
  // then hops to the next-nearest instead of piling everyone on one seam (which
  // saturation would drop to ~0.7 efficiency, slowing the tuned economy). Seeds
  // from workers already on a gather order, and counts each assignment made in
  // this same pass so consecutive idle workers spread across nodes.
  const softCap = UNITS.worker.minerSoftCap ?? Infinity;
  const projected = new Map();
  for (const w of workers) {
    if (w.order && w.order.type === "gather") projected.set(w.order.nodeId, (projected.get(w.order.nodeId) || 0) + 1);
  }

  workers.forEach(w => {
    if (w.order) return;
    let pool;
    if (otherLive.length && oreLive.length && secondaryMiners < secondaryCap) {
      pool = otherLive; secondaryMiners++;   // fund the extras with a small crew...
    } else if (oreLive.length) {
      pool = oreLive;                         // ...but keep the bulk of workers on ore
    } else {
      pool = otherLive.length ? otherLive : live;   // ore's gone β€” take spendable, else any live node
    }
    let best = null, bestScore = Infinity;
    for (const n of pool) {
      const m = projected.get(n.id) || 0;
      const over = Math.max(0, m + 1 - softCap);   // penalty kicks in once the node is already at the cap
      const score = Math.hypot(n.x - w.x, n.y - w.y) + over * SATURATION_STEER;
      if (score < bestScore) { bestScore = score; best = n; }
    }
    if (best) {
      w.order = { type: "gather", nodeId: best.id };
      projected.set(best.id, (projected.get(best.id) || 0) + 1);
    }
  });
}

// `fog` defaults to state.fogAI so the pre-existing single-owner call site (engine/ai.js, owner
// "ai") reads exactly as before; a self-play "player" controller passes its own ctx.fog
// (state.fogs.player) instead, so it plans logistics off ITS OWN discovered nodes, never the "ai"
// owner's β€” see the fog-fairness discipline this whole module's header comment already documents.
export function assignIdleWorkers(state, workers, fog = state.fogAI) {
  // ODYSSEY: the AI runs REAL logistics like the player β€” dedicate a bounded share of workers to
  // feeding/clearing its factories and rig (finite buffers, stalls and all) before the gather pass, so
  // its industry pays the same labour cost the player's does. Skirmish has no factories β†’ no-op there.
  if (state.endless) assignAiLogistics(state, workers);
  idleGatherAssign(state, fog, workers);
}

// The PLAYER-side twin of assignIdleWorkers' gather half β€” reused by a background colony's
// worker-sustain standing order (engine/colonyPolicy.js), which re-tasks idle player workers on a
// world nobody's driving. Reads the PLAYER's own fog (state.fog), not the AI's, and deliberately
// skips assignAiLogistics (haul/service/repair): sustaining workers just means putting idle hands
// back on a node, not opting a colony into the AI's own factory-servicing behaviour.
export function assignIdlePlayerGather(state, workers) {
  idleGatherAssign(state, state.fog, workers);
}

// Tier 4 (engine/aiDifficulty.js rusherGraduates, Hard only): how long into an Odyssey world a
// non-developing archetype's opening rush has clearly either succeeded or become a non-factor β€”
// past this, on Hard, it graduates into a patient developer rather than staying a permanent
// flatline for the rest of what can be an hours-long session. Pure time, no wave-outcome
// tracking needed: if the game is still running this far in, "wait for the rush to resolve, then
// judge it" and "just check the clock" converge on the same answer.
const RUSHER_GRADUATE_TIME = 1200;   // 20 minutes

// Does this AI climb past power+electrify into the deep chain (engine/aiIndustry.js) β€” and, by the
// same signal, extend its PLANNED unit mix past its archetype's own tiers (plannedMix, below)? The
// patient-developer signal (archetype.wantsRefinery), the Economic strategy's override, or Hard's
// rusher graduation. Lives here (moved from aiIndustry.js) rather than there, so this file's own
// plannedMix β€” the unit-mix half of "does this AI climb the deep chain" β€” and aiIndustry's factory
// climb + industry reserve read the exact same test; aiIndustry now imports this instead of
// defining it (see its own header comment). Leaf-safe: aiStrategy.js and aiDifficulty.js are both
// pure data tables with no imports of their own, so reaching into them here creates no cycle.
export function wantsDeepIndustry(state, archetype, strategy, owner = "ai") {
  // `owner` matters: difficultyFor defaults to the "ai" controller, so in self-play the asking
  // controller's graduation used to be decided by the OTHER side's difficulty. Defaulting to "ai"
  // keeps every single-owner caller byte-identical.
  const graduated = !!difficultyFor(state, owner).rusherGraduates && state.time > RUSHER_GRADUATE_TIME;
  return !!(archetype.wantsRefinery || strategy.wantsIndustryAlways || graduated);
}

// GRADUATE EXTENSION (docs/improvement-proposals.md "Graduation reaches the army"): a developing AI
// (wantsDeepIndustry) climbs aiIndustry's Foundry/Arsenal chain, but a pure Tier-1 mix β€” today, only
// the Rusher's β€” never mentions a single gated unit, so aiEconomy's wantsFoundry/wantsArsenal
// (computed off the mix) stayed false even for a graduated Hard Rusher: it could reach a Star Dock
// and field Leviathans while its Barracks cycled Skiff/Bastion forever. Appended ONCE β€” and only
// when the base mix has no Foundry-gated entry of its own β€” so an Economist/Balanced mix (which
// already carries a Lancer or Breacher) is a genuine no-op here; only a pure Tier-1 profile picks
// this up.
const GRADUATE_EXTENSION = ["lancer", "lancer", "dreadnought"];

// The archetype's PLANNED unit mix β€” its base unitMix, extended with GRADUATE_EXTENSION for a
// developing/graduated AI (see above) β€” BEFORE effectiveMix's prereq/afford filtering, which runs
// on top of this unchanged. That keeps the extension deadlock-free: a planned Lancer only ever
// enters the AI's ACTUAL cycle once the Foundry it now motivates aiEconomy to build has completed
// (prereqsMet), exactly like every other gated entry already works. Skirmish is untouched: the
// state.endless gate means a skirmish's planned mix is always exactly archetype.unitMix, whatever
// the difficulty or elapsed time β€” the short game stays byte-identical.
export function plannedMix(state, archetype, owner = "ai") {
  const base = archetype.unitMix || [];
  if (!state.endless) return base;
  if (!wantsDeepIndustry(state, archetype, strategyFor(state, owner), owner)) return base;
  if (base.some(t => (UNITS[t]?.requires || []).includes("foundry"))) return base;   // already reaches the Foundry on its own
  return [...base, ...GRADUATE_EXTENSION];
}

// The AI's planned unit mix (plannedMix, above) with entries this map can never pay for dropped β€”
// a cost commodity no SURFACE deposit produces (Vesper's surface has no
// radioactives, so its Breacher entry is skipped, leaving today's exact
// three-unit cycle). Hidden caches are deliberately excluded: they can hold a
// commodity the surface lacks, but they're far, contested, and may never be
// mined, so planning the whole cycle around one would just re-stall the mix on
// a unit the AI has no steady income for. Surface EXISTENCE, not remaining
// amount, is checked, so the surviving cycle is constant for the whole match
// (nodes drain, they never vanish) and the sequence stays deterministic. Falls
// back to plain Skiffs if the filter empties the mix entirely.
// Also drops any unit whose TECH prereqs aren't met yet (Lancer/Breacher before
// the Foundry is up), so the cycle runs Skiff/Bastion only until then and never
// stalls on a locked entry β€” pickNextUnitType can only ever return an unlocked,
// affordable unit. This makes the mix change ONCE, deterministically, the tick
// the Foundry completes; both the base cycle and the counter-pick (which only
// adopts a counter that mix.includes) are prereq-safe through this one filter.
// A graduated Rusher's Lancer/Dreadnought graduate entries are no exception β€” they're filtered by
// this exact same prereqsMet check, so they enter the cycle only once their own gate is built.
// `owner` defaults to "ai" so every pre-existing call site (this file's own maxSupplyDemand, every
// test, tools/ailab.js) reads exactly as before. A self-play "player" controller passes its own
// ctx.owner instead β€” prereqsMet must check THAT owner's own completed buildings (its own Foundry/
// Arsenal), never the "ai" owner's: without this, the second controller's mix would silently gate
// on the FIRST controller's tech, either locking it out of units it can actually build or (worse)
// "unlocking" ones it can't, a correctness bug on top of the fairness one.
export function effectiveMix(state, archetype, owner = "ai") {
  const mix = plannedMix(state, archetype, owner).filter(t =>
    UNITS[t]
    && BUILDINGS.barracks.produces?.includes(t)
    && prereqsMet(state, owner, UNITS[t])
    && affordableOnSurface(state, t));
  return mix.length ? mix : ["skiff"];
}

// The biggest SUPPLY cost the AI could actually try to pay next: the largest entry in this
// world's effective mix, plus anything a completed Star Dock can build (Odyssey's 8-supply
// Leviathan). aiEconomy's Habitat trigger is sized from this rather than from a flat 2, because
// pickNextUnitType retries the SAME mix entry until it succeeds β€” so a free-supply gap that's
// wide enough to skip the old `cap - 2` trigger but too narrow for a 4- or 8-supply unit is a
// hard deadlock: production can't queue, and the Habitat that would unblock it never fires.
// (Measured on a 50-minute Odyssey world β€” docs/odyssey-ai-review.md Β§2.2.) Floored at 2, so a
// world whose mix is all 1- and 2-supply units keeps exactly its old timing.
export function maxSupplyDemand(state, archetype, owner = "ai") {
  let max = 2;
  for (const t of effectiveMix(state, archetype, owner)) max = Math.max(max, UNITS[t].supplyCost || 0);
  // A Star Dock's capital ships are queued by aiIndustry, which SKIPS rather than retrying when
  // they don't fit β€” no deadlock there, but the headroom still has to arrive eventually or the
  // deep-industry payoff is unreachable. Only counted once the Star Dock actually stands.
  for (const b of state.buildings.values()) {
    if (b.owner !== owner || b.type !== "stardock" || b.constructing) continue;
    for (const t of BUILDINGS.stardock.produces || []) max = Math.max(max, UNITS[t]?.supplyCost || 0);
    break;
  }
  return max;
}

// Can the AI pay for `unitType` from this world's STEADY income β€” i.e. does every
// cost commodity have a non-hidden (surface) deposit somewhere on the map? Shared
// by effectiveMix (which unit types actually cycle) and the Foundry/Arsenal
// "wants" gates below. Deliberately NOT prereq-aware: the gates use it to decide
// whether teching up would ever pay off, so folding in the building prereq would
// deadlock (you'd never build the Arsenal a gas-Wraith needs because the Wraith
// is prereq-locked until the Arsenal exists). ore/crystals/radioactives are
// seeded near every base (map.js MIN_GUARANTEE), so only the specialty commodities
// (gas/ice/relics) actually gate anything here.
export function affordableOnSurface(state, unitType) {
  const def = UNITS[unitType];
  return !!def && Object.keys(def.cost).every(com => state.map.nodes.some(n => n.com === com && !n.hidden));
}

// Which upgrade doctrine the AI commits to. It prefers its archetype's flavour
// (rusher/balanced Assault, economist Bulwark), but follows the world's economy
// when that world is clearly richer in the OTHER doctrine's commodity β€” Assault
// runs on radioactives, Bulwark on crystals β€” so it doesn't build a Refinery it
// can't actually research on a world short of its preferred commodity. Surface
// deposits only (mirrors effectiveMix): steady income, not a contested cache.
export function aiDoctrine(state, archetype) {
  const surfaceTotal = com => state.map.nodes
    .filter(n => n.com === com && !n.hidden).reduce((s, n) => s + n.max, 0);
  const rad = surfaceTotal("radioactives"), cry = surfaceTotal("crystals");
  const pref = archetype.doctrine || "assault";
  if (pref === "assault") return rad >= cry * 0.6 ? "assault" : "bulwark";
  return cry >= rad * 0.6 ? "bulwark" : "assault";
}