Claude Claude Sonnet 5 commited on
Commit
53ce1fb
Β·
unverified Β·
1 Parent(s): 62ceb62

Move DIFFICULTY_OPTIONS into engine/aiDifficulty.js, add state.ai.difficulty

Browse files

Scaffold for tiered difficulty economics: the Easy/Medium/Hard table now
lives in engine/ (mirroring how aiStrategy.js already holds STRATEGIES)
with a difficultyFor(state) resolver alongside it, and every state.ai now
carries the picked difficulty as a plain string, threaded through boot.js's
skirmish/Odyssey paths and galaxy.js's per-planet seeding, with save/load
round-trip support. setup.js re-exports DIFFICULTY_OPTIONS so boot.js and
existing tests are unaffected. No economic field is added to any difficulty
tier yet β€” every new/updated test proves this lands as a pure no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G73oK4p3bnreizCcMUHVEy

boot.js CHANGED
@@ -108,7 +108,8 @@ export function startGame(planetId) {
108
  // (aiArchetypes.js), so the opponent's identity is part of the world's character.
109
  const aiFaction = archetypeFor(planetId).faction || "neutral";
110
  const fresh = createGameState({ planetId, seed, rng: mulberry32(seed),
111
- aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy, sizeMult: setup.sizeMult, resourceMult: setup.resourceMult,
 
112
  playerFaction: setup.faction, aiFaction });
113
  bootState(fresh, { intro: true });
114
  }
 
108
  // (aiArchetypes.js), so the opponent's identity is part of the world's character.
109
  const aiFaction = archetypeFor(planetId).faction || "neutral";
110
  const fresh = createGameState({ planetId, seed, rng: mulberry32(seed),
111
+ aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy, difficulty: setup.difficulty,
112
+ sizeMult: setup.sizeMult, resourceMult: setup.resourceMult,
113
  playerFaction: setup.faction, aiFaction });
114
  bootState(fresh, { intro: true });
115
  }
