Spaces:
Paused
Paused
Download competitionWorker.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/competitionWorker.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/competitionWorker.js
-
curl -L -o competitionWorker.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/competitionWorker.js
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). | |
| ============================================================ */ | |
| ; | |
| 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) }); | |
| } | |
| }; | |
| } | |