Spaces:
Paused
Paused
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));
});
|