Spaces:
Paused
Paused
Download pairing.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 32.3 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/pairing.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/pairing.js
-
curl -L -o pairing.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/pairing.js
32.3 kB
| // @ts-check | |
| ; | |
| /* ============================================================ | |
| PAIRING β the pure "who plays whom" logic every competition FORMAT is built on: Swiss (rounds | |
| paired by closest standing, with backtracking matching and proper bye assignment), round-robin | |
| (every unordered pair once) and knockout (single elimination). Extracted out of tools/ailab.js | |
| (docs/competitions-and-elo.md Phase 3) so the in-game competition screen β a browser Worker, in | |
| the stage after this one β and the CLI bench schedule tournaments through ONE implementation: | |
| "the Swiss matcher's backtracking, its bye rule, and its already-played-pair avoidance are | |
| exactly the sort of code that must not be reimplemented", per that phase's own brief. | |
| Root-level and pure, exactly like elo.js and for the same reason (D1): a schedule is not sim | |
| state, and both the game and the bench need the identical one. No clock, no storage, no DOM, no | |
| engine state β the single import is hashStr (engine/rng.js), used to derive each round's seed | |
| base the way every other seed in this repo is derived. | |
| NOTHING HERE DECIDES WHO WON A MATCH. Every schedule below takes its match runner as a PARAMETER | |
| (`runPair(a, b, opts)`, returning tools/ailab.js's runSwappedDuel aggregate shape: | |
| `{ aWins, bWins, draws, avgMargin, β¦ }`). The fairness machinery β side-swap, identical pinned | |
| APM/micro for both seats, difficulty brackets, per-match seed derivation β stays in | |
| tools/duelCore.js and tools/ailab.js where it already lives and has already had bugs found in it. | |
| This module only decides the SCHEDULE. That separation is also what makes every function here | |
| testable on synthetic results in milliseconds instead of by simulating real matches. | |
| tools/ailab.js re-exports rankStandings/pairRound/buildSwissBracket straight through under their | |
| original names, so runSwissBracket, runSwissTournament and every other existing caller β plus | |
| test/ailab.test.js's own import line β keep working completely unchanged. The paragraphs below | |
| moved here verbatim from tools/ailab.js's TIER 5 header, because they are the record of WHY this | |
| code has the shape it does; the parts about the CLI's own round count and --difficulties handling | |
| stayed there, with runSwissTournament. | |
| PAIRING RULE (findMatching/pairRound below): sort the field by current wins (ties keep the | |
| stable order they arrive in), then find a matching that avoids every already-played pair IF | |
| ONE EXISTS. The first cut of this shipped as pure greedy β walk the sorted list, pair each | |
| candidate with the first not-yet-played opponent remaining β and independent review caught | |
| that it does NOT deliver the "avoids a repeat unless truly forced" guarantee it claimed: | |
| fuzz-testing against a brute-force ground-truth checker measured avoidable repeats in roughly | |
| a quarter to half of realistic tournaments (n=6..50), because a greedy walk can strand two | |
| candidates with each other even when a full zero-repeat matching exists elsewhere in the same | |
| round (classic "first fit with no lookahead" failure). findMatching now backtracks: for each | |
| candidate (closest-standing first) it tries an unplayed opponent before ever trying a repeat, | |
| and if that choice can't be extended into a complete matching for the rest of the pool, it | |
| backtracks and tries the next one. Bounded by MATCH_ATTEMPT_CAP (small Swiss pools β this | |
| tool's whole use case β resolve in a handful of attempts; the cap exists so a pathological | |
| input can never hang) with a plain greedy pass as the last-resort fallback if the cap is hit, | |
| so this always terminates and always returns a complete pairing. | |
| BYES: odd `candidates.length` leaves one candidate unpaired each round. It's awarded to the | |
| LOWEST-standing candidate who hasn't had one yet, falling back to lowest-standing overall once | |
| everyone has β standard Swiss practice. That fallback CAN eventually reach the field's current | |
| leader in a long-enough Swiss on a small pool (this tool's own default rounds formula keeps | |
| real runs far short of that, but nothing prevents it if `--rounds` is pushed high). A bye is | |
| NOT a win: it's the schedule's own bookkeeping for "this candidate wasn't paired," and the | |
| first cut of this got that wrong too β it credited a bye as a full pairing's worth of wins | |
| (`worlds.length * seeds * 2`, i.e. a maximum-margin sweep) with zero matching losses, which an | |
| independent review caught letting a candidate that never fights at all tie or outrank one that | |
| won a genuine match (reproduced live: a neverInitiates/zero-standing-army candidate that drew | |
| a bye matched a real winner's standing). A bye now adds to nobody's `wins`/`losses` β only its | |
| own `byes` counter β so standings reflect actual fighting, never schedule luck. `byeMatchCount` | |
| is kept as an informational field only (how big a real pairing this round would have been), | |
| never applied to anyone's tally. | |
| ============================================================ */ | |
| import { hashStr } from "./engine/rng.js"; | |
| /** | |
| * @typedef {{ name: string }} Entrant | |
| * Any candidate-like object β tools/ailab.js's `{ name, strategy, overrides }` candidates and | |
| * competitionLedger.js's roster rows both qualify. This module only ever reads `.name`; | |
| * everything else is passed through to the injected `runPair` untouched. | |
| */ | |
| /** | |
| * @typedef {{ name: string, wins?: number, losses?: number, draws?: number, byes?: number }} StandingsRow | |
| * One entrant's running tally within a bracket. `byes` is bookkeeping only β never scored. | |
| */ | |
| /** | |
| * @typedef {{ aWins?: number, bWins?: number, draws?: number, avgMargin?: number }} PairResult | |
| * The aggregate a `runPair(a, b, opts)` returns for one PAIRING (not one match): tools/ailab.js's | |
| * runSwappedDuel shape. Only these four fields are ever read here; everything else it carries is | |
| * stored verbatim for the caller/UI. | |
| */ | |
| const pairKey = (a, b) => [a, b].sort().join("::"); | |
| // Total (candidate, opponent) trial attempts findMatching will spend across an ENTIRE call | |
| // (shared across all its recursive branches via the mutable `budget` object) before giving up | |
| // and falling back to a plain greedy pass. Small Swiss pools β this tool's whole use case β | |
| // resolve in well under this; it exists purely so a pathological input can never hang. | |
| const MATCH_ATTEMPT_CAP = 20000; | |
| // Backtracking search for a COMPLETE ZERO-REPEAT matching over `pool` (already sorted by | |
| // standing, closest-first) β every pair it considers must be absent from `played`; it never | |
| // accepts a repeat itself. For the pool's first member, tries each unplayed opponent in | |
| // closest-standing order, recursing on what's left; if a choice can't be extended into a | |
| // complete zero-repeat matching for the REST of the pool, it backtracks and tries the next | |
| // opponent β the step a plain greedy walk skips, which is exactly how it can strand two | |
| // candidates with each other (see the header comment above). Returns null β not a | |
| // repeat-containing matching β when no zero-repeat completion exists from here, so a caller | |
| // can tell "impossible" apart from "found one" and only then fall back to greedyMatching, which | |
| // is the one place a repeat is ever chosen. `budget.n` is decremented on every attempt and | |
| // shared across the whole recursion (mutated in place), so total work is bounded regardless of | |
| // tree depth; running out returns null the same as genuine impossibility, and the caller's | |
| // greedy fallback still always completes since repeats are unconditionally allowed there. | |
| function findMatching(pool, played, budget) { | |
| if (!pool.length) return []; | |
| const [a, ...rest] = pool; | |
| for (const b of rest) { | |
| if (played.has(pairKey(a.name, b.name))) continue; // this search only ever considers UNPLAYED pairs | |
| if (budget.n <= 0) return null; | |
| budget.n--; | |
| const remaining = rest.filter(c => c !== b); | |
| const sub = findMatching(remaining, played, budget); | |
| if (sub) return [[a, b], ...sub]; | |
| } | |
| return null; // no zero-repeat completion from here (or the budget ran out) β caller falls back | |
| } | |
| // The original greedy pass (closest-standing first, first not-yet-played opponent, forced repeat | |
| // if none remain) β kept only as findMatching's fallback if MATCH_ATTEMPT_CAP is ever exhausted, | |
| // so pairRound always terminates and always returns a complete pairing either way. | |
| function greedyMatching(pool, played) { | |
| const rest = [...pool]; | |
| const pairs = []; | |
| while (rest.length) { | |
| const a = rest.shift(); | |
| let idx = rest.findIndex(b => !played.has(pairKey(a.name, b.name))); | |
| if (idx === -1) idx = 0; | |
| const b = rest.splice(idx, 1)[0]; | |
| pairs.push([a, b]); | |
| } | |
| return pairs; | |
| } | |
| // One round's pairings off the current standings, sorted highest-first. `played` is the set of | |
| // pairKey()s already run in ANY earlier round of this bracket; `hadBye` is the set of names | |
| // already given a bye. Returns { pairs: [[aRow, bRow], ...], byeName } β `pairs` holds the | |
| // standings rows (not the candidate objects; the caller looks the candidate up by name), so | |
| // this function stays pure and easy to test on plain data. Exported for direct, fast unit tests | |
| // against synthetic standings/played-sets β the pairing algorithm's own correctness doesn't | |
| // depend on running real matches. | |
| // Rank a Swiss field. Win RATE, with raw wins as the tie-break β not absolute wins, which is what | |
| // this used to sort on. A bye is scorable-neutral (nothing added to wins or losses), but its | |
| // recipient simply plays one fewer pairing, i.e. worlds x seeds x 2 fewer chances to earn a win β | |
| // 16 under the swiss CLI defaults. Sorting on totals therefore ranked "everyone who avoided a bye, | |
| // then everyone who didn't": a 2W-2L candidate at 50% finished below a 3W-3L candidate at 50% | |
| // purely for having played more. That is the mirror image of the bug e9ad1d0 fixed, in the one | |
| // place the tool is meant to be trustworthy. Ties fall back to name so the order is total. | |
| /** | |
| * @param {StandingsRow[]} rows | |
| * @returns {StandingsRow[]} a new array, best-first; the input is left untouched. | |
| */ | |
| export function rankStandings(rows) { | |
| const rate = r => { | |
| const n = (r.wins || 0) + (r.losses || 0) + (r.draws || 0); | |
| return n > 0 ? (r.wins || 0) / n : 0; | |
| }; | |
| return [...rows].sort((x, y) => | |
| rate(y) - rate(x) || (y.wins || 0) - (x.wins || 0) || (x.losses || 0) - (y.losses || 0) | |
| || (x.name < y.name ? -1 : x.name > y.name ? 1 : 0)); | |
| } | |
| /** | |
| * Tally a list of FINISHED pairings into standings rows, ranked by rankStandings above. The same | |
| * arithmetic buildSwissBracket does incrementally as it runs (a pairing's `aWins` are A's wins and | |
| * B's losses; a bye touches only its own counter, never wins/losses β see the BYES paragraph in | |
| * this file's header), but computed from a flat list after the fact, which is what a caller holding | |
| * only "the pairings finished so far" has: competitionWorker.js's round-robin result, and the | |
| * in-game tournament screen's LIVE standings as each pairing lands. Every name in `names` gets a | |
| * row even if it hasn't played yet, so a field never appears to shrink mid-tournament. | |
| * @param {string[]} names the whole field, so an unplayed entrant still gets a 0-0-0 row. | |
| * @param {{ aName: string, bName: string, aWins?: number, bWins?: number, draws?: number }[]} pairings | |
| * @param {string[]} [byeNames] one entry per bye awarded (the same name may appear more than once). | |
| * @returns {StandingsRow[]} ranked best-first; the inputs are left untouched. | |
| */ | |
| export function tallyStandings(names, pairings, byeNames = []) { | |
| const rows = new Map(names.map(name => [name, { name, wins: 0, losses: 0, draws: 0, byes: 0 }])); | |
| const rowFor = name => rows.get(name) || (rows.set(name, { name, wins: 0, losses: 0, draws: 0, byes: 0 }), rows.get(name)); | |
| for (const p of pairings || []) { | |
| const a = rowFor(p.aName), b = rowFor(p.bName); | |
| const aWins = p.aWins || 0, bWins = p.bWins || 0, draws = p.draws || 0; | |
| a.wins += aWins; a.losses += bWins; a.draws += draws; | |
| b.wins += bWins; b.losses += aWins; b.draws += draws; | |
| } | |
| for (const name of byeNames || []) rowFor(name).byes += 1; // NOT a win β see header | |
| return rankStandings([...rows.values()]); | |
| } | |
| /** | |
| * @param {StandingsRow[]} standingsRows | |
| * @param {Set<string>} played pairKey()s already run in an earlier round of this bracket. | |
| * @param {Set<string>} hadBye names already given a bye in this bracket. | |
| * @returns {{ pairs: StandingsRow[][], byeName: string|null }} | |
| */ | |
| export function pairRound(standingsRows, played, hadBye) { | |
| // Tie-break the pairing order by NAME, not by list position. Every standing is 0-0 in round 1, and | |
| // a stable sort preserved input order, so the loop below always took the last-LISTED candidate: | |
| // `--candidates a.json,β¦,e.json` structurally penalised e.json for nothing but its position. | |
| const order = [...standingsRows].sort((a, b) => | |
| b.wins - a.wins || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); | |
| let byeName = null; | |
| if (order.length % 2 === 1) { | |
| let idx = -1; | |
| for (let i = order.length - 1; i >= 0; i--) if (!hadBye.has(order[i].name)) { idx = i; break; } | |
| if (idx === -1) idx = order.length - 1; // everyone's already had one β lowest standing again | |
| byeName = order.splice(idx, 1)[0].name; | |
| } | |
| const pairs = findMatching(order, played, { n: MATCH_ATTEMPT_CAP }) || greedyMatching(order, played); | |
| return { pairs, byeName }; | |
| } | |
| /** | |
| * How many rounds a Swiss bracket over `n` entrants runs by default: `max(3, ceil(log2 n))` β the | |
| * textbook rule of thumb for how many rounds separate a field of that size, and the exact default | |
| * tools/ailab.js's runSwissTournament has always applied to `--rounds` (it calls this now, so the | |
| * CLI and the in-game screen can never drift apart on it). Exported because BOTH the in-game | |
| * tournament screen (which prices a run before starting it, and shows the number it will use) and | |
| * competitionWorker.js (which defaults the job's own optional `rounds`) need the identical answer. | |
| * @param {number} n | |
| * @returns {number} | |
| * @throws {Error} if n < 2, the same floor buildSwissBracket's own callers enforce. | |
| */ | |
| export function swissRoundCount(n) { | |
| if (!(n >= 2)) throw new Error(`a Swiss bracket needs at least 2 entrants, got ${n}`); | |
| return Math.max(3, Math.ceil(Math.log2(n))); | |
| } | |
| // One difficulty bracket's whole Swiss schedule: `numRounds` rounds of pairRound, each pairing | |
| // run through runSwappedDuel exactly like round-robin's own pairs β same opts shape, same | |
| // fairness. Standings accumulate match wins/losses/draws round over round, same tallying | |
| // runRoundRobinSwapped already does per pairing; a bye adds only to `byes`, never `wins`/ | |
| // `losses` β see the header comment above. | |
| // The Swiss SCHEDULE, with the match runner injected. Extracted so the combinatorics β round count, | |
| // pairings per round, bye rotation, rematch avoidance, standings order β can be tested on synthetic | |
| // results in milliseconds instead of by simulating real 15-40 sim-minute matches. Those schedule | |
| // tests were roughly 50 s of the suite's wall clock, all of it spent proving arithmetic. (The file | |
| // already demonstrated the fast idiom once, in pairRound's own test, and said why it was better.) | |
| // tools/ailab.js's runSwissBracket is a thin wrapper that injects the real runSwappedDuel. | |
| /** | |
| * @param {Entrant[]} candidates | |
| * @param {number} numRounds | |
| * @param {{ worlds?: string[], seeds?: number, seedBase?: number }} opts forwarded to `runPair` verbatim, seedBase per round. | |
| * @param {(a: Entrant, b: Entrant, opts: Object) => PairResult} runPair | |
| * @returns {{ rounds: number, byeMatchCount: number, | |
| * roundsLog: { round: number, byeName: string|null, byeMatchCount: number|null, matches: PairResult[] }[], | |
| * standings: StandingsRow[] }} | |
| */ | |
| export function buildSwissBracket(candidates, numRounds, opts, runPair) { | |
| const worlds = opts.worlds || ["korrath", "ferros", "vesper", "kybernet"]; | |
| const seeds = opts.seeds ?? 2; | |
| const byeMatchCount = worlds.length * seeds * 2; // informational only β == a real pairing's runSwappedDuel `n` | |
| const standings = new Map(candidates.map(c => [c.name, { name: c.name, wins: 0, losses: 0, draws: 0, byes: 0 }])); | |
| const played = new Set(), hadBye = new Set(); | |
| const rounds = []; | |
| for (let round = 1; round <= numRounds; round++) { | |
| const { pairs, byeName } = pairRound([...standings.values()], played, hadBye); | |
| if (byeName) { | |
| standings.get(byeName).byes += 1; // NOT a win β see header comment | |
| hadBye.add(byeName); | |
| } | |
| const matches = []; | |
| // Fold the round number into the seed so a forced rematch in a later round isn't a byte-for-byte | |
| // copy of the earlier one, while staying fully deterministic (hashStr, no clock, no unseeded | |
| // pick) β the same discipline duelSeed() already applies to world/difficulty/names/rep. | |
| const roundSeedBase = hashStr(`${opts.seedBase ?? 1}:swiss:round${round}`); | |
| for (const [aRow, bRow] of pairs) { | |
| const a = candidates.find(c => c.name === aRow.name); | |
| const b = candidates.find(c => c.name === bRow.name); | |
| played.add(pairKey(a.name, b.name)); | |
| const res = runPair(a, b, { ...opts, seedBase: roundSeedBase }); | |
| const A = standings.get(a.name), B = standings.get(b.name); | |
| A.wins += res.aWins; A.losses += res.bWins; A.draws += res.draws; | |
| B.wins += res.bWins; B.losses += res.aWins; B.draws += res.draws; | |
| matches.push(res); | |
| } | |
| rounds.push({ round, byeName, byeMatchCount: byeName ? byeMatchCount : null, matches }); | |
| } | |
| return { | |
| rounds: numRounds, byeMatchCount, roundsLog: rounds, | |
| standings: rankStandings([...standings.values()]), | |
| }; | |
| } | |
| /* ============================================================ | |
| ROUND-ROBIN β every unordered pair, once, in a fixed order | |
| ============================================================ */ | |
| // The schedule tools/ailab.js's runRoundRobin and runRoundRobinSwapped have always written inline | |
| // as a nested `for (i) for (j = i + 1)` loop, named and shared so the in-game formats schedule a | |
| // round-robin through the same code the bench does rather than growing a second copy of it. | |
| // Deliberately NOT circle-method round scheduling (which would group the pairs into rounds where | |
| // everyone plays at most once): a round-robin here is a flat list of pairings run back to back, and | |
| // that is all the callers want. The order is `i x j over the caller's own input order`, so it is | |
| // fully determined by the field it was handed β this helper never sorts a field of its own, because | |
| // the caller's order is meaningful (it is a seeding, or a roster order, or a CLI argument order). | |
| /** | |
| * @template T | |
| * @param {T[]} entrants entrant objects, or plain names β the elements are passed through as-is. | |
| * @returns {T[][]} every unordered pair exactly once: [[e0,e1], [e0,e2], β¦, [e(n-2),e(n-1)]]. | |
| */ | |
| export function roundRobinPairs(entrants) { | |
| const pairs = []; | |
| for (let i = 0; i < entrants.length; i++) | |
| for (let j = i + 1; j < entrants.length; j++) | |
| pairs.push([entrants[i], entrants[j]]); | |
| return pairs; | |
| } | |
| /* ============================================================ | |
| KNOCKOUT β single elimination (docs/competitions-and-elo.md Phase 3: "the most legible tournament | |
| PICTURE; a real final") | |
| SEEDING IS THE CALLER'S. `seededEntrants` arrives already in seed order, strongest first β the | |
| competition screen builds it (off Standings rating, falling back to name for anyone unrated). | |
| This function never re-sorts by rating, so it stays pure and completely decoupled from the | |
| ledger; it consumes an order, it does not invent one. | |
| THE BRACKET. Round 1 reduces the field to a power of two, which is what makes every later round a | |
| clean halving with no byes left to hand out: with `target` = the smallest power of two >= n, the | |
| top `target - n` seeds get a first-round bye and advance without playing, and the remaining | |
| entrants pair up CONSECUTIVELY in seed order (the first non-bye seed plays the next one, and so | |
| on). That leaves `target / 2` entrants β byes first, in seed order, then each match's winner in | |
| bracket order β and every round after it pairs consecutively again, winner-of-slot-1 against | |
| winner-of-slot-2. Deliberately NOT standard tournament reseeding (1-vs-16, 2-vs-15, β¦): simple | |
| consecutive pairing after the byes are peeled off is a completely legitimate single-elimination | |
| bracket, and the reseeding math is complexity this feature does not need. | |
| (A note for anyone diffing this against the Phase 3 brief, which says "let p = the LARGEST power | |
| of 2 <= n; the top (n - p) seeds get a bye": that formula and the invariant the brief states in | |
| the very next clause β "this is now always a power of 2, so no more byes after round 1" β only | |
| agree when n is p or 1.5p. They coincide on the brief's own worked example (n = 6: two byes | |
| either way) and diverge at, say, n = 11, where n - p = 3 byes would leave a 7-entrant round 2 and | |
| the bye-peeling would have to repeat every round β handing the top seed four byes and a walkover | |
| into the final. `target - n` byes is the standard reduction, keeps the stated invariant for every | |
| n, and preserves the quantity the brief actually names: `n - p` is the number of FIRST-ROUND | |
| MATCHES here whenever n is NOT itself a power of two. When n IS a power of two the two formulas | |
| simply agree on the thing that matters β zero byes β and `n - p` is 0 while round 1 plays n/2 | |
| matches, so the correspondence is a coincidence of the non-power-of-two case rather than an | |
| identity. Stated precisely because a comment asserting an invariant the code does not hold is a | |
| named defect class in this repo: docs/code-improvement-tiers.md, "Comments assert invariants the | |
| code does not hold".) | |
| A loss eliminates; the last entrant standing is the champion. Total non-bye matches is therefore | |
| always exactly n - 1 whatever n is and however many byes round 1 handed out β every match | |
| eliminates exactly one entrant, and a bye eliminates nobody. knockoutMatchCount() below is that | |
| fact on its own, for a cost estimate shown BEFORE a bracket runs. | |
| DRAWS. A pairing's aggregate can tie on wins even though this codebase near-eliminates real | |
| draws (engine/victory.js's own score tiebreak β see docs/competitions-and-elo.md D7). Somebody | |
| still has to advance, so: higher aggregate score margin first (`avgMargin`, the same field the | |
| pairing result already carries and the same tiebreak the standings use), then the higher seed | |
| (earlier in `seededEntrants`). Deterministic either way β never a coin flip, which would make a | |
| bracket unreproducible and D6 unenforceable. | |
| ============================================================ */ | |
| // The smallest power of two >= n (n >= 1). Loop rather than bit math: the fields here are tiny, and | |
| // this reads as its own definition. | |
| const nextPowerOfTwo = n => { let p = 1; while (p < n) p *= 2; return p; }; | |
| /** | |
| * How many matches a knockout bracket over `n` entrants will actually play β always n - 1, since | |
| * every match eliminates exactly one entrant and a bye eliminates nobody. Exported so the UI can | |
| * price a bracket ("β 15 matches, β 1 min") before committing to run one. | |
| * @param {number} n | |
| * @returns {number} | |
| * @throws {Error} if n < 2, the same floor buildKnockoutBracket itself enforces. | |
| */ | |
| export function knockoutMatchCount(n) { | |
| if (!(n >= 2)) throw new Error(`a knockout bracket needs at least 2 entrants, got ${n}`); | |
| return n - 1; | |
| } | |
| // Who advances out of one played pairing. Wins first, then aggregate score margin, then seed β | |
| // see the DRAWS paragraph in the header above for why each step exists and why none of them is | |
| // random. `seedOf` maps name -> index in the original seeded field, so "higher seed" means exactly | |
| // "the caller listed it earlier". | |
| function advanceFrom(a, b, result, seedOf) { | |
| if (!result || typeof result !== "object") | |
| throw new Error(`runPair returned no result for "${a.name}" vs "${b.name}" β a knockout can't advance anyone from that`); | |
| const aWins = result.aWins || 0, bWins = result.bWins || 0; | |
| if (aWins !== bWins) return aWins > bWins ? a : b; | |
| const margin = Number.isFinite(result.avgMargin) ? result.avgMargin : 0; | |
| if (margin !== 0) return margin > 0 ? a : b; | |
| return seedOf.get(a.name) <= seedOf.get(b.name) ? a : b; | |
| } | |
| /** | |
| * @typedef {Object} KnockoutSlot One position in the bracket tree: a played match, or a bye. | |
| * @property {number} round 1-based round this slot sits in. | |
| * @property {Entrant} a The entrant in the slot's first seat. Never null. | |
| * @property {Entrant|null} b The entrant it played β null exactly when `bye` is true. | |
| * @property {boolean} bye True when `a` advanced without playing anyone. | |
| * @property {Entrant} winner The entrant that advances out of this slot (=== `a` for a bye). | |
| * @property {PairResult|null} result What `runPair` returned β null exactly when `bye` is true. | |
| */ | |
| /** | |
| * @typedef {Object} KnockoutRound | |
| * @property {number} round | |
| * @property {KnockoutSlot[]} matches This round's slots in BRACKET order β slot i's winner meets | |
| * slot i+1's winner in the next round. Byes are slots too (`bye: true`), listed first, so the | |
| * round's own order IS the order its winners enter the next one. | |
| */ | |
| /** | |
| * @typedef {Object} KnockoutBracket | |
| * @property {Entrant[]} entrants The seeded field, copied in the order given: index = seed, 0 = strongest. | |
| * @property {KnockoutRound[]} rounds | |
| * Every round in order, so the whole tree (who played whom, who advanced, who had a bye) reads | |
| * straight back out of this with nothing to re-derive. | |
| * @property {number} matchCount Non-bye matches actually played β always entrants.length - 1. | |
| * @property {Entrant} champion The last entrant standing. | |
| */ | |
| /** | |
| * Run a single-elimination bracket over an ALREADY-SEEDED field, with the match runner injected β | |
| * the same shape buildSwissBracket takes, so a caller can hand both formats the identical | |
| * `runPair`. See the KNOCKOUT header comment above for the bracket rule, the bye rule and the | |
| * draw tie-break. | |
| * @param {Entrant[]} seededEntrants strongest first; this function consumes the order, never re-sorts it. | |
| * @param {{ seedBase?: number }} opts forwarded to `runPair` verbatim, with `seedBase` replaced | |
| * per round. Pass `{}` when there is nothing to forward β it is positional, like | |
| * buildSwissBracket's own, so that the injected `runPair` stays last in both. | |
| * @param {(a: Entrant, b: Entrant, opts: Object) => PairResult} runPair | |
| * @returns {KnockoutBracket} | |
| * @throws {Error} on fewer than 2 entrants, a nameless entrant, or two entrants sharing a name. | |
| */ | |
| export function buildKnockoutBracket(seededEntrants, opts, runPair) { | |
| const config = opts || {}; // whatever the caller forwards to runPair; absent is simply an empty bag | |
| const entrants = [...seededEntrants]; | |
| if (entrants.length < 2) | |
| throw new Error(`a knockout bracket needs at least 2 entrants, got ${entrants.length}`); | |
| // Same guard, same reasoning as tools/ailab.js's assertUniqueCandidateNames: a bracket is read | |
| // and rated by NAME (elo.js's RatingsTable is keyed by it), so two entrants sharing one would | |
| // make the tree ambiguous to anyone reading it and collide both sides' ratings downstream. | |
| const seedOf = new Map(); | |
| entrants.forEach((e, i) => { | |
| if (!e || !e.name) throw new Error('every knockout entrant needs a "name"'); | |
| if (seedOf.has(e.name)) throw new Error(`duplicate entrant name "${e.name}" β a knockout bracket is read by name`); | |
| seedOf.set(e.name, i); | |
| }); | |
| const rounds = []; | |
| let field = entrants; | |
| let matchCount = 0; | |
| for (let round = 1; field.length > 1; round++) { | |
| // Byes only ever fire in round 1: after it the field is a power of two, and this is 0 by | |
| // construction from then on β which is why no later round needs a special case. | |
| const byes = nextPowerOfTwo(field.length) - field.length; | |
| // Fold the round into the seed base exactly as buildSwissBracket does, so every pairing's seed | |
| // derives from (competition seed, round) and a whole bracket replays byte-for-byte (D6). | |
| const roundSeedBase = hashStr(`${config.seedBase ?? 1}:knockout:round${round}`); | |
| const matches = []; | |
| for (let i = 0; i < byes; i++) | |
| matches.push({ round, a: field[i], b: null, bye: true, winner: field[i], result: null }); | |
| for (let i = byes; i < field.length; i += 2) { | |
| const a = field[i], b = field[i + 1]; | |
| const result = runPair(a, b, { ...config, seedBase: roundSeedBase }); | |
| matches.push({ round, a, b, bye: false, winner: advanceFrom(a, b, result, seedOf), result }); | |
| matchCount++; | |
| } | |
| rounds.push({ round, matches }); | |
| field = matches.map(m => m.winner); | |
| } | |
| return { entrants, rounds, matchCount, champion: field[0] }; | |
| } | |
| /* ============================================================ | |
| SCHEDULE SHAPE β how many PAIRINGS each format schedules, and in how many rounds, WITHOUT | |
| running anything. Two callers need exactly this, for two different reasons: | |
| β’ the in-game tournament screen, to price a run before the player commits to it | |
| (docs/competitions-and-elo.md Phase 3: "a user who picks 16 entrants x 4 worlds x 3 seeds | |
| deserves to be told it's a long run before it starts, not after"); | |
| β’ competitionWorker.js, to turn a flat "this is pairing number k" counter into the | |
| "round 2 of 4, pairing 3 of 8" coordinates a progress readout needs β neither | |
| buildSwissBracket nor buildKnockoutBracket tells its injected runPair where in the schedule | |
| it currently is, and neither should have to. | |
| It lives HERE, next to the schedules it describes, because a second copy of this arithmetic that | |
| disagreed with what actually runs is precisely the kind of drift this module exists to prevent β | |
| test/pairing.test.js checks every plan against a real bracket, round for round, rather than | |
| against a restatement of the formula. | |
| ============================================================ */ | |
| /** | |
| * @param {"round-robin"|"swiss"|"knockout"} format | |
| * @param {number} n the field size. | |
| * @param {number} [rounds] Swiss only: an explicit round count overriding swissRoundCount(n). | |
| * Ignored by the other two formats, which have no round count to override. | |
| * @returns {{ round: number, pairings: number }[]} one entry per round, in order. | |
| * @throws {Error} on an unknown format or a field too small to schedule. | |
| */ | |
| export function tournamentRoundPlan(format, n, rounds) { | |
| if (!(n >= 2)) throw new Error(`a tournament needs at least 2 entrants, got ${n}`); | |
| // Round-robin is deliberately ONE flat round of every pair, matching roundRobinPairs above (see | |
| // its own comment on why this isn't circle-method round scheduling). | |
| if (format === "round-robin") return [{ round: 1, pairings: (n * (n - 1)) / 2 }]; | |
| if (format === "swiss") { | |
| const count = rounds > 0 ? Math.floor(rounds) : swissRoundCount(n); | |
| // floor(n/2), not ceil: an odd field's leftover entrant takes a BYE, which is not a pairing and | |
| // costs no matches β pairRound's own rule. | |
| return Array.from({ length: count }, (_, i) => ({ round: i + 1, pairings: Math.floor(n / 2) })); | |
| } | |
| if (format === "knockout") { | |
| // Walk the same reduction buildKnockoutBracket performs: round 1 byes the top | |
| // `nextPowerOfTwo(size) - size` seeds and pairs the rest consecutively, leaving a power of two; | |
| // every later round halves it. The pairings sum to n - 1 for every n (knockoutMatchCount's own | |
| // fact), byes or no byes. | |
| const plan = []; | |
| let size = n; | |
| for (let round = 1; size > 1; round++) { | |
| const byes = nextPowerOfTwo(size) - size; | |
| const pairings = (size - byes) / 2; | |
| plan.push({ round, pairings }); | |
| size = byes + pairings; | |
| } | |
| return plan; | |
| } | |
| throw new Error(`unknown tournament format "${format}"`); | |
| } | |