File size: 13,703 Bytes
53ce1fb
 
 
b28c2f0
9bb52d4
 
 
b02b34f
 
8101484
 
 
2bf7ef0
 
 
53ce1fb
 
 
 
b28c2f0
53ce1fb
 
b28c2f0
b02b34f
b28c2f0
 
53ce1fb
 
b28c2f0
 
 
 
b02b34f
b28c2f0
b02b34f
b28c2f0
 
 
 
b02b34f
 
 
 
53ce1fb
 
9bb52d4
b28c2f0
 
9bb52d4
 
 
 
b28c2f0
 
 
 
8101484
 
 
 
 
 
5e33d82
 
 
 
 
 
 
 
2bf7ef0
 
 
 
 
 
 
 
 
53ce1fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f4b083
 
 
 
53ce1fb
 
9f4b083
53ce1fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b28c2f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b02b34f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* ============================================================
   AI DIFFICULTY (engine/aiDifficulty.js): the Easy/Medium/Hard pick from the splash
   screen, orthogonal to the archetype and the player-picked strategy β€” see the
   module header for the full design. Tier 0 (scaffold: state.ai.difficulty,
   difficultyFor, round-trip) and Tier 1 (worker target, war patience, the seeded
   economic edge) both land here. Tier 2 (AI-only research pace) is table-shape-only
   here β€” its actual behavioral coverage lives in test/techtree.test.js, alongside
   updateResearch/researchTimeScale, which it composes with. Tier 3 (market barter)
   is the same split: the gating tests are here, aiBarter's own mechanism is covered
   in test/market.test.js alongside sell/buy, which it composes with. Tier 4
   (rusherGraduates) is table-shape-only here too β€” its actual behavior lives in
   test/aiIndustry.test.js, right alongside the "Rusher skips the deep chain" test
   it carves an exception into. Tier 5 (counterEvery) is the same split again: the
   table-shape assertions are here, the cadence's actual behavior lives in
   test/ai.test.js alongside the rest of the counter-pick coverage it composes with.
   ============================================================ */

import { test } from "node:test";
import assert from "node:assert/strict";
import { createGameState, makeUnit } from "../engine/state.js";
import { createGalaxy, activeState } from "../engine/galaxy.js";
import { serializeGame, deserializeGame } from "../engine/persist.js";
import { runAI } from "../engine/ai.js";
import { aiMarketBarter } from "../engine/aiEconomy.js";
import { createDiplomacy, updateDiplomacy } from "../engine/diplomacy.js";
import { UPGRADES } from "../engine/entities.js";
import { DIFFICULTY_OPTIONS, difficultyFor } from "../engine/aiDifficulty.js";

const THINK_INTERVAL = 1.5;   // must match ai.js's own THINK_INTERVAL to force a fresh think cycle

/* ---------- the table + resolution (Tier 0) ---------- */

test("Medium carries none of the archetype-scaling dials β€” the baseline every one of them is relative to", () => {
  const medium = DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
  for (const field of ["workerTargetMult", "graceMult", "grievanceMult", "economicEdge", "researchPaceMult"]) {
    assert.equal(medium[field], undefined, `medium.${field} should be unset`);
  }
});

test("marketAccess unlocks for Medium and Hard alike (a competence fix, not a Hard-only edge); Easy stays without it", () => {
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "easy").marketAccess, undefined, "Easy: kept simple for a new player");
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "medium").marketAccess, true);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "hard").marketAccess, true);
});

test("Easy softens worker target, war patience, and research pace; Hard sharpens all three, plus the economic edge", () => {
  const easy = DIFFICULTY_OPTIONS.find(o => o.mult === "easy");
  const hard = DIFFICULTY_OPTIONS.find(o => o.mult === "hard");
  assert.ok(easy.workerTargetMult < 1 && easy.graceMult > 1 && easy.grievanceMult < 1 && easy.researchPaceMult > 1,
    "Easy: a smaller economy, a longer diplomatic fuse, and slower research (a LARGER pace divisor is slower progress)");
  assert.ok(hard.workerTargetMult > 1 && hard.graceMult < 1 && hard.grievanceMult > 1 && hard.researchPaceMult < 1,
    "Hard: a bigger economy, a shorter diplomatic fuse, and faster research (a SMALLER pace divisor is faster progress)");
  assert.equal(hard.economicEdge, true);
  assert.equal(easy.economicEdge, undefined, "Easy gets no economic edge");
});

test("rusherGraduates is Hard-only β€” the one identity-level dial, not just a number", () => {
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "easy").rusherGraduates, undefined);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "medium").rusherGraduates, undefined);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "hard").rusherGraduates, true);
});

/* ---------- Tier 6: strategicCeiling (table-shape here; behavior in test/aiIndustry.test.js) ---------- */

