Spaces:
Paused
Paused
File size: 21,645 Bytes
d2a6f14 204faa9 d2a6f14 204faa9 d2a6f14 204faa9 d2a6f14 60d3ef9 204faa9 d2a6f14 628afc1 d2a6f14 628afc1 60d3ef9 d2a6f14 60d3ef9 d2a6f14 60d3ef9 d2a6f14 60d3ef9 d2a6f14 60d3ef9 d2a6f14 204faa9 d2a6f14 204faa9 d2a6f14 | 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 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | /* ============================================================
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) });
}
};
}
|