Spaces:
Paused
Paused
Download test/aiDifficulty.test.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/test/aiDifficulty.test.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/test/aiDifficulty.test.js
-
curl -L -o aiDifficulty.test.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/test/aiDifficulty.test.js
13.7 kB
| /* ============================================================ | |
| 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)); | |
| }); | |