engine/aiDifficulty.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ AI DIFFICULTY β€” the Easy/Medium/Hard pick from the splash screen, orthogonal to
3
+ both the archetype (engine/aiArchetypes.js β€” which planet/temperament) and the
4
+ player-picked strategy (engine/aiStrategy.js β€” whether/when it voluntarily
5
+ attacks). Bundles the two original dials β€” how FAST the opponent acts (aiApm)
6
+ and whether it MICROS its army (aiMicro) β€” plus the picker's own label/note.
7
+ This is the ONE list of valid difficulty keys: it drives the Easy/Medium/Hard
8
+ buttons in setup.js AND (via boot.js's difficultyDials, which looks a key up in
9
+ this same array) the AI dials a match actually runs with β€” so a key can never
10
+ exist in one place and not the other, which used to let a mismatched difficulty
11
+ silently downgrade to Medium instead of erroring.
12
+
13
+ Economic dials land here as more fields on an entry, read through
14
+ difficultyFor(state) the same defensive way engine/aiStrategy.js's
15
+ strategyFor(state) already reads STRATEGIES β€” a multiplier field with `|| 1`
16
+ at its use site, a flag read falsy β€” so an absent field or a legacy/unknown key
17
+ composes as a no-op. This file adds no such field yet: state.ai.difficulty and
18
+ this resolver are pure scaffold, unread by anything but their own test until a
19
+ dial is added on top.
20
+ ============================================================ */
21
+
22
+ "use strict";
23
+
24
+ export const DIFFICULTY_OPTIONS = [
25
+ { label: "Easy", mult: "easy", note: "slow Β· no micro", aiApm: 20, aiMicro: false },
26
+ { label: "Medium", mult: "medium", note: "a fair fight", aiApm: 65, aiMicro: false },
27
+ { label: "Hard", mult: "hard", note: "fast Β· focus-fire Β· kite", aiApm: 140, aiMicro: true },
28
+ ];
29
+
30
+ /** The active difficulty entry for this match β€” DIFFICULTY_OPTIONS' medium entry when
31
+ * state.ai.difficulty is unset or names a key that isn't in the list, the same fallback
32
+ * boot.js's difficultyDials already applies before a match even starts. */
33
+ export function difficultyFor(state) {
34
+ return DIFFICULTY_OPTIONS.find(o => o.mult === state.ai.difficulty)
35
+ || DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
36
+ }
engine/galaxy.js CHANGED
@@ -132,7 +132,8 @@ export function addPlanet(galaxy, planetId, { unsettled = false } = {}) {
132
  const aiFaction = archetypeFor(planetId).faction || "neutral";
133
  const state = createGameState({
134
  planetId, seed, rng: mulberry32(seed),
135
- aiApm: s.aiApm, aiMicro: s.aiMicro, aiStrategy: s.aiStrategy, sizeMult: s.sizeMult, resourceMult: s.resourceMult,
 
136
  playerFaction: s.playerFaction, aiFaction, endless: true,
137
  });
138
  if (unsettled) {
 
132
  const aiFaction = archetypeFor(planetId).faction || "neutral";
133
  const state = createGameState({
134
  planetId, seed, rng: mulberry32(seed),
135
+ aiApm: s.aiApm, aiMicro: s.aiMicro, aiStrategy: s.aiStrategy, difficulty: s.difficulty,
136
+ sizeMult: s.sizeMult, resourceMult: s.resourceMult,
137
  playerFaction: s.playerFaction, aiFaction, endless: true,
138
  });
139
  if (unsettled) {
engine/persist.js CHANGED
@@ -404,6 +404,10 @@ function serPlanet(state) {
404
  // way aiApm/aiMicro already are (not re-derived like the archetype, since it's not a function of
405
  // planetId), so an old save without the field defaults to "default" (byte-identical to today).
406
  aiStrategy: state.ai.strategy || "default",
 
 
 
 
407
  // Sim-time of the last seen threat near home (drives Economic's war-footing window,
408
  // engine/ai.js). Additive + nullish-defaulted, so an old save without it loads as "no recent
409
  // threat" β€” the safe, conservative default (a freshly reloaded game reads as being at peace).
@@ -557,6 +561,7 @@ function rehydratePlanet(P) {
557
  scoutId: P.ai.aiScoutId, think: P.ai.aiThink,
558
  apm: P.ai.aiApm, micro: P.ai.aiMicro,
559
  strategy: P.ai.aiStrategy || "default",
 
560
  lastThreatAt: P.ai.aiLastThreatAt ?? null,
561
  actionBudget: P.ai.aiActionBudget,
562
  attackForce: P.ai.aiAttackForce, attackDesperate: P.ai.aiAttackDesperate,
 
404
  // way aiApm/aiMicro already are (not re-derived like the archetype, since it's not a function of
405
  // planetId), so an old save without the field defaults to "default" (byte-identical to today).
406
  aiStrategy: state.ai.strategy || "default",
407
+ // The splash-screen Easy/Medium/Hard pick (engine/aiDifficulty.js) β€” persisted the same
408
+ // redundant-per-planet way aiApm/aiMicro/aiStrategy already are, so an old save without the
409
+ // field defaults to "medium" (the same fallback difficultyFor(state) itself applies).
410
+ aiDifficulty: state.ai.difficulty || "medium",
411
  // Sim-time of the last seen threat near home (drives Economic's war-footing window,
412
  // engine/ai.js). Additive + nullish-defaulted, so an old save without it loads as "no recent
413
  // threat" β€” the safe, conservative default (a freshly reloaded game reads as being at peace).
 
561
  scoutId: P.ai.aiScoutId, think: P.ai.aiThink,
562
  apm: P.ai.aiApm, micro: P.ai.aiMicro,
563
  strategy: P.ai.aiStrategy || "default",
564
+ difficulty: P.ai.aiDifficulty || "medium",
565
  lastThreatAt: P.ai.aiLastThreatAt ?? null,
566
  actionBudget: P.ai.aiActionBudget,
567
  attackForce: P.ai.aiAttackForce, attackDesperate: P.ai.aiAttackDesperate,
engine/state.js CHANGED
@@ -80,7 +80,7 @@ export function makeBuilding(type, owner, x, y, opts = {}) {
80
  * regenerates deterministically from the seed, so two same-option runs are identical.
81
  * @param {{ planetId?: string, rng?: () => number, seed?: number, sizeMult?: number,
82
  * resourceMult?: number, endless?: boolean, aiApm?: number, aiMicro?: boolean,
83
- * aiStrategy?: string, playerFaction?: string, aiFaction?: string }} [opts]
84
  * @returns {State}
85
  */
86
  export function createGameState(opts = {}) {
@@ -156,6 +156,7 @@ export function createGameState(opts = {}) {
156
  apm: opts.aiApm ?? null, // AI actions-per-minute cap from the splash screen; null = unthrottled (default/tests)
157
  micro: opts.aiMicro ?? false, // Tactical AI: unit-level micro (focus-fire, kiting). Off by default (and in tests).
158
  strategy: opts.aiStrategy || "default", // player-picked AI strategy (engine/aiStrategy.js) β€” "default" β‡’ byte-identical to today
 
159
  lastThreatAt: null, // sim-time of the last seen threat near home β€” drives the Economic strategy's war-footing window (engine/ai.js)
160
  actionBudget: 0, // accumulated action credits (see engine/ai.js's accrueActionBudget)
161
  attackForce: 0, // size of the current committed attack at its peak β€” drives the retreat check (ai.js)
 
80
  * regenerates deterministically from the seed, so two same-option runs are identical.
81
  * @param {{ planetId?: string, rng?: () => number, seed?: number, sizeMult?: number,
82
  * resourceMult?: number, endless?: boolean, aiApm?: number, aiMicro?: boolean,
83
+ * aiStrategy?: string, difficulty?: string, playerFaction?: string, aiFaction?: string }} [opts]
84
  * @returns {State}
85
  */
86
  export function createGameState(opts = {}) {
 
156
  apm: opts.aiApm ?? null, // AI actions-per-minute cap from the splash screen; null = unthrottled (default/tests)
157
  micro: opts.aiMicro ?? false, // Tactical AI: unit-level micro (focus-fire, kiting). Off by default (and in tests).
158
  strategy: opts.aiStrategy || "default", // player-picked AI strategy (engine/aiStrategy.js) β€” "default" β‡’ byte-identical to today
159
+ difficulty: opts.difficulty || "medium", // splash-screen Easy/Medium/Hard pick (engine/aiDifficulty.js) β€” read via difficultyFor(state)
160
  lastThreatAt: null, // sim-time of the last seen threat near home β€” drives the Economic strategy's war-footing window (engine/ai.js)
161
  actionBudget: 0, // accumulated action credits (see engine/ai.js's accrueActionBudget)
162
  attackForce: 0, // size of the current committed attack at its peak β€” drives the retreat check (ai.js)
engine/types.js CHANGED
@@ -218,6 +218,7 @@
218
  * @property {{x:number, y:number}|null} colonyTarget
219
  * @property {number|null} apm
220
  * @property {boolean} micro
 
221
  * @property {number} actionBudget
222
  * @property {number} attackForce
223
  * @property {boolean} attackDesperate
 
218
  * @property {{x:number, y:number}|null} colonyTarget
219
  * @property {number|null} apm
220
  * @property {boolean} micro
221
+ * @property {string} difficulty splash-screen Easy/Medium/Hard pick (engine/aiDifficulty.js), read via difficultyFor(state)
222
  * @property {number} actionBudget
223
  * @property {number} attackForce
224
  * @property {boolean} attackDesperate
setup.js CHANGED
@@ -13,6 +13,8 @@ import { PLANETS } from "./data.js";
13
  import { PLANET_MODIFIERS } from "./engine/map.js";
14
  import { archetypeFor, PLANET_ARCHETYPE } from "./engine/aiArchetypes.js";
15
  import { STRATEGIES } from "./engine/aiStrategy.js";
 
 
16
  import { FACTIONS, PLAYABLE_FACTIONS } from "./engine/factions.js";
17
  import { hasSave, loadGame, hasOdysseySave, loadOdyssey } from "./saveload.js";
18
  import { APP_VERSION } from "./version.js";
@@ -37,18 +39,6 @@ const RESOURCE_OPTIONS = [
37
  { label: "Normal", mult: 1.0, note: "balanced" },
38
  { label: "Abundant", mult: 1.5, note: "rich deposits" },
39
  ];
40
- // Difficulty bundles the two dials β€” how FAST the opponent acts (aiApm) and
41
- // whether it MICROS its army (aiMicro) β€” into one Easy/Medium/Hard pick, plus
42
- // the picker's own label/note. This is the ONE list of valid difficulty keys:
43
- // it drives the Easy/Medium/Hard buttons here AND (via boot.js's difficultyDials,
44
- // which looks a key up in this same array) the AI dials a match actually runs
45
- // with β€” so a key can never exist in one place and not the other, which used to
46
- // let a mismatched difficulty silently downgrade to Medium instead of erroring.
47
- export const DIFFICULTY_OPTIONS = [
48
- { label: "Easy", mult: "easy", note: "slow Β· no micro", aiApm: 20, aiMicro: false },
49
- { label: "Medium", mult: "medium", note: "a fair fight", aiApm: 65, aiMicro: false },
50
- { label: "Hard", mult: "hard", note: "fast Β· focus-fire Β· kite", aiApm: 140, aiMicro: true },
51
- ];
52
  // Playable factions for the setup picker β€” a passive-trait identity for your side
53
  // (engine/factions.js). Each option's `mult` is the faction id, its note the short
54
  // tagline of its edge. The AI's faction comes from the world's archetype instead.
 
13
  import { PLANET_MODIFIERS } from "./engine/map.js";
14
  import { archetypeFor, PLANET_ARCHETYPE } from "./engine/aiArchetypes.js";
15
  import { STRATEGIES } from "./engine/aiStrategy.js";
16
+ import { DIFFICULTY_OPTIONS } from "./engine/aiDifficulty.js";
17
+ export { DIFFICULTY_OPTIONS }; // re-exported: boot.js and a few tests still import it from here
18
  import { FACTIONS, PLAYABLE_FACTIONS } from "./engine/factions.js";
19
  import { hasSave, loadGame, hasOdysseySave, loadOdyssey } from "./saveload.js";
20
  import { APP_VERSION } from "./version.js";
 
39
  { label: "Normal", mult: 1.0, note: "balanced" },
40
  { label: "Abundant", mult: 1.5, note: "rich deposits" },
41
  ];
 
 
 
 
 
 
 
 
 
 
 
 
42
  // Playable factions for the setup picker β€” a passive-trait identity for your side
43
  // (engine/factions.js). Each option's `mult` is the faction id, its note the short
44
  // tagline of its edge. The AI's faction comes from the world's archetype instead.
test/aiDifficulty.test.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ AI DIFFICULTY (engine/aiDifficulty.js): the Easy/Medium/Hard pick from the splash
3
+ screen, orthogonal to the archetype and the player-picked strategy β€” see the
4
+ module header for the full design. This is Tier 0 of the section-08 economic-
5
+ dial proposal: pure scaffold, so every test here proves a NO-OP β€” the same
6
+ dials as before, now resolvable off a live state the same way strategyFor already
7
+ resolves engine/aiStrategy.js's STRATEGIES. Round-trip coverage lives here too
8
+ (engine/persist.js's serPlanet/rehydratePlanet), since nothing else exercises it.
9
+ ============================================================ */
10
+
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { createGameState } from "../engine/state.js";
14
+ import { createGalaxy, activeState } from "../engine/galaxy.js";
15
+ import { serializeGame, deserializeGame } from "../engine/persist.js";
16
+ import { DIFFICULTY_OPTIONS, difficultyFor } from "../engine/aiDifficulty.js";
17
+
18
+ test("DIFFICULTY_OPTIONS carries no economic fields yet β€” a genuine no-op scaffold", () => {
19
+ for (const opt of DIFFICULTY_OPTIONS) {
20
+ for (const field of ["workerTargetMult", "graceMult", "grievanceMult", "economicEdge",
21
+ "researchPaceMult", "marketAccess"]) {
22
+ assert.equal(opt[field], undefined, `${opt.mult}.${field} should be unset β€” Tier 0 adds no dial yet`);
23
+ }
24
+ }
25
+ });
26
+
27
+ test("every difficulty option still names its two original dials (aiApm, aiMicro) and its key", () => {
28
+ for (const key of ["easy", "medium", "hard"]) {
29
+ const opt = DIFFICULTY_OPTIONS.find(o => o.mult === key);
30
+ assert.ok(opt, `expected a "${key}" entry`);
31
+ assert.equal(typeof opt.aiApm, "number");
32
+ assert.equal(typeof opt.aiMicro, "boolean");
33
+ }
34
+ });
35
+
36
+ test("difficultyFor resolves each real key to its matching DIFFICULTY_OPTIONS entry", () => {
37
+ for (const key of ["easy", "medium", "hard"]) {
38
+ const s = createGameState({ planetId: "ferros", difficulty: key });
39
+ assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === key));
40
+ }
41
+ });
42
+
43
+ test("difficultyFor falls back to medium for an unset or unrecognised state.ai.difficulty", () => {
44
+ const s = createGameState({ planetId: "ferros" });
45
+ assert.equal(s.ai.difficulty, "medium", "createGameState's own default, unprompted");
46
+ assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "medium"));
47
+ s.ai.difficulty = "not-a-real-difficulty";
48
+ assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "medium"), "unrecognised β‡’ medium, never throws");
49
+ });
50
+
51
+ test("createGameState wires opts.difficulty onto state.ai.difficulty", () => {
52
+ const s = createGameState({ planetId: "ferros", difficulty: "hard" });
53
+ assert.equal(s.ai.difficulty, "hard");
54
+ assert.equal(difficultyFor(s), DIFFICULTY_OPTIONS.find(o => o.mult === "hard"));
55
+ });
56
+
57
+ test("createGalaxy threads settings.difficulty down into every planet's state.ai.difficulty, not just the active one", () => {
58
+ const g = createGalaxy({ seed: 11, difficulty: "hard" });
59
+ assert.equal(g.settings.difficulty, "hard");
60
+ for (const state of g.planets.values()) {
61
+ assert.equal(state.ai.difficulty, "hard", `world ${state.planetId} should inherit the galaxy's difficulty`);
62
+ }
63
+ assert.equal(activeState(g).ai.difficulty, "hard");
64
+ });
65
+
66
+ test("a saved game round-trips state.ai.difficulty through JSON", () => {
67
+ const a = createGameState({ planetId: "ferros", difficulty: "hard" });
68
+ const b = deserializeGame(JSON.parse(JSON.stringify(serializeGame(a))));
69
+ assert.equal(b.ai.difficulty, "hard");
70
+ });
71
+
72
+ test("an old save with no aiDifficulty field loads as medium β€” the same defaulting every other additive ai.* field already gets", () => {
73
+ const a = createGameState({ planetId: "ferros", difficulty: "hard" });
74
+ const save = serializeGame(a);
75
+ delete save.ai.aiDifficulty; // simulate a save written before this field existed
76
+ const b = deserializeGame(JSON.parse(JSON.stringify(save)));
77
+ assert.equal(b.ai.difficulty, "medium");
78
+ });
test/static-integrity.test.js CHANGED
@@ -98,17 +98,19 @@ test("data.js's lore-flavor faction data has its own name β€” no duplicate FACTI
98
  });
99
 
100
  // Regression guard: boot.js used to keep its own hand-maintained easy/medium/hard -> {aiApm,
101
- // aiMicro} map, entirely separate from setup.js's DIFFICULTY_OPTIONS (which drives the
102
- // Easy/Medium/Hard picker). If the two ever drifted β€” a difficulty key added to one list but
103
- // not the other β€” `DIFFICULTY[setup.difficulty] || DIFFICULTY.medium` silently downgraded an
104
- // unrecognised difficulty to Medium instead of erroring. setup.js's DIFFICULTY_OPTIONS is now
105
- // the one list carrying the AI dials too, and boot.js derives from it.
106
- test("setup.js's DIFFICULTY_OPTIONS is the single source of every difficulty's AI dials; boot.js derives from it", () => {
 
 
107
  const setupSrc = readFileSync(join(root, "setup.js"), "utf8");
108
  const bootSrc = readFileSync(join(root, "boot.js"), "utf8");
109
 
110
- const optionsBlock = setupSrc.match(/export const DIFFICULTY_OPTIONS\s*=\s*\[([\s\S]*?)\n\];/);
111
- assert.ok(optionsBlock, "setup.js must export DIFFICULTY_OPTIONS");
112
  const entries = [...optionsBlock[1].matchAll(/\{([^}]*)\}/g)].map(m => m[1]);
113
  assert.ok(entries.length >= 3, "expected at least the three Easy/Medium/Hard entries");
114
  for (const entry of entries) {
@@ -117,6 +119,10 @@ test("setup.js's DIFFICULTY_OPTIONS is the single source of every difficulty's A
117
  assert.match(entry, /aiMicro:\s*(true|false)/, `difficulty option missing its aiMicro dial (must live in the canonical list): ${entry}`);
118
  }
119
 
 
 
 
 
120
  assert.match(bootSrc, /import\s*\{[^}]*\bDIFFICULTY_OPTIONS\b[^}]*\}\s*from\s*["']\.\/setup\.js["']/,
121
  "boot.js should import DIFFICULTY_OPTIONS from setup.js rather than hardcoding its own difficulty list");
122
  assert.doesNotMatch(bootSrc, /easy:\s*\{\s*aiApm/,
 
98
  });
99
 
100
  // Regression guard: boot.js used to keep its own hand-maintained easy/medium/hard -> {aiApm,
101
+ // aiMicro} map, entirely separate from the list driving the Easy/Medium/Hard picker. If the two
102
+ // ever drifted β€” a difficulty key added to one list but not the other β€”
103
+ // `DIFFICULTY[setup.difficulty] || DIFFICULTY.medium` silently downgraded an unrecognised
104
+ // difficulty to Medium instead of erroring. engine/aiDifficulty.js's DIFFICULTY_OPTIONS is the
105
+ // one list carrying the AI dials (and, going forward, any economic difficulty fields) β€” setup.js
106
+ // imports it for the picker rather than keeping its own copy, and boot.js derives from it too.
107
+ test("engine/aiDifficulty.js's DIFFICULTY_OPTIONS is the single source of every difficulty's AI dials; setup.js and boot.js derive from it", () => {
108
+ const difficultySrc = readFileSync(join(root, "engine", "aiDifficulty.js"), "utf8");
109
  const setupSrc = readFileSync(join(root, "setup.js"), "utf8");
110
  const bootSrc = readFileSync(join(root, "boot.js"), "utf8");
111
 
112
+ const optionsBlock = difficultySrc.match(/export const DIFFICULTY_OPTIONS\s*=\s*\[([\s\S]*?)\n\];/);
113
+ assert.ok(optionsBlock, "engine/aiDifficulty.js must export DIFFICULTY_OPTIONS");
114
  const entries = [...optionsBlock[1].matchAll(/\{([^}]*)\}/g)].map(m => m[1]);
115
  assert.ok(entries.length >= 3, "expected at least the three Easy/Medium/Hard entries");
116
  for (const entry of entries) {
 
119
  assert.match(entry, /aiMicro:\s*(true|false)/, `difficulty option missing its aiMicro dial (must live in the canonical list): ${entry}`);
120
  }
121
 
122
+ assert.doesNotMatch(setupSrc, /export const DIFFICULTY_OPTIONS\s*=\s*\[/,
123
+ "setup.js must not keep its own copy of DIFFICULTY_OPTIONS β€” that duplication is exactly the drift this guards against");
124
+ assert.match(setupSrc, /import\s*\{[^}]*\bDIFFICULTY_OPTIONS\b[^}]*\}\s*from\s*["']\.\/engine\/aiDifficulty\.js["']/,
125
+ "setup.js should import DIFFICULTY_OPTIONS from engine/aiDifficulty.js");
126
  assert.match(bootSrc, /import\s*\{[^}]*\bDIFFICULTY_OPTIONS\b[^}]*\}\s*from\s*["']\.\/setup\.js["']/,
127
  "boot.js should import DIFFICULTY_OPTIONS from setup.js rather than hardcoding its own difficulty list");
128
  assert.doesNotMatch(bootSrc, /easy:\s*\{\s*aiApm/,