SpaceCities / test /duelCore.test.js
Claude
Evolve the AI: a genome, mutation, crossover, and a selection loop
9f53528 unverified
Raw History Blame
12.3 kB
/* ============================================================
Guards for tools/duelCore.js — the fairness-critical "run one duel match" primitive extracted
out of tools/ailab.js (docs/competitions-and-elo.md Phase 1) so a browser Worker can import it
in a later stage without pulling in ailab.js's CLI/printing/search/leaderboard code.
tools/ailab.js's own test/ailab.test.js already exercises pinnedDuelDials/the match runner
EXHAUSTIVELY through runDuel/runSwappedDuel/runDuelBrackets — fairness across sides, difficulty
pinning, determinism, override isolation — and stays the safety net proving the extraction
didn't change any of that observable behaviour. This file adds direct, ISOLATED coverage of the
primitive itself, imported straight from tools/duelCore.js, never through tools/ailab.js.
============================================================ */
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { pinnedDuelDials, duelSeed, runDuelMatch } from "../tools/duelCore.js";
import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
import { STRATEGIES } from "../engine/aiStrategy.js";
/* ---------- pinnedDuelDials ---------- */
test("pinnedDuelDials reads apm/micro straight off the named DIFFICULTY_OPTIONS row", () => {
for (const opt of DIFFICULTY_OPTIONS) {
assert.deepEqual(pinnedDuelDials(opt.mult), { difficulty: opt.mult, apm: opt.aiApm, micro: !!opt.aiMicro },
`difficulty "${opt.mult}" must echo its own row's dials`);
}
});
test("pinnedDuelDials normalizes an unknown/typo'd difficulty to medium's dials AND label", () => {
const medium = DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
const dials = pinnedDuelDials("totallyBogusTypo");
assert.deepEqual(dials, { difficulty: "medium", apm: medium.aiApm, micro: !!medium.aiMicro },
"an unrecognised difficulty must fall back to medium's own dials, and REPORT medium -- not echo the garbage input");
});
/* ---------- duelSeed ---------- */
test("duelSeed is deterministic: identical inputs produce the identical seed", () => {
const a = duelSeed(1, "ferros", "medium", "Alpha", "Beta", 0);
const b = duelSeed(1, "ferros", "medium", "Alpha", "Beta", 0);
assert.equal(a, b);
assert.equal(typeof a, "number");
});
test("duelSeed sorts the two candidate names into the hash, so a pair draws the identical map regardless of argument order", () => {
const forward = duelSeed(1, "ferros", "medium", "Alpha", "Beta", 0);
const backward = duelSeed(1, "ferros", "medium", "Beta", "Alpha", 0);
assert.equal(forward, backward, "duelSeed(a,b,...) and duelSeed(b,a,...) must draw the same map");
});
test("duelSeed genuinely varies with world, difficulty, replicate, and base seed", () => {
const base = duelSeed(1, "ferros", "medium", "Alpha", "Beta", 0);
assert.notEqual(base, duelSeed(1, "korrath", "medium", "Alpha", "Beta", 0), "world must vary the seed");
assert.notEqual(base, duelSeed(1, "ferros", "hard", "Alpha", "Beta", 0), "difficulty must vary the seed");
assert.notEqual(base, duelSeed(1, "ferros", "medium", "Alpha", "Beta", 1), "replicate must vary the seed");
assert.notEqual(base, duelSeed(2, "ferros", "medium", "Alpha", "Beta", 0), "the base seed must vary the seed");
assert.notEqual(base, duelSeed(1, "ferros", "medium", "Alpha", "Gamma", 0), "the actual pair must vary the seed");
});
/* ---------- duelSeed's seedKey escape hatch (docs/ai-evolution-design.md §8.1) ---------- */
test("omitting seedKey leaves duelSeed byte-for-byte the derivation it has always been", () => {
// The whole point of the parameter is that it is invisible unless asked for: every existing
// caller (runDuel without an evolutionary opts, every recorded competition row, every saved
// --json duel result) must keep drawing the identical maps.
for (const [world, diff, rep] of [["ferros", "medium", 0], ["korrath", "hard", 3], ["vesper", "easy", 1]]) {
assert.equal(duelSeed(1, world, diff, "Alpha", "Beta", rep),
duelSeed(1, world, diff, "Alpha", "Beta", rep, undefined),
"an explicit undefined seedKey must be identical to omitting it");
}
});
test("seedKey replaces the NAMES in the hash, so differently-named pairs share one map set", () => {
// This is the property an evolutionary run needs: generated names (g3i7 vs g2i4) would otherwise
// hash to their own maps every generation, and "the child beat its parent's number" would be
// partly a statement about map luck rather than about the genome.
const a = duelSeed(1, "ferros", "medium", "g3i7", "g2i4", 0, "evolve");
const b = duelSeed(1, "ferros", "medium", "totally", "different", 0, "evolve");
assert.equal(a, b, "with a seedKey pinned, the candidate names must not reach the map draw");
assert.notEqual(a, duelSeed(1, "ferros", "medium", "g3i7", "g2i4", 0),
"and it must actually differ from the unpinned derivation, or it is doing nothing");
// Everything else still varies exactly as before — the key replaces the names, nothing else.
assert.notEqual(a, duelSeed(1, "korrath", "medium", "g3i7", "g2i4", 0, "evolve"));
assert.notEqual(a, duelSeed(1, "ferros", "hard", "g3i7", "g2i4", 0, "evolve"));
assert.notEqual(a, duelSeed(1, "ferros", "medium", "g3i7", "g2i4", 1, "evolve"));
assert.notEqual(a, duelSeed(2, "ferros", "medium", "g3i7", "g2i4", 0, "evolve"));
assert.notEqual(a, duelSeed(1, "ferros", "medium", "g3i7", "g2i4", 0, "evolve2"));
});
/* ---------- runDuelMatch (formerly ailab.js's own private duelRun) ---------- */
const dials = pinnedDuelDials("medium");
test("runDuelMatch returns exactly the row shape every ailab.js consumer relies on -- no more, no fewer fields", () => {
const row = runDuelMatch({
world: "ferros", seed: duelSeed(7, "ferros", "medium", "A", "B", 0), dials,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "aggressive", minutes: 5,
});
const expectedFields = [
"world", "seed", "difficulty", "swapAsym", "aName", "aStrategy", "bName", "bStrategy",
"aDifficulty", "bDifficulty", "aApm", "bApm", "aMicro", "bMicro",
"winner", "winReason", "time", "aScore", "bScore", "margin",
];
assert.deepEqual(Object.keys(row).sort(), [...expectedFields].sort(),
"runDuelMatch's row shape must not change AT ALL -- every existing ailab.js consumer depends on this exact set of fields");
});
test("runDuelMatch is deterministic — same inputs, byte-identical row", () => {
const cfg = {
world: "ferros", seed: duelSeed(7, "ferros", "medium", "A", "B", 0), dials,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "aggressive", minutes: 5,
};
assert.equal(JSON.stringify(runDuelMatch(cfg)), JSON.stringify(runDuelMatch(cfg)),
"two identical calls diverged: something reads a clock or an unseeded pick");
});
test("runDuelMatch pins the SAME dials object's apm/micro/difficulty into both sides, read back off the real controllers", () => {
for (const difficulty of ["easy", "hard"]) {
const d = pinnedDuelDials(difficulty);
const dial = DIFFICULTY_OPTIONS.find(o => o.mult === difficulty);
const row = runDuelMatch({
world: "ferros", seed: duelSeed(3, "ferros", difficulty, "A", "B", 0), dials: d,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "economic", minutes: 5,
});
assert.equal(row.difficulty, difficulty);
assert.equal(row.aDifficulty, difficulty);
assert.equal(row.bDifficulty, difficulty);
assert.equal(row.aApm, dial.aiApm, "candidate A's own controller must carry this difficulty's aiApm");
assert.equal(row.bApm, dial.aiApm, "candidate B's own controller must carry the SAME aiApm as A, not its own");
assert.equal(row.aMicro, !!dial.aiMicro);
assert.equal(row.bMicro, !!dial.aiMicro);
}
});
test("runDuelMatch reports swapAsym back exactly as given", () => {
const cfg = {
world: "ferros", seed: duelSeed(7, "ferros", "medium", "A", "B", 0), dials,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "aggressive", minutes: 5,
};
assert.equal(runDuelMatch({ ...cfg, swapAsym: false }).swapAsym, false);
assert.equal(runDuelMatch({ ...cfg, swapAsym: true }).swapAsym, true);
assert.equal(runDuelMatch(cfg).swapAsym, false, "swapAsym must default falsy when omitted, coerced to a real boolean");
});
test("runDuelMatch actually resolves a fight: a strong candidate beats a defenceless one", () => {
// neverInitiates + a standing-army cap of ZERO is absolute defencelessness in skirmish (no
// desperation timeout) -- the same fixture shape test/ailab.test.js's own duel tests use, here
// exercised directly against the primitive with a single match rather than a whole worlds x
// seeds matrix (that exhaustive coverage already lives in test/ailab.test.js via runDuel).
STRATEGIES.labDuelCoreCrippled = { name: "labDuelCoreCrippled", desc: "test", neverInitiates: true, standingArmyCap: 0 };
const row = runDuelMatch({
world: "korrath", seed: duelSeed(7, "korrath", "medium", "normal", "crippled", 0), dials,
aName: "normal", aStrategy: "default", bName: "crippled", bStrategy: "labDuelCoreCrippled", minutes: 20,
});
assert.equal(row.winner, "a", `the normal candidate must win (got winner=${row.winner}, reason=${row.winReason})`);
assert.equal(row.winReason, "elimination", "a defenceless base should lose to a genuine assault before the clock");
assert.ok(row.margin > 0, "the winning side's score margin must be positive");
});
/* ---------- D3 (docs/competitions-and-elo.md): aArchetype/bArchetype -- the per-entrant archetype
override, threaded through runDuelMatch's own config into createSelfPlayState's two controllers.
The row shape itself must stay UNCHANGED (archetype is an input dial here, never a reported
output field -- see the exact-shape test above), so this proves the override reaches the sim
some other way: two otherwise-identical runs that disagree only on archetype must diverge. ---------- */
test("runDuelMatch's aArchetype/bArchetype reach the simulated match without changing the row's own field set", () => {
const cfg = {
world: "ferros", seed: duelSeed(9, "ferros", "medium", "A", "B", 0), dials,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "default", minutes: 8,
};
const baseline = runDuelMatch(cfg);
const withArchetypes = runDuelMatch({ ...cfg, aArchetype: "rusher", bArchetype: "technologist" });
assert.deepEqual(Object.keys(withArchetypes).sort(), Object.keys(baseline).sort(),
"supplying an archetype must not add or remove ANY field from the row -- it's an input dial, not an output");
assert.notEqual(JSON.stringify(baseline), JSON.stringify(withArchetypes),
"two otherwise-identical matches that disagree only on archetype must actually simulate differently -- " +
"proof the override genuinely reaches the created states, not just accepted and ignored");
});
test("runDuelMatch treats a missing/null aArchetype/bArchetype as byte-identical to omitting the option entirely", () => {
const cfg = {
world: "ferros", seed: duelSeed(9, "ferros", "medium", "A", "B", 0), dials,
aName: "A", aStrategy: "default", bName: "B", bStrategy: "default", minutes: 8,
};
const omitted = runDuelMatch(cfg);
const explicitNull = runDuelMatch({ ...cfg, aArchetype: null, bArchetype: undefined });
assert.equal(JSON.stringify(omitted), JSON.stringify(explicitNull));
});
/* ---------- browser importability (docs/competitions-and-elo.md Phase 1: "it will be imported by
a Worker next stage") -- same idiom test/ai-selfplay.test.js already uses for tools/selfplay.js,
pointed at this file instead. ---------- */
test("tools/duelCore.js has no bare `document` or `window` reference anywhere in its source", () => {
const src = readFileSync(new URL("../tools/duelCore.js", import.meta.url), "utf8");
const FORBIDDEN = /\bdocument\b|\bwindow\b/;
const offenders = [];
src.split("\n").forEach((line, i) => {
if (FORBIDDEN.test(line)) offenders.push(`${i + 1}: ${line.trim()}`);
});
assert.deepEqual(offenders, [],
"tools/duelCore.js must have zero `document`/`window` references so a browser Worker can import it cleanly:\n"
+ offenders.join("\n"));
});