SpaceCities / tools /duelCore.js
Claude
Evolve the AI: a genome, mutation, crossover, and a selection loop
9f53528 unverified
Raw History Blame
7.52 kB
/* ============================================================
DUEL CORE β€” 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 ~1600 lines of CLI/printing/argv/search/leaderboard
code. Everything below is pure: it reads engine/aiDifficulty.js's DIFFICULTY_OPTIONS table and
drives tools/selfplay.js's createSelfPlayState/runSelfPlayMatch β€” both already DOM-free β€” and
this file itself has no browser-global reference anywhere (test/duelCore.test.js pins that).
tools/ailab.js re-imports these three names (pinnedDuelDials, duelSeed, and runDuelMatch under
its own existing local name `duelRun`) so every one of its own call sites β€” runDuel,
runRoundRobinSwapped, runSwissBracket, and (through those) runSwappedDuel/runDuelBrackets/the
`search --tournament-against` integration/the duel+swiss CLI handlers β€” keeps working exactly
as before, unmodified. runDuel's own header comment in tools/ailab.js is where the FAIRNESS
argument for why difficulty/apm/micro must be pinned identical for both sides lives; the two
comments below explain only what each function itself does.
============================================================ */
"use strict";
import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
import { hashStr } from "../engine/rng.js";
import { createSelfPlayState, runSelfPlayMatch } from "./selfplay.js";
import { playerScore } from "../engine/victory.js";
// difficulty -> the ONE dial object both sides read (apm/micro, derived from the same
// DIFFICULTY_OPTIONS row labWorld's own --apm real already reads for a single side) β€” see
// tools/ailab.js's DUEL header comment for the FAIRNESS argument this exists to satisfy. Exported
// so a test can read back exactly what a run should have used, rather than trusting the CLI flag
// was honoured.
/**
* @param {string} difficulty
* @returns {{ difficulty: string, apm: number|null, micro: boolean }}
*/
export function pinnedDuelDials(difficulty) {
const opt = DIFFICULTY_OPTIONS.find(o => o.mult === difficulty) || DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
// Echo back opt.mult, NOT the raw input: apm/micro already fall back to medium's values when
// `difficulty` doesn't match any DIFFICULTY_OPTIONS row, so the reported difficulty has to fall
// back with them β€” otherwise a typo'd/unknown --difficulty would run medium's dials but LABEL
// the result with the garbage string, misleading anyone reading a saved --json duel result about
// which difficulty actually ran.
return { difficulty: opt.mult, apm: opt.aiApm, micro: !!opt.aiMicro };
}
// Every duel match's seed is derived from (base seed, world, difficulty, BOTH candidate names,
// replicate) β€” reproducible on its own. The pair's names are SORTED into the hash, so
// duelSeed(base, w, d, a, b, rep) and duelSeed(base, w, d, b, a, rep) draw the identical map:
// runSwappedDuel (tools/ailab.js) exists to isolate seat asymmetry β€” its header says "a
// side-symmetry bug would show up as those two disagreeing" β€” and with an order-dependent seed the
// two halves would differ in seat AND map, so a disagreement would be confounded with ordinary
// seed variance and carry no information at all.
//
// `seedKey` OVERRIDES the name-derived half of that hash, and exists for one caller: an
// evolutionary search (tools/genome.js, docs/ai-evolution-design.md Β§8.1). Names in the hash are
// exactly right when the pair IS the experiment β€” two hand-written candidates, compared to each
// other β€” but wrong the moment names are generated per individual, because then a generation's
// every pairing draws its own map set and "the child scored better than its parent did" is partly
// a statement about map luck rather than about the genome. Passing one fixed key for a whole
// generation (or a whole run) pins every pairing in it to the same maps, so genomes are compared
// on identical ground. Omitted, this is byte-for-byte the seed derivation it has always been.
/**
* @param {number} base
* @param {string} world
* @param {string} difficulty
* @param {string} aName
* @param {string} bName
* @param {number} rep
* @param {string} [seedKey] replaces the sorted-names segment; omit for today's exact behaviour.
* @returns {number}
*/
export function duelSeed(base, world, difficulty, aName, bName, rep, seedKey) {
const pairPart = seedKey != null ? seedKey : [aName, bName].sort().join("|");
return hashStr(`${base}:duel:${world}:${difficulty}:${pairPart}:${rep}`);
}
// Run ONE match: candidate A as owner "player", candidate B as owner "ai", both spending from the
// SAME `dials` object (see pinnedDuelDials above / tools/ailab.js's FAIRNESS paragraph). `swapAsym`
// exchanges the map's own asym halves (engine/map.js) for THIS match only. Returns a plain result
// row β€” this exact shape is load-bearing: many functions in tools/ailab.js consume rows shaped
// exactly like this, so every field name here must stay byte-for-byte stable.
/**
* @param {{ world: string, seed: number,
* dials: { difficulty: string, apm: number|null, micro: boolean },
* aName: string, aStrategy: string, bName: string, bStrategy: string,
* aArchetype?: string|null, bArchetype?: string|null,
* minutes?: number, swapAsym?: boolean }} cfg
* @returns {{
* world: string, seed: number, difficulty: string, swapAsym: boolean,
* aName: string, aStrategy: string, bName: string, bStrategy: string,
* aDifficulty: string, bDifficulty: string,
* aApm: number|null, bApm: number|null, aMicro: boolean, bMicro: boolean,
* winner: "a"|"b"|"draw", winReason: string|null, time: number,
* aScore: number, bScore: number, margin: number,
* }}
*/
export function runDuelMatch({ world, seed, dials, aName, aStrategy, bName, bStrategy, aArchetype, bArchetype, minutes, swapAsym }) {
const state = createSelfPlayState({
planetId: world, seed, swapAsym,
matchTimeLimit: minutes ? minutes * 60 : undefined,
// aArchetype/bArchetype (docs/competitions-and-elo.md D3): an INPUT dial only β€” never reflected
// on the returned row below, whose field set is pinned byte-for-byte by test/duelCore.test.js's
// own exact-shape test (every tools/ailab.js consumer depends on it not growing a field).
ai: { ...dials, strategy: bStrategy, archetype: bArchetype },
playerAi: { ...dials, strategy: aStrategy, archetype: aArchetype },
});
const result = runSelfPlayMatch(state, minutes ? { maxSeconds: minutes * 60 + 120 } : undefined);
const aScore = playerScore(state, "player");
const bScore = playerScore(state, "ai");
const winner = !result.over ? "draw" : result.winner === "player" ? "a" : "b";
return {
world, seed, difficulty: dials.difficulty, swapAsym: !!swapAsym,
aName, aStrategy, bName, bStrategy,
// Read back off the actual controllers, not the input β€” the structural-fairness tests assert
// on exactly these fields, so a bug that let the two dials diverge would show here.
aDifficulty: state.playerAi.difficulty, bDifficulty: state.ai.difficulty,
aApm: state.playerAi.apm, bApm: state.ai.apm, aMicro: state.playerAi.micro, bMicro: state.ai.micro,
winner, winReason: result.winReason, time: +result.time.toFixed(1),
aScore: +aScore.toFixed(1), bScore: +bScore.toFixed(1), margin: +(aScore - bScore).toFixed(1),
};
}