Spaces:
Paused
Paused
Claude Claude Sonnet 5 commited on
Move DIFFICULTY_OPTIONS into engine/aiDifficulty.js, add state.ai.difficulty
Browse filesScaffold 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 +2 -1
- engine/aiDifficulty.js +36 -0
- engine/galaxy.js +2 -1
- engine/persist.js +5 -0
- engine/state.js +2 -1
- engine/types.js +1 -0
- setup.js +2 -12
- test/aiDifficulty.test.js +78 -0
- test/static-integrity.test.js +14 -8
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,
|
|
|
|
| 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,
|
|
|
|
| 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
|
| 102 |
-
//
|
| 103 |
-
//
|
| 104 |
-
//
|
| 105 |
-
//
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
const setupSrc = readFileSync(join(root, "setup.js"), "utf8");
|
| 108 |
const bootSrc = readFileSync(join(root, "boot.js"), "utf8");
|
| 109 |
|
| 110 |
-
const optionsBlock =
|
| 111 |
-
assert.ok(optionsBlock, "
|
| 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/,
|