Spaces:
Paused
Paused
File size: 12,287 Bytes
9af0ddd 9f53528 9af0ddd 628afc1 9af0ddd | 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 | /* ============================================================
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"));
});
|