test("strategicCeiling is Easy-only β€” the second identity-level dial", () => {
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "easy").strategicCeiling, true);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "medium").strategicCeiling, undefined);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "hard").strategicCeiling, undefined);
});

/* ---------- Tier 5: counter-pick cadence (table-shape here; behavior in test/ai.test.js) ---------- */

test("counterEvery is 0 on Easy (never counter-picks), 2 on Hard (reacts half again as fast); Medium carries none of it", () => {
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "easy").counterEvery, 0);
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "medium").counterEvery, undefined,
    "unset -> engine/aiMilitary.js's `?? COUNTER_EVERY` fallback, byte-identical to before this dial existed");
  assert.equal(DIFFICULTY_OPTIONS.find(o => o.mult === "hard").counterEvery, 2);
});

test("every difficulty option still names its two original dials (aiApm, aiMicro) and its key", () => {
  for (const key of ["easy", "medium", "hard"]) {
    const opt = DIFFICULTY_OPTIONS.find(o => o.mult === key);
    assert.ok(opt, `expected a "${key}" entry`);
    assert.equal(typeof opt.aiApm, "number");
    assert.equal(typeof opt.aiMicro, "boolean");
  }
});

test("difficultyFor resolves each real key to its matching DIFFICULTY_OPTIONS entry", () => {
  for (const key of ["easy", "medium", "hard"]) {
    const s = createGameState({ planetId: "ferros", difficulty: key });
    assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === key));
  }
});

test("difficultyFor falls back to medium for an unset or unrecognised state.ai.difficulty", () => {
  const s = createGameState({ planetId: "ferros" });
  assert.equal(s.ai.difficulty, "medium", "createGameState's own default, unprompted");
  assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "medium"));
  s.ai.difficulty = "not-a-real-difficulty";
  assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "medium"), "unrecognised β‡’ medium, never throws");
});

test("createGameState wires opts.difficulty onto state.ai.difficulty", () => {
  const s = createGameState({ planetId: "ferros", difficulty: "hard" });
  assert.equal(s.ai.difficulty, "hard");
  assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "hard"));
});

test("createGalaxy threads settings.difficulty onto the active (start) planet's state.ai.difficulty", () => {
  // Superseded by the varied-neighbour feature (test/livingGalaxy.test.js): every OTHER world now
  // resolves its own independent difficulty (neighbourAiProfile, engine/galaxy.js) rather than
  // mirroring this setting everywhere β€” only the player's actual start seat still matches it exactly.
  const g = createGalaxy({ seed: 11, difficulty: "hard" });
  assert.equal(g.settings.difficulty, "hard");
  assert.equal(g.planets.get(g.activeId).ai.difficulty, "hard", "the start world matches the player's pick");
  assert.equal(activeState(g).ai.difficulty, "hard");
});

test("a saved game round-trips state.ai.difficulty through JSON", () => {
  const a = createGameState({ planetId: "ferros", difficulty: "hard" });
  const b = deserializeGame(JSON.parse(JSON.stringify(serializeGame(a))));
  assert.equal(b.ai.difficulty, "hard");
});

test("an old save with no aiDifficulty field loads as medium β€” the same defaulting every other additive ai.* field already gets", () => {
  const a = createGameState({ planetId: "ferros", difficulty: "hard" });
  const save = serializeGame(a);
  delete save.ai.aiDifficulty;   // simulate a save written before this field existed
  const b = deserializeGame(JSON.parse(JSON.stringify(save)));
  assert.equal(b.ai.difficulty, "medium");
});

/* ---------- Tier 1: worker target ---------- */

test("workerTargetMult composes onto the archetype's workerTarget: Hard queues a worker Easy wouldn't, at the identical headcount", () => {
  const mk = difficulty => {
    const s = createGameState({ planetId: "ferros", rng: () => 0.5, difficulty });
    // A fixed, easy-to-reason-about archetype (mirrors ai.test.js's "legacy archetype" fixture) β€”
    // isolates the difficulty multiplier from any archetype-specific workerTarget value.
    s.ai.archetype = { name: "Test", workerTarget: 10, armyAttackSize: 4, attackTimeout: 90, unitMix: ["skiff"] };
    s.players.ai.resources.ore = 100000;
    // Pin the AI's worker headcount at exactly 9 β€” the boundary between Easy's 10*0.8=8
    // (9 already meets/exceeds it, so it must NOT queue another) and Hard's 10*1.25=12.5
    // (9 is still short, so it MUST queue one).
    for (const [id, u] of [...s.units]) if (u.owner === "ai" && u.type === "worker") s.units.delete(id);
    for (let i = 0; i < 9; i++) {
      const w = makeUnit("worker", "ai", s.map.bases.ai.x + i, s.map.bases.ai.y);
      s.units.set(w.id, w);
    }
    return s;
  };
  const cc = s => [...s.buildings.values()].find(b => b.owner === "ai" && b.type === "command");

  const easy = mk("easy");
  runAI(easy, THINK_INTERVAL);
  assert.equal(cc(easy).queue.length, 0, "Easy's target (8) is already met by 9 workers β€” no new one queued");

  const hard = mk("hard");
  runAI(hard, THINK_INTERVAL);
  assert.equal(cc(hard).queue.length, 1, "Hard's target (12.5) is still short at 9 workers β€” queues one more");
});

