SpaceCities / competitionWorker.js
Claude
Phase 5: the roster can carry a player's own genome
60d3ef9 unverified
Raw History Blame
21.6 kB
/* ============================================================
COMPETITION WORKER β€” the batch simulator behind the in-game Quick Duel and Tournament screens
(docs/competitions-and-elo.md Phase 1, D5: "AI matches batch-simulate in a module Web Worker").
Runs off the main thread so a multi-match duel doesn't freeze the tab; posts progress after
EVERY match so competition.js can show a live "X of Y" bar and a Cancel that actually cancels β€”
Worker#terminate() from the main thread kills this thread outright, so this file needs no
cooperative cancellation of its own; it can just run its loop straight through. That holds for a
whole TOURNAMENT too (Phase 3, below): it's one synchronous run on this thread, so terminating
the Worker mid-round leaves nothing running and nothing half-written β€” the ledger is only ever
written by competition.js, from the "done" message, which a terminated Worker never sends.
TWO JOB KINDS, dispatched at the bottom of this file:
β€’ the original single-duel job (no `kind` field) β€” runCompetitionJob, UNCHANGED by Phase 3;
β€’ `{ kind: "tournament", format, field, … }` β€” runTournamentJob, which builds its "run one
pairing" function out of runCompetitionJob itself (so a tournament pairing is byte-for-byte
the same side-swapped, difficulty-pinned work a Quick Duel already does) and hands it to
pairing.js's schedules as their injected `runPair`.
Imports ONLY tools/duelCore.js and pairing.js (plus whatever THEY need β€” engine/aiDifficulty.js,
engine/rng.js, tools/selfplay.js's pure core, engine/victory.js's playerScore) β€” never
tools/ailab.js, whose ~1600 lines of CLI/printing/search/leaderboard code exist for a Node bench,
not a browser bundle. That means runDuel/runSwappedDuel/flipDuelResult (ailab.js-only, and
unexported even there) aren't available here: the side-swapped world x seed loop below is a
small, FRESH re-implementation of runSwappedDuel's own SHAPE (mirrored deliberately β€” see its header comment
in tools/ailab.js), built only from duelCore's exported primitives (pinnedDuelDials/duelSeed/
runDuelMatch) β€” never reimplementing what THOSE already do (the dial-pinning, the seed hash, or
the match runner itself).
FAIRNESS: difficulty is the ONE dial pinnedDuelDials resolves for BOTH sides of every match β€”
never two separate difficulties for A and B (see tools/duelCore.js's own header and
tools/ailab.js's runDuel FAIRNESS paragraph for why pinning matters). Every world x seed
replicate runs BOTH ways β€” entrantA as owner "player"/entrantB as owner "ai", then the reverse,
relabelled back onto A/B by flipRow below (a local copy of ailab.js's own private row-flip,
since neither ailab.js nor duelCore.js exports it) β€” so neither entrant sits in a structurally
different seat for the whole duel. swapAsym alternates by replicate parity, exactly like
tools/ailab.js's runDuel already does for the CLI.
Loaded from competition.js as `new Worker(new URL("./competitionWorker.js", import.meta.url),
{ type: "module" })` β€” a construction the static import-graph walker in
test/static-integrity.test.js cannot see (its regex only recognises an `import ... from`
statement, a dynamic `import()` call, or a bare side-effect import statement β€” none of which
this Worker construction is), so this file is named in that test's own EXEMPT set with a
comment explaining why, rather than tripping its "orphan" check.
Message-handler wiring is guarded the way dom.js guards `document`/`window`: under plain Node
(no `self`, or a `self` with no `postMessage`) the wiring below is simply skipped, so
test/static-integrity.test.js's "every shipped UI module imports cleanly under Node with no
DOM" check (which bare-imports this file in a child process) passes. The actual job-running
logic (runCompetitionJob, and runTournamentJob below it) stays a plain, DOM/self-free export any
test can call directly with no real Worker involved β€” mirrors observer.js's own split of pure
data (observerStats) from its DOM-facing consumer (observerPanel.js).
============================================================ */
"use strict";
import { pinnedDuelDials, duelSeed, runDuelMatch } from "./tools/duelCore.js";
// tools/genome.js is pure and browser-safe by construction (that is why applyOverrides lives
// there and not in tools/ailab.js, which imports node:fs and can never run in a Worker).
import { toOverrides, applyOverrides, snapshotTables, restoreTables } from "./tools/genome.js";
import {
roundRobinPairs, buildSwissBracket, buildKnockoutBracket,
swissRoundCount, tournamentRoundPlan, tallyStandings,
} from "./pairing.js";
// Relabel a runDuelMatch() row from B's perspective to A's β€” "B played owner player, A played
// owner ai" becomes "what A did, reported as if A were the row's own aName". A plain copy of
// tools/ailab.js's own PRIVATE flipDuelResult inner helper (unexported there, and ailab.js itself
// is off-limits here β€” see the header above), kept byte-for-byte equivalent so a duel run through
// this worker tallies exactly the way tools/ailab.js's runSwappedDuel already does.
function flipRow(r) {
return {
...r,
aName: r.bName, bName: r.aName,
aStrategy: r.bStrategy, bStrategy: r.aStrategy,
aDifficulty: r.bDifficulty, bDifficulty: r.aDifficulty,
aApm: r.bApm, bApm: r.aApm, aMicro: r.bMicro, bMicro: r.aMicro,
winner: r.winner === "a" ? "b" : r.winner === "b" ? "a" : "draw",
aScore: r.bScore, bScore: r.aScore, margin: -r.margin,
};
}
/**
* Run one Quick Duel job to completion: every world x seed replicate, both directions
* (side-swapped β€” see the header FAIRNESS paragraph), reporting each finished match through
* `onProgress` as it resolves. Pure/DOM-free/self-free β€” directly callable from Node (or a test)
* with no Worker involved at all (see the header's observer.js comparison).
* @param {{ entrantA: {name: string, strategy?: string, archetype?: string|null},
* entrantB: {name: string, strategy?: string, archetype?: string|null},
* difficulty: string, worlds: string[], seeds: number, seedBase: number, matchTimeLimit?: number }} job
* @param {(p: {completed: number, total: number, row: object}) => void} [onProgress]
* @returns {{ rows: object[], aWins: number, bWins: number, draws: number }}
*/
export function runCompetitionJob(job, onProgress) {
const { entrantA, entrantB, difficulty, worlds, seeds, seedBase, matchTimeLimit } = job || {};
if (!entrantA || !entrantA.name) throw new Error('entrantA needs a "name"');
if (!entrantB || !entrantB.name) throw new Error('entrantB needs a "name"');
if (!Array.isArray(worlds) || worlds.length === 0) throw new Error("job needs at least one world");
if (!(seeds > 0)) throw new Error("job needs at least one seed per world");
const dials = pinnedDuelDials(difficulty); // read ONCE β€” the whole fairness point, see header
// runDuelMatch wants minutes; the job's own matchTimeLimit is SECONDS, matching setup.js's own
// setup.matchTimeLimit convention (engine/victory.js's DEFAULT_MATCH_TIME_LIMIT is in seconds).
const minutes = matchTimeLimit ? matchTimeLimit / 60 : undefined;
// ailab.js's own runDuel defaults an unset strategy the same way, at the same layer (the match
// runner itself never defaults it β€” see tools/duelCore.js's runDuelMatch, which echoes back
// whatever aStrategy/bStrategy it's given verbatim).
const aStrategy = entrantA.strategy || "default";
const bStrategy = entrantB.strategy || "default";
// docs/competitions-and-elo.md D3: each entrant's own optional per-entrant doctrine, defaulting
// to null (no override β€” runDuelMatch/createSelfPlayState/createAiController all read a falsy
// archetype as "use the world's own temperament", exactly as before this option existed).
const aArchetype = entrantA.archetype || null;
const bArchetype = entrantB.archetype || null;
// A player-authored entrant carries its own GENOME rather than naming a shipped strategy
// (docs/ai-evolution-design.md). Write both entrants' rows into the live AI tables before any
// match runs β€” they share one live table for the fight, exactly as tools/ailab.js's runDuel does
// β€” and restore afterwards so one competition's genomes can never leak into the next.
// The strategy KEY is the entrant's own name, which is what competitionLedger.js's
// addRosterEntry/cleanRosterEntry already store for a genome-carrying entry, so the two halves
// cannot drift: whatever name the row was filed under is the name the table is keyed by.
const tableSnapshot = snapshotTables();
if (entrantA.genome) applyOverrides(toOverrides(entrantA.genome, entrantA.name));
if (entrantB.genome) applyOverrides(toOverrides(entrantB.genome, entrantB.name));
try {
const total = worlds.length * seeds * 2; // both directions, side-swapped
const rows = [];
let completed = 0;
const post = row => {
rows.push(row);
completed++;
if (onProgress) onProgress({ completed, total, row });
};
for (const world of worlds) {
for (let rep = 0; rep < seeds; rep++) {
// Sorted into the hash (tools/duelCore.js's own duelSeed), so both directions below draw
// the SAME map for this (world, rep) β€” only the seat assignment differs between them.
const seed = duelSeed(seedBase, world, difficulty, entrantA.name, entrantB.name, rep);
const swapAsym = rep % 2 === 1; // replicate parity β€” same rule tools/ailab.js's runDuel uses
// Direction 1 ("bAsAi"): entrantA owns "player", entrantB owns "ai" β€” runDuelMatch's own
// fixed mapping, no relabelling needed. Archetype rides along the SAME seat its own
// strategy does (aArchetype with aStrategy, bArchetype with bStrategy) β€” an entrant's
// doctrine follows the entrant, not the seat, exactly like strategy already does here.
post({
...runDuelMatch({
world, seed, dials, minutes, swapAsym,
aName: entrantA.name, aStrategy, aArchetype, bName: entrantB.name, bStrategy, bArchetype,
}),
direction: "bAsAi",
});
// Direction 2 ("aAsAi"): entrantB owns "player", entrantA owns "ai" β€” then flipped back so
// the row still reads aName=entrantA/bName=entrantB, exactly like runSwappedDuel's aAsAi.
// Archetype swaps seats right alongside strategy, for the same reason.
post({
...flipRow(runDuelMatch({
world, seed, dials, minutes, swapAsym,
aName: entrantB.name, aStrategy: bStrategy, aArchetype: bArchetype,
bName: entrantA.name, bStrategy: aStrategy, bArchetype: aArchetype,
})),
direction: "aAsAi",
});
}
}
const aWins = rows.filter(r => r.winner === "a").length;
const bWins = rows.filter(r => r.winner === "b").length;
const draws = rows.filter(r => r.winner === "draw").length;
return { rows, aWins, bWins, draws };
} finally {
restoreTables(tableSnapshot); // never let one competition's genomes leak into the next
}
}
/* ============================================================
TOURNAMENTS (docs/competitions-and-elo.md Phase 3) β€” round-robin, Swiss and knockout, scheduled
by pairing.js and run one pairing at a time through runCompetitionJob above.
THE ONE IDEA HERE: a tournament pairing is exactly a Quick Duel. runPairing below is a thin
wrapper around runCompetitionJob β€” the same side-swap, the same replicate-parity swapAsym, the
same pinned-difficulty fairness, the same row shape the ledger already stores β€” aggregated into
the `{ aWins, bWins, draws, avgMargin }` shape pairing.js's schedules read off a `runPair`. So
there is ONE per-pairing implementation in this file, already covered by this file's own tests
and Phase 1/2's live verification, rather than a second one that could drift from it.
PROGRESS, at real granularity. A 15-pairing round-robin at 4 worlds x 2 seeds is 240 matches and
several minutes; one opaque spinner over that is not acceptable (the Phase 3 brief's own point
about telling the player what a long run is doing). Two message kinds go out:
β€’ `{ type: "progress" }` after every MATCH, carrying the round/pairing coordinates that
tournamentRoundPlan derives from a flat pairing counter (neither buildSwissBracket nor
buildKnockoutBracket tells its runPair where in the schedule it is β€” see that function's own
comment) plus the global match counter;
β€’ `{ type: "pairing" }` after every PAIRING, a summary (no rows) so the screen can fold the
result into live standings immediately.
THE DONE MESSAGE carries the format's own full result β€” standings for round-robin/Swiss, the
whole bracket tree for knockout β€” PLUS `pairings`: every pairing's rows in true completion order
(round 1 before round 2, in-round order preserved). That list is what competition.js folds into
the ledger, one recordCompetition call per pairing, in that order β€” D6's canonical-ordering rule
applied to a multi-pairing tournament exactly as Quick Duel already applies it to one duel. The
rows travel ONCE: they ride `pairings`, and every other structure in the message (the bracket
tree, the Swiss rounds log) carries the aggregate only.
============================================================ */
const TOURNAMENT_FORMATS = new Set(["round-robin", "swiss", "knockout"]);
// Drop the (large) per-match rows off a pairing result, keeping the aggregate β€” see the DONE
// MESSAGE paragraph above on why the rows only ever travel once.
const withoutRows = ({ rows, ...rest }) => rest;
// Validate the field ONCE, before anything is simulated: a nameless entrant, or two entrants
// sharing a name, is the same identity hazard elo.js's RatingsTable and buildKnockoutBracket both
// already reject β€” catching it here means a 200-match tournament can't get halfway before failing.
function validateField(field) {
const entrants = Array.isArray(field) ? field : [];
if (entrants.length < 2) throw new Error(`a tournament needs at least 2 entrants, got ${entrants.length}`);
const seen = new Set();
for (const e of entrants) {
if (!e || typeof e.name !== "string" || !e.name.trim()) throw new Error('every tournament entrant needs a "name"');
if (seen.has(e.name)) throw new Error(`duplicate entrant name "${e.name}" β€” a tournament is scored by name`);
seen.add(e.name);
}
return entrants;
}
/**
* Run one tournament job to completion. Pure/DOM-free/self-free, exactly like runCompetitionJob
* above and for the same reason (directly callable from Node or a test with no Worker at all).
* @param {{ kind?: string, format: "round-robin"|"swiss"|"knockout",
* field: {name: string, strategy?: string, archetype?: string|null}[],
* difficulty: string, worlds: string[], seeds: number, seedBase?: number,
* rounds?: number, matchTimeLimit?: number }} job
* @param {(msg: object) => void} [onProgress] receives every "progress"/"pairing" message.
* @returns {object} the "done" payload (minus its `type`) β€” see the DONE MESSAGE comment above.
*/
export function runTournamentJob(job, onProgress) {
const { format, field, difficulty, worlds, seeds, seedBase, rounds, matchTimeLimit } = job || {};
if (!TOURNAMENT_FORMATS.has(format)) throw new Error(`unknown tournament format "${format}"`);
const entrants = validateField(field);
if (!Array.isArray(worlds) || worlds.length === 0) throw new Error("a tournament job needs at least one world");
if (!(seeds > 0)) throw new Error("a tournament job needs at least one seed per world");
const names = entrants.map(e => e.name);
const roundCount = format === "swiss"
? (rounds > 0 ? Math.floor(rounds) : swissRoundCount(entrants.length))
: undefined;
const plan = tournamentRoundPlan(format, entrants.length, roundCount);
const matchesPerPairing = worlds.length * seeds * 2; // both side-swapped directions, same as matchCount
const totalPairings = plan.reduce((sum, r) => sum + r.pairings, 0);
const totalMatches = totalPairings * matchesPerPairing;
/** @type {object[]} every finished pairing, WITH its rows, in true completion order (D6). */
const pairings = [];
let completedMatches = 0;
const post = msg => { if (onProgress) onProgress(msg); };
// Where the k-th pairing of the whole tournament sits in the schedule. Derived from the plan
// rather than from the schedule functions themselves, which don't report it β€” see this section's
// PROGRESS paragraph.
const coordsFor = index => {
let seen = 0;
for (const r of plan) {
if (index < seen + r.pairings)
return { round: r.round, roundTotal: plan.length, pairing: index - seen + 1, pairingTotal: r.pairings };
seen += r.pairings;
}
// Unreachable while the plan prices the whole schedule (test/pairing.test.js pins that for
// every format and every field size) β€” but a progress readout must never be the thing that
// throws mid-tournament, so pin to the last round instead.
const last = plan[plan.length - 1];
return { round: last.round, roundTotal: plan.length, pairing: last.pairings, pairingTotal: last.pairings };
};
// The injected `runPair` every pairing.js schedule takes: ONE Quick Duel's worth of work.
const runPairing = (a, b, opts) => {
const coords = coordsFor(pairings.length);
const { rows, aWins, bWins, draws } = runCompetitionJob({
entrantA: a, entrantB: b, difficulty, worlds, seeds,
// buildSwissBracket/buildKnockoutBracket fold the round into the seed base they forward
// (D6 β€” a replayed tournament is byte-identical); round-robin has no rounds and passes the
// job's own base straight through.
seedBase: (opts && opts.seedBase != null) ? opts.seedBase : seedBase,
matchTimeLimit,
}, () => {
completedMatches++;
post({
type: "progress", kind: "tournament", format, ...coords,
completed: completedMatches, total: totalMatches, aName: a.name, bName: b.name,
});
});
const avgMargin = rows.length ? rows.reduce((sum, r) => sum + r.margin, 0) / rows.length : 0;
const result = { round: coords.round, pairing: coords.pairing, aName: a.name, bName: b.name, aWins, bWins, draws, avgMargin, rows };
pairings.push(result);
post({ type: "pairing", kind: "tournament", format, ...coords, ...withoutRows(result), completed: completedMatches, total: totalMatches });
return result;
};
const done = { kind: "tournament", format, field: names, difficulty, worlds: [...worlds], seeds, totalMatches };
if (format === "round-robin") {
// roundRobinPairs IS the schedule (every unordered pair once, in the caller's own order); the
// loop around it is all a round-robin needs β€” no rounds, no byes, no rematch avoidance.
for (const [a, b] of roundRobinPairs(entrants)) runPairing(a, b, { seedBase });
done.standings = tallyStandings(names, pairings);
} else if (format === "swiss") {
const bracket = buildSwissBracket(entrants, roundCount, { worlds, seeds, seedBase }, runPairing);
done.rounds = bracket.rounds;
done.byeMatchCount = bracket.byeMatchCount;
done.standings = bracket.standings; // pairing.js's own ranked table, byes included
done.roundsLog = bracket.roundsLog.map(r => ({ ...r, matches: r.matches.map(withoutRows) }));
done.byeNames = bracket.roundsLog.map(r => r.byeName).filter(Boolean);
} else {
// The field arrives ALREADY SEEDED (strongest first) β€” competition.js seeds it off the
// bracket's own ladder before posting the job, exactly as pairing.js's header requires
// ("this function consumes an order, it does not invent one").
const bracket = buildKnockoutBracket(entrants, { seedBase }, runPairing);
done.bracket = {
entrants: bracket.entrants,
matchCount: bracket.matchCount,
champion: bracket.champion,
rounds: bracket.rounds.map(r => ({
round: r.round,
matches: r.matches.map(m => ({ ...m, result: m.result ? withoutRows(m.result) : null })),
})),
};
done.champion = bracket.champion.name;
}
done.pairings = pairings;
done.completedMatches = completedMatches;
return done;
}
// Message-handler wiring: guarded the dom.js way (see header) so this file bare-imports cleanly
// under Node. Checking `self.postMessage` (not just `self`) is the part that actually matters β€”
// some Node builds define a bare `self` global with none of the real Worker surface, and it's
// postMessage's own presence that proves this is a genuine Worker context.
if (typeof self !== "undefined" && typeof self.postMessage === "function") {
self.onmessage = async e => {
try {
// A tournament job says so; ANY other job is the original single-duel kind, handled exactly
// as it always was (Quick Duel's own message contract is untouched by Phase 3).
if (e.data && e.data.kind === "tournament") {
const result = runTournamentJob(e.data, msg => self.postMessage(msg));
self.postMessage({ type: "done", ...result });
} else {
const result = runCompetitionJob(e.data, p => self.postMessage({ type: "progress", ...p }));
self.postMessage({ type: "done", ...result });
}
} catch (err) {
self.postMessage({ type: "error", message: String((err && err.message) || err) });
}
};
}