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) });
    }
  };
}