/* ---------- Tier 1: war patience ---------- */

test("Hard sours an Odyssey neighbour faster than Easy, at identical time & scarcity, on the identical archetype", () => {
  // vesper -> Balanced, which carries no odyssey overlay of its own (ai-odyssey.test.js), isolating
  // the difficulty effect from any archetype-driven grace/grievance difference β€” the same isolation
  // aiStrategy.test.js's own Aggressive-vs-Adaptive diplomacy test uses.
  const mk = difficulty => {
    const s = createGameState({ planetId: "vesper", rng: () => 0.5, difficulty });
    s.diplomacy = createDiplomacy();
    s.endless = true;
    return s;
  };
  const hard = mk("hard"), easy = mk("easy");
  for (const s of [hard, easy]) {
    // Past BOTH difficulties' grace window (420s * graceMult: ~378 for Hard, ~483 for Easy) β€”
    // difficulty's own multiplier is deliberately mild (unlike Aggressive's 0.2), so a time still
    // inside either grace window floors both to the identical GRACE_FLOOR and hides the difference.
    s.time = 600;
    for (const n of s.map.nodes) n.amount = n.max * 0.5;   // identical scarcity on both worlds
    for (let i = 0; i < 60; i++) updateDiplomacy(s, 0.5);
  }
  assert.ok(hard.diplomacy.stance < easy.diplomacy.stance,
    `Hard is more hostile than Easy at identical time & scarcity (hard ${hard.diplomacy.stance.toFixed(3)} vs easy ${easy.diplomacy.stance.toFixed(3)})`);
});

/* ---------- Tier 1: economic edge ---------- */

test("Hard seeds the synthetic hardEdge upgrade onto the AI (and only the AI) at creation; every other tier leaves it unset", () => {
  const hard = createGameState({ planetId: "ferros", difficulty: "hard" });
  assert.equal(hard.players.ai.upgrades.hardEdge, true);
  assert.equal(hard.players.player.upgrades.hardEdge, undefined, "never the player's β€” this is a difficulty artifact, not a real pick");

  for (const difficulty of ["easy", "medium", undefined]) {
    const s = createGameState({ planetId: "ferros", difficulty });
    assert.equal(s.players.ai.upgrades.hardEdge, undefined, `${difficulty ?? "(default)"} seeds no economic edge`);
  }
});

test("hardEdge is doctrine-less and excluded from the player-facing Refinery panel by design (see entities.js committedDoctrine)", () => {
  assert.equal(UPGRADES.hardEdge.doctrine, undefined);
  assert.equal(UPGRADES.hardEdge.aiOnly, true);
  assert.equal(typeof UPGRADES.hardEdge.gatherYieldMult, "number");
  assert.equal(typeof UPGRADES.hardEdge.produceTimeMult, "number");
});

/* ---------- Tier 3: market barter gating (the mechanism itself is test/market.test.js) ---------- */

test("aiMarketBarter never fires on Easy, even against an enormous surplus", () => {
  const g = createGalaxy({ seed: 3, difficulty: "easy" });
  const s = activeState(g);
  s.players.ai.resources.ore = 1_000_000;
  const before = { ...s.players.ai.resources };
  aiMarketBarter(s);
  assert.deepEqual(s.players.ai.resources, before, "Easy has no marketAccess β€” a pure no-op regardless of surplus");
});

test("aiMarketBarter fires on Medium and Hard alike against a real surplus", () => {
  for (const difficulty of ["medium", "hard"]) {
    const g = createGalaxy({ seed: 3, difficulty });
    const s = activeState(g);
    s.players.ai.resources.ore = 1_000_000;
    const oreBefore = s.players.ai.resources.ore;
    aiMarketBarter(s);
    assert.ok(s.players.ai.resources.ore < oreBefore, `${difficulty}: a real trade actually moved the surplus`);
  }
});

test("aiMarketBarter is a no-op outside Odyssey β€” there's no state.market to trade on", () => {
  const skirmish = createGameState({ planetId: "ferros", difficulty: "hard" });
  assert.equal(skirmish.market, undefined, "sanity: a bare skirmish state really has no market");
  assert.doesNotThrow(() => aiMarketBarter(skirmish));

  const bareEndless = createGameState({ planetId: "ferros", endless: true, difficulty: "hard" });
  assert.equal(bareEndless.market, undefined, "sanity: state.market is only ever set by galaxy.js's addPlanet, not createGameState itself");
  assert.doesNotThrow(() => aiMarketBarter(bareEndless));
});