Reflect group stage in World Cup sim; seed R32 from real 2026 bracket #107
6 changed files with 1328 additions and 107 deletions
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FIFA_2026_R32_TEMPLATE,
|
||||
THIRD_PLACE_SLOTS,
|
||||
assignThirdPlaceSlots,
|
||||
type R32Slot,
|
||||
} from "../fifa-2026-bracket";
|
||||
|
||||
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
|
||||
|
||||
describe("FIFA_2026_R32_TEMPLATE", () => {
|
||||
it("has 16 matches with unique DB match numbers 1–16", () => {
|
||||
expect(FIFA_2026_R32_TEMPLATE).toHaveLength(16);
|
||||
const dbNums = FIFA_2026_R32_TEMPLATE.map((m) => m.dbMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(dbNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
|
||||
});
|
||||
|
||||
it("maps onto the official FIFA match numbers 73–88", () => {
|
||||
const fifaNums = FIFA_2026_R32_TEMPLATE.map((m) => m.fifaMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(fifaNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 73));
|
||||
});
|
||||
|
||||
it("uses each group winner and runner-up exactly once across the 32 slots", () => {
|
||||
const winners: string[] = [];
|
||||
const runnersUp: string[] = [];
|
||||
for (const m of FIFA_2026_R32_TEMPLATE) {
|
||||
for (const slot of [m.slot1, m.slot2] as R32Slot[]) {
|
||||
if (slot.kind === "winner") winners.push(slot.group);
|
||||
if (slot.kind === "runnerUp") runnersUp.push(slot.group);
|
||||
}
|
||||
}
|
||||
expect(winners.toSorted()).toEqual(GROUPS);
|
||||
expect(runnersUp.toSorted()).toEqual(GROUPS);
|
||||
});
|
||||
|
||||
it("has exactly 8 third-place slots", () => {
|
||||
expect(THIRD_PLACE_SLOTS).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignThirdPlaceSlots", () => {
|
||||
it("assigns all 8 qualifying thirds to distinct, eligible slots", () => {
|
||||
// Best-8 thirds come from groups A–H (a plausible qualifying set).
|
||||
const qualifying = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||
const assignment = assignThirdPlaceSlots(qualifying);
|
||||
|
||||
expect(assignment.size).toBe(8);
|
||||
|
||||
// Every slot filled, each by an eligible & qualifying group, no group reused.
|
||||
const usedGroups = new Set<string>();
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId) ?? "";
|
||||
expect(group).not.toBe("");
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(qualifying).toContain(group);
|
||||
expect(usedGroups.has(group)).toBe(false);
|
||||
usedGroups.add(group);
|
||||
}
|
||||
expect(usedGroups.size).toBe(8);
|
||||
});
|
||||
|
||||
it("produces a valid matching for every 8-of-12 qualifying combination", () => {
|
||||
// Exhaustively check all C(12,8) = 495 combinations resolve to a complete,
|
||||
// eligibility-respecting, conflict-free assignment.
|
||||
let combinations = 0;
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
combinations++;
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
expect(assignment.size).toBe(8);
|
||||
const used = new Set(assignment.values());
|
||||
expect(used.size).toBe(8);
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId);
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(chosen).toContain(group);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) {
|
||||
combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
}
|
||||
};
|
||||
combos(0, []);
|
||||
expect(combinations).toBe(495);
|
||||
});
|
||||
|
||||
it("is deterministic for a given combination", () => {
|
||||
const qualifying = ["B", "D", "E", "F", "H", "I", "J", "L"];
|
||||
const a = assignThirdPlaceSlots(qualifying);
|
||||
const b = assignThirdPlaceSlots(qualifying.toReversed());
|
||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
||||
});
|
||||
|
||||
it("matches FIFA's exact Annex C allocation for option 1 (groups E–L qualify)", () => {
|
||||
// Published row 1: 1A vs 3E, 1B vs 3J, 1D vs 3I, 1E vs 3F,
|
||||
// 1G vs 3H, 1I vs 3G, 1K vs 3L, 1L vs 3K.
|
||||
// Mapped to DB third-slot ids (the match each winner plays in):
|
||||
const assignment = assignThirdPlaceSlots(["E", "F", "G", "H", "I", "J", "K", "L"]);
|
||||
expect(assignment.get(11)).toBe("E"); // winner A's match
|
||||
expect(assignment.get(15)).toBe("J"); // winner B's match
|
||||
expect(assignment.get(7)).toBe("I"); // winner D's match
|
||||
expect(assignment.get(1)).toBe("F"); // winner E's match
|
||||
expect(assignment.get(8)).toBe("H"); // winner G's match
|
||||
expect(assignment.get(2)).toBe("G"); // winner I's match
|
||||
expect(assignment.get(16)).toBe("L"); // winner K's match
|
||||
expect(assignment.get(12)).toBe("K"); // winner L's match
|
||||
});
|
||||
|
||||
it("template eligible-group lists exactly match the Annex C table", () => {
|
||||
// For every slot, collect the set of groups the published table ever assigns
|
||||
// to it across all 495 combinations, and assert it equals the hand-written
|
||||
// eligibleGroups list in the template.
|
||||
const observed = new Map<number, Set<string>>(
|
||||
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
|
||||
);
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
for (const [slotId, group] of assignment) observed.get(slotId)?.add(group);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
};
|
||||
combos(0, []);
|
||||
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
expect([...(observed.get(slot.thirdSlotId) ?? [])].toSorted()).toEqual(
|
||||
[...slot.eligibleGroups].toSorted()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
162
app/lib/fifa-2026-bracket.ts
Normal file
162
app/lib/fifa-2026-bracket.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Official 2026 FIFA World Cup knockout bracket structure (48-team format).
|
||||
*
|
||||
* Encodes how the 32 knockout qualifiers are seeded into the Round of 32 from
|
||||
* their group-stage finishing positions, so the simulator can reproduce the
|
||||
* real tournament draw *before* the admin has manually populated the bracket
|
||||
* (i.e. while the group stage is still being played).
|
||||
*
|
||||
* Two pieces:
|
||||
* (a) FIFA_2026_R32_TEMPLATE — the fixed group-position → slot mapping.
|
||||
* (b) assignThirdPlaceSlots() — which group's 3rd-place team fills each of the
|
||||
* eight "best third-place" slots, given which 8 of the 12 groups produced
|
||||
* a qualifying third.
|
||||
*
|
||||
* Bracket-tree note: the official FIFA match numbers (73–88 for the R32) do NOT
|
||||
* pair adjacently into the Round of 16 (e.g. R16 match 89 = winner of FIFA 74 vs
|
||||
* winner of FIFA 77). The codebase advances brackets with the canonical
|
||||
* `nextMatchNumber = ceil(matchNumber / 2)` rule (see `advanceWinnerTemplate` in
|
||||
* app/models/playoff-match.ts). To make that rule reproduce FIFA's real bracket
|
||||
* halves all the way to the final, the official matches are assigned to DB
|
||||
* matchNumbers 1–16 in the deliberate order below — verified through
|
||||
* R16 → QF → SF so both halves and the semifinal pairings match the real draw.
|
||||
*
|
||||
* Source: Wikipedia "2026 FIFA World Cup knockout stage" and
|
||||
* "Template:2026 FIFA World Cup third-place table" (FIFA regulations Annex C).
|
||||
*/
|
||||
|
||||
import {
|
||||
ANNEX_C_THIRD_PLACE_ALLOCATION,
|
||||
THIRD_PLACE_WINNER_COLUMNS,
|
||||
} from "./fifa-2026-third-place-allocation";
|
||||
|
||||
export type R32Slot =
|
||||
| { kind: "winner"; group: string }
|
||||
| { kind: "runnerUp"; group: string }
|
||||
/** thirdSlotId is the DB matchNumber this slot belongs to (each is unique). */
|
||||
| { kind: "third"; thirdSlotId: number; eligibleGroups: string[] };
|
||||
|
||||
export interface R32MatchSpec {
|
||||
/** 1–16; drives the codebase's ceil(n/2) bracket tree. */
|
||||
dbMatchNumber: number;
|
||||
/** Official FIFA match number (73–88); for traceability only. */
|
||||
fifaMatchNumber: number;
|
||||
slot1: R32Slot;
|
||||
slot2: R32Slot;
|
||||
}
|
||||
|
||||
const winner = (group: string): R32Slot => ({ kind: "winner", group });
|
||||
const runnerUp = (group: string): R32Slot => ({ kind: "runnerUp", group });
|
||||
const third = (dbMatchNumber: number, eligibleGroups: string[]): R32Slot => ({
|
||||
kind: "third",
|
||||
thirdSlotId: dbMatchNumber,
|
||||
eligibleGroups,
|
||||
});
|
||||
|
||||
export const FIFA_2026_R32_TEMPLATE: R32MatchSpec[] = [
|
||||
{ dbMatchNumber: 1, fifaMatchNumber: 74, slot1: winner("E"), slot2: third(1, ["A", "B", "C", "D", "F"]) },
|
||||
{ dbMatchNumber: 2, fifaMatchNumber: 77, slot1: winner("I"), slot2: third(2, ["C", "D", "F", "G", "H"]) },
|
||||
{ dbMatchNumber: 3, fifaMatchNumber: 73, slot1: runnerUp("A"), slot2: runnerUp("B") },
|
||||
{ dbMatchNumber: 4, fifaMatchNumber: 75, slot1: winner("F"), slot2: runnerUp("C") },
|
||||
{ dbMatchNumber: 5, fifaMatchNumber: 83, slot1: runnerUp("K"), slot2: runnerUp("L") },
|
||||
{ dbMatchNumber: 6, fifaMatchNumber: 84, slot1: winner("H"), slot2: runnerUp("J") },
|
||||
{ dbMatchNumber: 7, fifaMatchNumber: 81, slot1: winner("D"), slot2: third(7, ["B", "E", "F", "I", "J"]) },
|
||||
{ dbMatchNumber: 8, fifaMatchNumber: 82, slot1: winner("G"), slot2: third(8, ["A", "E", "H", "I", "J"]) },
|
||||
{ dbMatchNumber: 9, fifaMatchNumber: 76, slot1: winner("C"), slot2: runnerUp("F") },
|
||||
{ dbMatchNumber: 10, fifaMatchNumber: 78, slot1: runnerUp("E"), slot2: runnerUp("I") },
|
||||
{ dbMatchNumber: 11, fifaMatchNumber: 79, slot1: winner("A"), slot2: third(11, ["C", "E", "F", "H", "I"]) },
|
||||
{ dbMatchNumber: 12, fifaMatchNumber: 80, slot1: winner("L"), slot2: third(12, ["E", "H", "I", "J", "K"]) },
|
||||
{ dbMatchNumber: 13, fifaMatchNumber: 86, slot1: winner("J"), slot2: runnerUp("H") },
|
||||
{ dbMatchNumber: 14, fifaMatchNumber: 88, slot1: runnerUp("D"), slot2: runnerUp("G") },
|
||||
{ dbMatchNumber: 15, fifaMatchNumber: 85, slot1: winner("B"), slot2: third(15, ["E", "F", "G", "I", "J"]) },
|
||||
{ dbMatchNumber: 16, fifaMatchNumber: 87, slot1: winner("K"), slot2: third(16, ["D", "E", "I", "J", "L"]) },
|
||||
];
|
||||
|
||||
/** The eight third-place slots (DB matchNumber → eligible source groups). */
|
||||
export const THIRD_PLACE_SLOTS: Array<{ thirdSlotId: number; eligibleGroups: string[] }> =
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) =>
|
||||
[m.slot1, m.slot2].filter((s): s is Extract<R32Slot, { kind: "third" }> => s.kind === "third")
|
||||
).map((s) => ({ thirdSlotId: s.thirdSlotId, eligibleGroups: s.eligibleGroups }));
|
||||
|
||||
/**
|
||||
* For each third-place slot, the group winner it faces — derived from the
|
||||
* template (a third slot always shares its match with a winner slot). This is
|
||||
* how the Annex C columns (keyed by group winner) map onto our DB slots.
|
||||
*/
|
||||
const WINNER_BY_THIRD_SLOT = new Map<number, string>(
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) => {
|
||||
const slots = [m.slot1, m.slot2];
|
||||
const thirdSlot = slots.find((s) => s.kind === "third");
|
||||
const winnerSlot = slots.find((s) => s.kind === "winner");
|
||||
return thirdSlot && winnerSlot
|
||||
? [[thirdSlot.thirdSlotId, winnerSlot.group] as [number, string]]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Assign each qualifying third-place group to its third-place slot, using FIFA's
|
||||
* exact published Annex C allocation (495-row lookup). The lookup value lists the
|
||||
* third-place group facing each group winner in the column order
|
||||
* [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L]; we map each onto the slot that faces that
|
||||
* winner.
|
||||
*
|
||||
* Returns a map of thirdSlotId (DB matchNumber) → group letter. Falls back to a
|
||||
* constrained matching only for non-standard inputs (e.g. fewer than 8 thirds in
|
||||
* degraded data) where no Annex C row exists.
|
||||
*/
|
||||
export function assignThirdPlaceSlots(qualifyingGroups: string[]): Map<number, string> {
|
||||
const groups = qualifyingGroups.map((g) => g.toUpperCase());
|
||||
const key = groups.toSorted().join("");
|
||||
const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key];
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
if (allocation && allocation.length === THIRD_PLACE_WINNER_COLUMNS.length) {
|
||||
THIRD_PLACE_WINNER_COLUMNS.forEach((winnerGroup, i) => {
|
||||
const thirdGroup = allocation[i];
|
||||
const slotId = [...WINNER_BY_THIRD_SLOT].find(([, w]) => w === winnerGroup)?.[0];
|
||||
if (slotId !== undefined) assignment.set(slotId, thirdGroup);
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
return assignThirdPlaceSlotsByMatching(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrained-matching fallback used only when no Annex C row applies (non-8
|
||||
* qualifying groups). A perfect, eligibility-respecting matching is found via
|
||||
* backtracking so the simulation still completes deterministically.
|
||||
*/
|
||||
function assignThirdPlaceSlotsByMatching(groups: string[]): Map<number, string> {
|
||||
const slots = THIRD_PLACE_SLOTS.map((s) => ({
|
||||
thirdSlotId: s.thirdSlotId,
|
||||
options: groups.filter((g) => s.eligibleGroups.includes(g)),
|
||||
})).toSorted((a, b) => a.options.length - b.options.length || a.thirdSlotId - b.thirdSlotId);
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
const used = new Set<string>();
|
||||
|
||||
const backtrack = (i: number): boolean => {
|
||||
if (i === slots.length) return true;
|
||||
for (const group of slots[i].options) {
|
||||
if (used.has(group)) continue;
|
||||
assignment.set(slots[i].thirdSlotId, group);
|
||||
used.add(group);
|
||||
if (backtrack(i + 1)) return true;
|
||||
used.delete(group);
|
||||
assignment.delete(slots[i].thirdSlotId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (backtrack(0)) return assignment;
|
||||
|
||||
const leftover = groups.filter((g) => !used.has(g));
|
||||
for (const s of THIRD_PLACE_SLOTS) {
|
||||
if (assignment.has(s.thirdSlotId)) continue;
|
||||
const g = leftover.shift();
|
||||
if (g) assignment.set(s.thirdSlotId, g);
|
||||
}
|
||||
return assignment;
|
||||
}
|
||||
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
|
||||
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
||||
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
|
||||
//
|
||||
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
|
||||
// Value: the qualifying third-place group facing each group winner, in the
|
||||
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
|
||||
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
|
||||
|
||||
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
|
||||
ABCDEFGH: "HGBCAFDE",
|
||||
ABCDEFGI: "CGBDAFEI",
|
||||
ABCDEFGJ: "CGBDAFEJ",
|
||||
ABCDEFGK: "CGBDAFEK",
|
||||
ABCDEFGL: "CGBDAFLE",
|
||||
ABCDEFHI: "HEBCAFDI",
|
||||
ABCDEFHJ: "HJBCAFDE",
|
||||
ABCDEFHK: "HEBCAFDK",
|
||||
ABCDEFHL: "HFBCADLE",
|
||||
ABCDEFIJ: "CJBDAFEI",
|
||||
ABCDEFIK: "CEBDAFIK",
|
||||
ABCDEFIL: "CEBDAFLI",
|
||||
ABCDEFJK: "CJBDAFEK",
|
||||
ABCDEFJL: "CJBDAFLE",
|
||||
ABCDEFKL: "CEBDAFLK",
|
||||
ABCDEGHI: "HGBCADEI",
|
||||
ABCDEGHJ: "HGBCADEJ",
|
||||
ABCDEGHK: "HGBCADEK",
|
||||
ABCDEGHL: "HGBCADLE",
|
||||
ABCDEGIJ: "EGBCADIJ",
|
||||
ABCDEGIK: "EGBCADIK",
|
||||
ABCDEGIL: "EGBCADLI",
|
||||
ABCDEGJK: "EGBCADJK",
|
||||
ABCDEGJL: "EGBCADLJ",
|
||||
ABCDEGKL: "EGBCADLK",
|
||||
ABCDEHIJ: "HJBCADEI",
|
||||
ABCDEHIK: "HEBCADIK",
|
||||
ABCDEHIL: "HEBCADLI",
|
||||
ABCDEHJK: "HJBCADEK",
|
||||
ABCDEHJL: "HJBCADLE",
|
||||
ABCDEHKL: "HEBCADLK",
|
||||
ABCDEIJK: "EJBCADIK",
|
||||
ABCDEIJL: "EJBCADLI",
|
||||
ABCDEIKL: "EIBCADLK",
|
||||
ABCDEJKL: "EJBCADLK",
|
||||
ABCDFGHI: "HGBCAFDI",
|
||||
ABCDFGHJ: "HGBCAFDJ",
|
||||
ABCDFGHK: "HGBCAFDK",
|
||||
ABCDFGHL: "CGBDAFLH",
|
||||
ABCDFGIJ: "CGBDAFIJ",
|
||||
ABCDFGIK: "CGBDAFIK",
|
||||
ABCDFGIL: "CGBDAFLI",
|
||||
ABCDFGJK: "CGBDAFJK",
|
||||
ABCDFGJL: "CGBDAFLJ",
|
||||
ABCDFGKL: "CGBDAFLK",
|
||||
ABCDFHIJ: "HJBCAFDI",
|
||||
ABCDFHIK: "HFBCADIK",
|
||||
ABCDFHIL: "HFBCADLI",
|
||||
ABCDFHJK: "HJBCAFDK",
|
||||
ABCDFHJL: "CJBDAFLH",
|
||||
ABCDFHKL: "HFBCADLK",
|
||||
ABCDFIJK: "CJBDAFIK",
|
||||
ABCDFIJL: "CJBDAFLI",
|
||||
ABCDFIKL: "CIBDAFLK",
|
||||
ABCDFJKL: "CJBDAFLK",
|
||||
ABCDGHIJ: "HGBCADIJ",
|
||||
ABCDGHIK: "HGBCADIK",
|
||||
ABCDGHIL: "HGBCADLI",
|
||||
ABCDGHJK: "HGBCADJK",
|
||||
ABCDGHJL: "HGBCADLJ",
|
||||
ABCDGHKL: "HGBCADLK",
|
||||
ABCDGIJK: "CJBDAGIK",
|
||||
ABCDGIJL: "CJBDAGLI",
|
||||
ABCDGIKL: "IGBCADLK",
|
||||
ABCDGJKL: "CJBDAGLK",
|
||||
ABCDHIJK: "HJBCADIK",
|
||||
ABCDHIJL: "HJBCADLI",
|
||||
ABCDHIKL: "HIBCADLK",
|
||||
ABCDHJKL: "HJBCADLK",
|
||||
ABCDIJKL: "IJBCADLK",
|
||||
ABCEFGHI: "HGBCAFEI",
|
||||
ABCEFGHJ: "HGBCAFEJ",
|
||||
ABCEFGHK: "HGBCAFEK",
|
||||
ABCEFGHL: "HGBCAFLE",
|
||||
ABCEFGIJ: "EGBCAFIJ",
|
||||
ABCEFGIK: "EGBCAFIK",
|
||||
ABCEFGIL: "EGBCAFLI",
|
||||
ABCEFGJK: "EGBCAFJK",
|
||||
ABCEFGJL: "EGBCAFLJ",
|
||||
ABCEFGKL: "EGBCAFLK",
|
||||
ABCEFHIJ: "HJBCAFEI",
|
||||
ABCEFHIK: "HEBCAFIK",
|
||||
ABCEFHIL: "HEBCAFLI",
|
||||
ABCEFHJK: "HJBCAFEK",
|
||||
ABCEFHJL: "HJBCAFLE",
|
||||
ABCEFHKL: "HEBCAFLK",
|
||||
ABCEFIJK: "EJBCAFIK",
|
||||
ABCEFIJL: "EJBCAFLI",
|
||||
ABCEFIKL: "EIBCAFLK",
|
||||
ABCEFJKL: "EJBCAFLK",
|
||||
ABCEGHIJ: "HJBCAGEI",
|
||||
ABCEGHIK: "EGBCAHIK",
|
||||
ABCEGHIL: "EGBCAHLI",
|
||||
ABCEGHJK: "HJBCAGEK",
|
||||
ABCEGHJL: "HJBCAGLE",
|
||||
ABCEGHKL: "EGBCAHLK",
|
||||
ABCEGIJK: "EJBCAGIK",
|
||||
ABCEGIJL: "EJBCAGLI",
|
||||
ABCEGIKL: "EGBAICLK",
|
||||
ABCEGJKL: "EJBCAGLK",
|
||||
ABCEHIJK: "EJBCAHIK",
|
||||
ABCEHIJL: "EJBCAHLI",
|
||||
ABCEHIKL: "EIBCAHLK",
|
||||
ABCEHJKL: "EJBCAHLK",
|
||||
ABCEIJKL: "EJBAICLK",
|
||||
ABCFGHIJ: "HGBCAFIJ",
|
||||
ABCFGHIK: "HGBCAFIK",
|
||||
ABCFGHIL: "HGBCAFLI",
|
||||
ABCFGHJK: "HGBCAFJK",
|
||||
ABCFGHJL: "HGBCAFLJ",
|
||||
ABCFGHKL: "HGBCAFLK",
|
||||
ABCFGIJK: "CJBFAGIK",
|
||||
ABCFGIJL: "CJBFAGLI",
|
||||
ABCFGIKL: "IGBCAFLK",
|
||||
ABCFGJKL: "CJBFAGLK",
|
||||
ABCFHIJK: "HJBCAFIK",
|
||||
ABCFHIJL: "HJBCAFLI",
|
||||
ABCFHIKL: "HIBCAFLK",
|
||||
ABCFHJKL: "HJBCAFLK",
|
||||
ABCFIJKL: "IJBCAFLK",
|
||||
ABCGHIJK: "HJBCAGIK",
|
||||
ABCGHIJL: "HJBCAGLI",
|
||||
ABCGHIKL: "IGBCAHLK",
|
||||
ABCGHJKL: "HJBCAGLK",
|
||||
ABCGIJKL: "IJBCAGLK",
|
||||
ABCHIJKL: "IJBCAHLK",
|
||||
ABDEFGHI: "HGBDAFEI",
|
||||
ABDEFGHJ: "HGBDAFEJ",
|
||||
ABDEFGHK: "HGBDAFEK",
|
||||
ABDEFGHL: "HGBDAFLE",
|
||||
ABDEFGIJ: "EGBDAFIJ",
|
||||
ABDEFGIK: "EGBDAFIK",
|
||||
ABDEFGIL: "EGBDAFLI",
|
||||
ABDEFGJK: "EGBDAFJK",
|
||||
ABDEFGJL: "EGBDAFLJ",
|
||||
ABDEFGKL: "EGBDAFLK",
|
||||
ABDEFHIJ: "HJBDAFEI",
|
||||
ABDEFHIK: "HEBDAFIK",
|
||||
ABDEFHIL: "HEBDAFLI",
|
||||
ABDEFHJK: "HJBDAFEK",
|
||||
ABDEFHJL: "HJBDAFLE",
|
||||
ABDEFHKL: "HEBDAFLK",
|
||||
ABDEFIJK: "EJBDAFIK",
|
||||
ABDEFIJL: "EJBDAFLI",
|
||||
ABDEFIKL: "EIBDAFLK",
|
||||
ABDEFJKL: "EJBDAFLK",
|
||||
ABDEGHIJ: "HJBDAGEI",
|
||||
ABDEGHIK: "EGBDAHIK",
|
||||
ABDEGHIL: "EGBDAHLI",
|
||||
ABDEGHJK: "HJBDAGEK",
|
||||
ABDEGHJL: "HJBDAGLE",
|
||||
ABDEGHKL: "EGBDAHLK",
|
||||
ABDEGIJK: "EJBDAGIK",
|
||||
ABDEGIJL: "EJBDAGLI",
|
||||
ABDEGIKL: "EGBAIDLK",
|
||||
ABDEGJKL: "EJBDAGLK",
|
||||
ABDEHIJK: "EJBDAHIK",
|
||||
ABDEHIJL: "EJBDAHLI",
|
||||
ABDEHIKL: "EIBDAHLK",
|
||||
ABDEHJKL: "EJBDAHLK",
|
||||
ABDEIJKL: "EJBAIDLK",
|
||||
ABDFGHIJ: "HGBDAFIJ",
|
||||
ABDFGHIK: "HGBDAFIK",
|
||||
ABDFGHIL: "HGBDAFLI",
|
||||
ABDFGHJK: "HGBDAFJK",
|
||||
ABDFGHJL: "HGBDAFLJ",
|
||||
ABDFGHKL: "HGBDAFLK",
|
||||
ABDFGIJK: "FJBDAGIK",
|
||||
ABDFGIJL: "FJBDAGLI",
|
||||
ABDFGIKL: "IGBDAFLK",
|
||||
ABDFGJKL: "FJBDAGLK",
|
||||
ABDFHIJK: "HJBDAFIK",
|
||||
ABDFHIJL: "HJBDAFLI",
|
||||
ABDFHIKL: "HIBDAFLK",
|
||||
ABDFHJKL: "HJBDAFLK",
|
||||
ABDFIJKL: "IJBDAFLK",
|
||||
ABDGHIJK: "HJBDAGIK",
|
||||
ABDGHIJL: "HJBDAGLI",
|
||||
ABDGHIKL: "IGBDAHLK",
|
||||
ABDGHJKL: "HJBDAGLK",
|
||||
ABDGIJKL: "IJBDAGLK",
|
||||
ABDHIJKL: "IJBDAHLK",
|
||||
ABEFGHIJ: "HJBFAGEI",
|
||||
ABEFGHIK: "EGBFAHIK",
|
||||
ABEFGHIL: "EGBFAHLI",
|
||||
ABEFGHJK: "HJBFAGEK",
|
||||
ABEFGHJL: "HJBFAGLE",
|
||||
ABEFGHKL: "EGBFAHLK",
|
||||
ABEFGIJK: "EJBFAGIK",
|
||||
ABEFGIJL: "EJBFAGLI",
|
||||
ABEFGIKL: "EGBAIFLK",
|
||||
ABEFGJKL: "EJBFAGLK",
|
||||
ABEFHIJK: "EJBFAHIK",
|
||||
ABEFHIJL: "EJBFAHLI",
|
||||
ABEFHIKL: "EIBFAHLK",
|
||||
ABEFHJKL: "EJBFAHLK",
|
||||
ABEFIJKL: "EJBAIFLK",
|
||||
ABEGHIJK: "EJBAHGIK",
|
||||
ABEGHIJL: "EJBAHGLI",
|
||||
ABEGHIKL: "EGBAIHLK",
|
||||
ABEGHJKL: "EJBAHGLK",
|
||||
ABEGIJKL: "EJBAIGLK",
|
||||
ABEHIJKL: "EJBAIHLK",
|
||||
ABFGHIJK: "HJBFAGIK",
|
||||
ABFGHIJL: "HJBFAGLI",
|
||||
ABFGHIKL: "HGBAIFLK",
|
||||
ABFGHJKL: "HJBFAGLK",
|
||||
ABFGIJKL: "IJBFAGLK",
|
||||
ABFHIJKL: "HJBAIFLK",
|
||||
ABGHIJKL: "HJBAIGLK",
|
||||
ACDEFGHI: "HGECAFDI",
|
||||
ACDEFGHJ: "HGJCAFDE",
|
||||
ACDEFGHK: "HGECAFDK",
|
||||
ACDEFGHL: "HGFCADLE",
|
||||
ACDEFGIJ: "CGJDAFEI",
|
||||
ACDEFGIK: "CGEDAFIK",
|
||||
ACDEFGIL: "CGEDAFLI",
|
||||
ACDEFGJK: "CGJDAFEK",
|
||||
ACDEFGJL: "CGJDAFLE",
|
||||
ACDEFGKL: "CGEDAFLK",
|
||||
ACDEFHIJ: "HJECAFDI",
|
||||
ACDEFHIK: "HEFCADIK",
|
||||
ACDEFHIL: "HEFCADLI",
|
||||
ACDEFHJK: "HJECAFDK",
|
||||
ACDEFHJL: "HJFCADLE",
|
||||
ACDEFHKL: "HEFCADLK",
|
||||
ACDEFIJK: "CJEDAFIK",
|
||||
ACDEFIJL: "CJEDAFLI",
|
||||
ACDEFIKL: "CEIDAFLK",
|
||||
ACDEFJKL: "CJEDAFLK",
|
||||
ACDEGHIJ: "HGJCADEI",
|
||||
ACDEGHIK: "HGECADIK",
|
||||
ACDEGHIL: "HGECADLI",
|
||||
ACDEGHJK: "HGJCADEK",
|
||||
ACDEGHJL: "HGJCADLE",
|
||||
ACDEGHKL: "HGECADLK",
|
||||
ACDEGIJK: "EGJCADIK",
|
||||
ACDEGIJL: "EGJCADLI",
|
||||
ACDEGIKL: "EGICADLK",
|
||||
ACDEGJKL: "EGJCADLK",
|
||||
ACDEHIJK: "HJECADIK",
|
||||
ACDEHIJL: "HJECADLI",
|
||||
ACDEHIKL: "HEICADLK",
|
||||
ACDEHJKL: "HJECADLK",
|
||||
ACDEIJKL: "EJICADLK",
|
||||
ACDFGHIJ: "HGJCAFDI",
|
||||
ACDFGHIK: "HGFCADIK",
|
||||
ACDFGHIL: "HGFCADLI",
|
||||
ACDFGHJK: "HGJCAFDK",
|
||||
ACDFGHJL: "CGJDAFLH",
|
||||
ACDFGHKL: "HGFCADLK",
|
||||
ACDFGIJK: "CGJDAFIK",
|
||||
ACDFGIJL: "CGJDAFLI",
|
||||
ACDFGIKL: "CGIDAFLK",
|
||||
ACDFGJKL: "CGJDAFLK",
|
||||
ACDFHIJK: "HJFCADIK",
|
||||
ACDFHIJL: "HJFCADLI",
|
||||
ACDFHIKL: "HFICADLK",
|
||||
ACDFHJKL: "HJFCADLK",
|
||||
ACDFIJKL: "CJIDAFLK",
|
||||
ACDGHIJK: "HGJCADIK",
|
||||
ACDGHIJL: "HGJCADLI",
|
||||
ACDGHIKL: "HGICADLK",
|
||||
ACDGHJKL: "HGJCADLK",
|
||||
ACDGIJKL: "IGJCADLK",
|
||||
ACDHIJKL: "HJICADLK",
|
||||
ACEFGHIJ: "HGJCAFEI",
|
||||
ACEFGHIK: "HGECAFIK",
|
||||
ACEFGHIL: "HGECAFLI",
|
||||
ACEFGHJK: "HGJCAFEK",
|
||||
ACEFGHJL: "HGJCAFLE",
|
||||
ACEFGHKL: "HGECAFLK",
|
||||
ACEFGIJK: "EGJCAFIK",
|
||||
ACEFGIJL: "EGJCAFLI",
|
||||
ACEFGIKL: "EGICAFLK",
|
||||
ACEFGJKL: "EGJCAFLK",
|
||||
ACEFHIJK: "HJECAFIK",
|
||||
ACEFHIJL: "HJECAFLI",
|
||||
ACEFHIKL: "HEICAFLK",
|
||||
ACEFHJKL: "HJECAFLK",
|
||||
ACEFIJKL: "EJICAFLK",
|
||||
ACEGHIJK: "EGJCAHIK",
|
||||
ACEGHIJL: "EGJCAHLI",
|
||||
ACEGHIKL: "EGICAHLK",
|
||||
ACEGHJKL: "EGJCAHLK",
|
||||
ACEGIJKL: "EJICAGLK",
|
||||
ACEHIJKL: "EJICAHLK",
|
||||
ACFGHIJK: "HGJCAFIK",
|
||||
ACFGHIJL: "HGJCAFLI",
|
||||
ACFGHIKL: "HGICAFLK",
|
||||
ACFGHJKL: "HGJCAFLK",
|
||||
ACFGIJKL: "IGJCAFLK",
|
||||
ACFHIJKL: "HJICAFLK",
|
||||
ACGHIJKL: "HJICAGLK",
|
||||
ADEFGHIJ: "HGJDAFEI",
|
||||
ADEFGHIK: "HGEDAFIK",
|
||||
ADEFGHIL: "HGEDAFLI",
|
||||
ADEFGHJK: "HGJDAFEK",
|
||||
ADEFGHJL: "HGJDAFLE",
|
||||
ADEFGHKL: "HGEDAFLK",
|
||||
ADEFGIJK: "EGJDAFIK",
|
||||
ADEFGIJL: "EGJDAFLI",
|
||||
ADEFGIKL: "EGIDAFLK",
|
||||
ADEFGJKL: "EGJDAFLK",
|
||||
ADEFHIJK: "HJEDAFIK",
|
||||
ADEFHIJL: "HJEDAFLI",
|
||||
ADEFHIKL: "HEIDAFLK",
|
||||
ADEFHJKL: "HJEDAFLK",
|
||||
ADEFIJKL: "EJIDAFLK",
|
||||
ADEGHIJK: "EGJDAHIK",
|
||||
ADEGHIJL: "EGJDAHLI",
|
||||
ADEGHIKL: "EGIDAHLK",
|
||||
ADEGHJKL: "EGJDAHLK",
|
||||
ADEGIJKL: "EJIDAGLK",
|
||||
ADEHIJKL: "EJIDAHLK",
|
||||
ADFGHIJK: "HGJDAFIK",
|
||||
ADFGHIJL: "HGJDAFLI",
|
||||
ADFGHIKL: "HGIDAFLK",
|
||||
ADFGHJKL: "HGJDAFLK",
|
||||
ADFGIJKL: "IGJDAFLK",
|
||||
ADFHIJKL: "HJIDAFLK",
|
||||
ADGHIJKL: "HJIDAGLK",
|
||||
AEFGHIJK: "EGJFAHIK",
|
||||
AEFGHIJL: "EGJFAHLI",
|
||||
AEFGHIKL: "EGIFAHLK",
|
||||
AEFGHJKL: "EGJFAHLK",
|
||||
AEFGIJKL: "EJIFAGLK",
|
||||
AEFHIJKL: "EJIFAHLK",
|
||||
AEGHIJKL: "EJIAHGLK",
|
||||
AFGHIJKL: "HJIFAGLK",
|
||||
BCDEFGHI: "CGBDHFEI",
|
||||
BCDEFGHJ: "HGBCJFDE",
|
||||
BCDEFGHK: "CGBDHFEK",
|
||||
BCDEFGHL: "CGBDHFLE",
|
||||
BCDEFGIJ: "CGBDJFEI",
|
||||
BCDEFGIK: "CGBDEFIK",
|
||||
BCDEFGIL: "CGBDEFLI",
|
||||
BCDEFGJK: "CGBDJFEK",
|
||||
BCDEFGJL: "CGBDJFLE",
|
||||
BCDEFGKL: "CGBDEFLK",
|
||||
BCDEFHIJ: "CJBDHFEI",
|
||||
BCDEFHIK: "CEBDHFIK",
|
||||
BCDEFHIL: "CEBDHFLI",
|
||||
BCDEFHJK: "CJBDHFEK",
|
||||
BCDEFHJL: "CJBDHFLE",
|
||||
BCDEFHKL: "CEBDHFLK",
|
||||
BCDEFIJK: "CJBDEFIK",
|
||||
BCDEFIJL: "CJBDEFLI",
|
||||
BCDEFIKL: "CEBDIFLK",
|
||||
BCDEFJKL: "CJBDEFLK",
|
||||
BCDEGHIJ: "HGBCJDEI",
|
||||
BCDEGHIK: "EGBCHDIK",
|
||||
BCDEGHIL: "EGBCHDLI",
|
||||
BCDEGHJK: "HGBCJDEK",
|
||||
BCDEGHJL: "HGBCJDLE",
|
||||
BCDEGHKL: "EGBCHDLK",
|
||||
BCDEGIJK: "EGBCJDIK",
|
||||
BCDEGIJL: "EGBCJDLI",
|
||||
BCDEGIKL: "EGBCIDLK",
|
||||
BCDEGJKL: "EGBCJDLK",
|
||||
BCDEHIJK: "EJBCHDIK",
|
||||
BCDEHIJL: "EJBCHDLI",
|
||||
BCDEHIKL: "EIBCHDLK",
|
||||
BCDEHJKL: "EJBCHDLK",
|
||||
BCDEIJKL: "EJBCIDLK",
|
||||
BCDFGHIJ: "HGBCJFDI",
|
||||
BCDFGHIK: "CGBDHFIK",
|
||||
BCDFGHIL: "CGBDHFLI",
|
||||
BCDFGHJK: "HGBCJFDK",
|
||||
BCDFGHJL: "CGBDHFLJ",
|
||||
BCDFGHKL: "CGBDHFLK",
|
||||
BCDFGIJK: "CGBDJFIK",
|
||||
BCDFGIJL: "CGBDJFLI",
|
||||
BCDFGIKL: "CGBDIFLK",
|
||||
BCDFGJKL: "CGBDJFLK",
|
||||
BCDFHIJK: "CJBDHFIK",
|
||||
BCDFHIJL: "CJBDHFLI",
|
||||
BCDFHIKL: "CIBDHFLK",
|
||||
BCDFHJKL: "CJBDHFLK",
|
||||
BCDFIJKL: "CJBDIFLK",
|
||||
BCDGHIJK: "HGBCJDIK",
|
||||
BCDGHIJL: "HGBCJDLI",
|
||||
BCDGHIKL: "HGBCIDLK",
|
||||
BCDGHJKL: "HGBCJDLK",
|
||||
BCDGIJKL: "IGBCJDLK",
|
||||
BCDHIJKL: "HJBCIDLK",
|
||||
BCEFGHIJ: "HGBCJFEI",
|
||||
BCEFGHIK: "EGBCHFIK",
|
||||
BCEFGHIL: "EGBCHFLI",
|
||||
BCEFGHJK: "HGBCJFEK",
|
||||
BCEFGHJL: "HGBCJFLE",
|
||||
BCEFGHKL: "EGBCHFLK",
|
||||
BCEFGIJK: "EGBCJFIK",
|
||||
BCEFGIJL: "EGBCJFLI",
|
||||
BCEFGIKL: "EGBCIFLK",
|
||||
BCEFGJKL: "EGBCJFLK",
|
||||
BCEFHIJK: "EJBCHFIK",
|
||||
BCEFHIJL: "EJBCHFLI",
|
||||
BCEFHIKL: "EIBCHFLK",
|
||||
BCEFHJKL: "EJBCHFLK",
|
||||
BCEFIJKL: "EJBCIFLK",
|
||||
BCEGHIJK: "EJBCHGIK",
|
||||
BCEGHIJL: "EJBCHGLI",
|
||||
BCEGHIKL: "EGBCIHLK",
|
||||
BCEGHJKL: "EJBCHGLK",
|
||||
BCEGIJKL: "EJBCIGLK",
|
||||
BCEHIJKL: "EJBCIHLK",
|
||||
BCFGHIJK: "HGBCJFIK",
|
||||
BCFGHIJL: "HGBCJFLI",
|
||||
BCFGHIKL: "HGBCIFLK",
|
||||
BCFGHJKL: "HGBCJFLK",
|
||||
BCFGIJKL: "IGBCJFLK",
|
||||
BCFHIJKL: "HJBCIFLK",
|
||||
BCGHIJKL: "HJBCIGLK",
|
||||
BDEFGHIJ: "HGBDJFEI",
|
||||
BDEFGHIK: "EGBDHFIK",
|
||||
BDEFGHIL: "EGBDHFLI",
|
||||
BDEFGHJK: "HGBDJFEK",
|
||||
BDEFGHJL: "HGBDJFLE",
|
||||
BDEFGHKL: "EGBDHFLK",
|
||||
BDEFGIJK: "EGBDJFIK",
|
||||
BDEFGIJL: "EGBDJFLI",
|
||||
BDEFGIKL: "EGBDIFLK",
|
||||
BDEFGJKL: "EGBDJFLK",
|
||||
BDEFHIJK: "EJBDHFIK",
|
||||
BDEFHIJL: "EJBDHFLI",
|
||||
BDEFHIKL: "EIBDHFLK",
|
||||
BDEFHJKL: "EJBDHFLK",
|
||||
BDEFIJKL: "EJBDIFLK",
|
||||
BDEGHIJK: "EJBDHGIK",
|
||||
BDEGHIJL: "EJBDHGLI",
|
||||
BDEGHIKL: "EGBDIHLK",
|
||||
BDEGHJKL: "EJBDHGLK",
|
||||
BDEGIJKL: "EJBDIGLK",
|
||||
BDEHIJKL: "EJBDIHLK",
|
||||
BDFGHIJK: "HGBDJFIK",
|
||||
BDFGHIJL: "HGBDJFLI",
|
||||
BDFGHIKL: "HGBDIFLK",
|
||||
BDFGHJKL: "HGBDJFLK",
|
||||
BDFGIJKL: "IGBDJFLK",
|
||||
BDFHIJKL: "HJBDIFLK",
|
||||
BDGHIJKL: "HJBDIGLK",
|
||||
BEFGHIJK: "EJBFHGIK",
|
||||
BEFGHIJL: "EJBFHGLI",
|
||||
BEFGHIKL: "EGBFIHLK",
|
||||
BEFGHJKL: "EJBFHGLK",
|
||||
BEFGIJKL: "EJBFIGLK",
|
||||
BEFHIJKL: "EJBFIHLK",
|
||||
BEGHIJKL: "EJIBHGLK",
|
||||
BFGHIJKL: "HJBFIGLK",
|
||||
CDEFGHIJ: "CGJDHFEI",
|
||||
CDEFGHIK: "CGEDHFIK",
|
||||
CDEFGHIL: "CGEDHFLI",
|
||||
CDEFGHJK: "CGJDHFEK",
|
||||
CDEFGHJL: "CGJDHFLE",
|
||||
CDEFGHKL: "CGEDHFLK",
|
||||
CDEFGIJK: "CGEDJFIK",
|
||||
CDEFGIJL: "CGEDJFLI",
|
||||
CDEFGIKL: "CGEDIFLK",
|
||||
CDEFGJKL: "CGEDJFLK",
|
||||
CDEFHIJK: "CJEDHFIK",
|
||||
CDEFHIJL: "CJEDHFLI",
|
||||
CDEFHIKL: "CEIDHFLK",
|
||||
CDEFHJKL: "CJEDHFLK",
|
||||
CDEFIJKL: "CJEDIFLK",
|
||||
CDEGHIJK: "EGJCHDIK",
|
||||
CDEGHIJL: "EGJCHDLI",
|
||||
CDEGHIKL: "EGICHDLK",
|
||||
CDEGHJKL: "EGJCHDLK",
|
||||
CDEGIJKL: "EGICJDLK",
|
||||
CDEHIJKL: "EJICHDLK",
|
||||
CDFGHIJK: "CGJDHFIK",
|
||||
CDFGHIJL: "CGJDHFLI",
|
||||
CDFGHIKL: "CGIDHFLK",
|
||||
CDFGHJKL: "CGJDHFLK",
|
||||
CDFGIJKL: "CGIDJFLK",
|
||||
CDFHIJKL: "CJIDHFLK",
|
||||
CDGHIJKL: "HGICJDLK",
|
||||
CEFGHIJK: "EGJCHFIK",
|
||||
CEFGHIJL: "EGJCHFLI",
|
||||
CEFGHIKL: "EGICHFLK",
|
||||
CEFGHJKL: "EGJCHFLK",
|
||||
CEFGIJKL: "EGICJFLK",
|
||||
CEFHIJKL: "EJICHFLK",
|
||||
CEGHIJKL: "EJICHGLK",
|
||||
CFGHIJKL: "HGICJFLK",
|
||||
DEFGHIJK: "EGJDHFIK",
|
||||
DEFGHIJL: "EGJDHFLI",
|
||||
DEFGHIKL: "EGIDHFLK",
|
||||
DEFGHJKL: "EGJDHFLK",
|
||||
DEFGIJKL: "EGIDJFLK",
|
||||
DEFHIJKL: "EJIDHFLK",
|
||||
DEGHIJKL: "EJIDHGLK",
|
||||
DFGHIJKL: "HGIDJFLK",
|
||||
EFGHIJKL: "EJIFHGLK",
|
||||
};
|
||||
|
|
@ -78,6 +78,30 @@ function makeParticipants(count = 48) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** 12 canonical groups A–L, 4 members each (p0..p47), no completed matches. */
|
||||
function makeCanonicalGroups() {
|
||||
return Array.from({ length: 12 }, (_, gi) => ({
|
||||
id: `group-${gi}`,
|
||||
groupName: String.fromCharCode(65 + gi), // A..L
|
||||
scoringEventId: "event-1",
|
||||
members: [0, 1, 2, 3].map((s) => ({ participantId: `p${gi * 4 + s}` })),
|
||||
matches: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** 16 Round-of-32 matches with both slots filled (p0..p31), seq pairing. */
|
||||
function makeFullR32Draw() {
|
||||
return Array.from({ length: 16 }, (_, i) => ({
|
||||
round: "Round of 32",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: `p${i * 2}`,
|
||||
participant2Id: `p${i * 2 + 1}`,
|
||||
winnerId: null as string | null,
|
||||
loserId: null as string | null,
|
||||
isComplete: false,
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
|
@ -87,7 +111,7 @@ import { database } from "~/database/context";
|
|||
const mockDb = {
|
||||
query: {
|
||||
seasonParticipants: { findMany: vi.fn() },
|
||||
scoringEvents: { findFirst: vi.fn() },
|
||||
scoringEvents: { findMany: vi.fn() },
|
||||
tournamentGroups: { findMany: vi.fn() },
|
||||
playoffMatches: { findMany: vi.fn() },
|
||||
},
|
||||
|
|
@ -96,10 +120,15 @@ const mockDb = {
|
|||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
/** Set the resolved bracket scoring event (or null for none). */
|
||||
function mockBracketEvent(event: Record<string, unknown> | null) {
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue(event ? [event] : []);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
mockDb.query.seasonParticipants.findMany.mockReset();
|
||||
mockDb.query.scoringEvents.findFirst.mockReset();
|
||||
mockDb.query.scoringEvents.findMany.mockReset();
|
||||
mockDb.query.tournamentGroups.findMany.mockReset();
|
||||
mockDb.query.playoffMatches.findMany.mockReset();
|
||||
mockDb.select.mockReturnValue(mockDb);
|
||||
|
|
@ -110,7 +139,7 @@ beforeEach(() => {
|
|||
describe("WorldCupSimulator", () => {
|
||||
it("throws when no participants are found", async () => {
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -121,7 +150,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("returns one result per participant", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -137,7 +166,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -158,7 +187,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -180,7 +209,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("a team with pre-completed group stage result is fixed in simulation", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
|
||||
mockBracketEvent({ id: "event-1" });
|
||||
|
||||
// One group fully complete: p0 wins everything, p3 loses everything
|
||||
const group = {
|
||||
|
|
@ -234,7 +263,7 @@ describe("WorldCupSimulator", () => {
|
|||
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
|
|
@ -248,4 +277,116 @@ describe("WorldCupSimulator", () => {
|
|||
expect(probSeventh).toBeCloseTo(probEighth, 10);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Regime selection & bracket honoring ──────────────────────────────────
|
||||
|
||||
it("regime=draw: honors the real R32 draw and flags source as real bracket", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); // no groups, but draw exists
|
||||
|
||||
const r32 = makeFullR32Draw();
|
||||
// Lock R32 match 1: p0 beats p1, so p1 is eliminated in the Round of 32.
|
||||
r32[0] = { ...r32[0], winnerId: "p0", loserId: "p1", isComplete: true };
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
|
||||
|
||||
const sim = new WorldCupSimulator(50);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("real bracket");
|
||||
|
||||
// p1 lost in the R32 → never earns any placement (scoring starts at QF).
|
||||
const p1 = results.find((r) => r.participantId === "p1")?.probabilities;
|
||||
const p1Mass =
|
||||
(p1?.probFirst ?? 0) + (p1?.probSecond ?? 0) + (p1?.probThird ?? 0) + (p1?.probFourth ?? 0) +
|
||||
(p1?.probFifth ?? 0) + (p1?.probSixth ?? 0) + (p1?.probSeventh ?? 0) + (p1?.probEighth ?? 0);
|
||||
expect(p1Mass).toBe(0);
|
||||
|
||||
// Teams p32..p47 are not in the 32-team draw → also zero.
|
||||
const p40 = results.find((r) => r.participantId === "p40")?.probabilities;
|
||||
expect((p40?.probFirst ?? 0) + (p40?.probFifth ?? 0)).toBe(0);
|
||||
|
||||
// Champion mass still normalizes to ~1.
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("partial R32 draw never places an already-drawn team into an empty slot twice", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
// Real, canonical groups so the empty slots get filled from group seeding.
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
|
||||
|
||||
// Partial draw: only match 1 is populated (p0 vs p1); matches 2–16 are empty,
|
||||
// so the seeded group results fill them — but must not re-place p0 or p1.
|
||||
const r32 = makeFullR32Draw().map((m, i) =>
|
||||
i === 0 ? m : { ...m, participant1Id: null, loserId: null, participant2Id: null }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
|
||||
|
||||
const sim = new WorldCupSimulator(40);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
// A team can finish at most one placement per sim, so its total probability
|
||||
// mass is ≤ 1. A duplicated team could exceed that (e.g. champion + QF-loser
|
||||
// in the same iteration). Assert no team's mass exceeds 1.
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
const mass =
|
||||
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||||
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
|
||||
expect(mass).toBeLessThanOrEqual(1 + 1e-9);
|
||||
}
|
||||
// Champion mass still normalizes to ~1 (exactly one champion per sim).
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("completed Final locks in the champion and runner-up", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([
|
||||
{ round: "Finals", matchNumber: 1, participant1Id: "p5", participant2Id: "p6", winnerId: "p5", loserId: "p6", isComplete: true },
|
||||
]);
|
||||
|
||||
const sim = new WorldCupSimulator(30);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results.find((r) => r.participantId === "p5")?.probabilities.probFirst).toBe(1);
|
||||
expect(results.find((r) => r.participantId === "p6")?.probabilities.probSecond).toBe(1);
|
||||
});
|
||||
|
||||
it("regime=groups: real 12-group stage uses the 2026 bracket seeding source", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]); // no draw yet
|
||||
|
||||
const sim = new WorldCupSimulator(40);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("2026 bracket seeding");
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
|
||||
it("regime=futures: no groups and no draw falls back with a flagged source", async () => {
|
||||
const participants = makeParticipants(48);
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
|
||||
mockBracketEvent(null);
|
||||
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
const sim = new WorldCupSimulator(30);
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results[0].source).toContain("futures fallback");
|
||||
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(sumFirst).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import { convertFuturesToElo, eloWinProbability } from "~/services/probability-e
|
|||
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { FIFA_48 } from "~/lib/bracket-templates";
|
||||
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
|
||||
|
|
@ -282,6 +284,159 @@ function simKnockout(
|
|||
return { winner, loser: winner === teamA ? teamB : teamA };
|
||||
}
|
||||
|
||||
/** A knockout matchup; either slot may be empty (TBD) before it's drawn. */
|
||||
interface KnockoutMatch {
|
||||
p1: string | null;
|
||||
p2: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered knockout round names derived from the template's `feedsInto` chain,
|
||||
* starting at `Round of 32`. Keeping this template-driven avoids hardcoded
|
||||
* round-name strings drifting from the bracket definition.
|
||||
*/
|
||||
function knockoutRoundChain(startName: string): string[] {
|
||||
const names: string[] = [];
|
||||
let current = FIFA_48.rounds.find((r) => r.name === startName);
|
||||
while (current) {
|
||||
names.push(current.name);
|
||||
const feedsInto = current.feedsInto;
|
||||
current = feedsInto ? FIFA_48.rounds.find((r) => r.name === feedsInto) : undefined;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one knockout round keyed by matchNumber and advance winners into the next
|
||||
* round using the canonical tree rule (`nextMatchNumber = ceil(n/2)`, odd → p1,
|
||||
* even → p2) — identical to `advanceWinnerTemplate` so simulated paths match the
|
||||
* bracket the admin tooling produces. Completed matches (from `completed`) lock
|
||||
* in their real winner/loser; a half-filled match (a bye / partial draw)
|
||||
* advances the present team.
|
||||
*/
|
||||
function runKnockoutRoundByNumber(
|
||||
roundName: string,
|
||||
matches: Map<number, KnockoutMatch>,
|
||||
completed: Map<string, { winnerId: string; loserId: string }>,
|
||||
eloFn: (id: string) => number,
|
||||
normalizedProb: (id: string) => number
|
||||
): {
|
||||
winnersByNumber: Map<number, string>;
|
||||
losersByNumber: Map<number, string>;
|
||||
nextRound: Map<number, KnockoutMatch>;
|
||||
} {
|
||||
const winnersByNumber = new Map<number, string>();
|
||||
const losersByNumber = new Map<number, string>();
|
||||
const nextRound = new Map<number, KnockoutMatch>();
|
||||
|
||||
const placeIntoNext = (matchNumber: number, teamId: string): void => {
|
||||
const nextNumber = Math.ceil(matchNumber / 2);
|
||||
const existing = nextRound.get(nextNumber) ?? { p1: null, p2: null };
|
||||
if (matchNumber % 2 === 1) existing.p1 = teamId;
|
||||
else existing.p2 = teamId;
|
||||
nextRound.set(nextNumber, existing);
|
||||
};
|
||||
|
||||
for (const matchNumber of [...matches.keys()].toSorted((a, b) => a - b)) {
|
||||
const m = matches.get(matchNumber);
|
||||
if (!m) continue;
|
||||
let winnerId: string | null = null;
|
||||
let loserId: string | null = null;
|
||||
|
||||
const fixed = completed.get(`${roundName}:${matchNumber}`);
|
||||
if (fixed) {
|
||||
winnerId = fixed.winnerId;
|
||||
loserId = fixed.loserId;
|
||||
} else if (m.p1 && m.p2) {
|
||||
const result = simKnockout(m.p1, m.p2, eloFn, normalizedProb);
|
||||
winnerId = result.winner;
|
||||
loserId = result.loser;
|
||||
} else if (m.p1 || m.p2) {
|
||||
winnerId = m.p1 ?? m.p2; // bye / partially-drawn match
|
||||
} else {
|
||||
continue; // empty match — nothing to advance
|
||||
}
|
||||
|
||||
if (winnerId) {
|
||||
winnersByNumber.set(matchNumber, winnerId);
|
||||
placeIntoNext(matchNumber, winnerId);
|
||||
}
|
||||
if (loserId) losersByNumber.set(matchNumber, loserId);
|
||||
}
|
||||
|
||||
return { winnersByNumber, losersByNumber, nextRound };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the Round of 32 from simulated group results using the official 2026
|
||||
* group-position template. `groupResults` maps group letter → finishing order
|
||||
* (index 0 = winner, 1 = runner-up, 2 = third). `qualifyingThirdGroups` are the
|
||||
* eight groups whose third-place team advanced; their slot assignment comes from
|
||||
* `assignThirdPlaceSlots`.
|
||||
*/
|
||||
function seedR32FromTemplate(
|
||||
groupResults: Map<string, TeamStats[]>,
|
||||
qualifyingThirdGroups: string[]
|
||||
): Map<number, KnockoutMatch> {
|
||||
const thirdAssignment = assignThirdPlaceSlots(qualifyingThirdGroups);
|
||||
|
||||
const resolveSlot = (slot: (typeof FIFA_2026_R32_TEMPLATE)[number]["slot1"]): string | null => {
|
||||
if (slot.kind === "winner") return groupResults.get(slot.group)?.[0]?.id ?? null;
|
||||
if (slot.kind === "runnerUp") return groupResults.get(slot.group)?.[1]?.id ?? null;
|
||||
const group = thirdAssignment.get(slot.thirdSlotId);
|
||||
return group ? groupResults.get(group)?.[2]?.id ?? null : null;
|
||||
};
|
||||
|
||||
const r32 = new Map<number, KnockoutMatch>();
|
||||
for (const spec of FIFA_2026_R32_TEMPLATE) {
|
||||
r32.set(spec.dbMatchNumber, { p1: resolveSlot(spec.slot1), p2: resolveSlot(spec.slot2) });
|
||||
}
|
||||
return r32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard single-elimination seed order for a bracket of `size` (a power of 2):
|
||||
* returns 1-based seed numbers in bracket position order so that seed 1 and 2
|
||||
* can only meet in the final. Used by the no-groups futures fallback.
|
||||
*/
|
||||
function standardSeedOrder(size: number): number[] {
|
||||
let order = [1];
|
||||
while (order.length < size) {
|
||||
const next: number[] = [];
|
||||
const rounds = order.length * 2 + 1;
|
||||
for (const seed of order) {
|
||||
next.push(seed, rounds - seed);
|
||||
}
|
||||
order = next;
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/** Normalize a stored group label to a bare letter (e.g. "Group A" → "A"). */
|
||||
function normalizeGroupLabel(name: string): string {
|
||||
return name.trim().toUpperCase().replace(/^GROUP\s+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* No-groups fallback field: the top 32 participants by Elo, standard-seeded into
|
||||
* the 16 Round-of-32 matches so top seeds are spread across the bracket. The
|
||||
* field is fixed; only match outcomes vary between simulations.
|
||||
*/
|
||||
function buildFuturesR32(
|
||||
participantIds: string[],
|
||||
eloFn: (id: string) => number
|
||||
): Map<number, KnockoutMatch> {
|
||||
const ranked = [...participantIds].toSorted((a, b) => eloFn(b) - eloFn(a)).slice(0, 32);
|
||||
const order = standardSeedOrder(32); // 1-based seeds in bracket position order
|
||||
const r32 = new Map<number, KnockoutMatch>();
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const p1 = ranked[order[i * 2] - 1] ?? null;
|
||||
const p2 = ranked[order[i * 2 + 1] - 1] ?? null;
|
||||
r32.set(i + 1, { p1, p2 });
|
||||
}
|
||||
return r32;
|
||||
}
|
||||
|
||||
// ─── Main simulator ───────────────────────────────────────────────────────────
|
||||
|
||||
export class WorldCupSimulator implements Simulator {
|
||||
|
|
@ -367,14 +522,24 @@ export class WorldCupSimulator implements Simulator {
|
|||
const normalizedProb = (id: string): number =>
|
||||
totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length;
|
||||
|
||||
// 5. Load group stage data from DB
|
||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||
// 5. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
|
||||
// several playoff_game events exist, take the most recent so a re-created
|
||||
// event wins over a stale one.
|
||||
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||||
),
|
||||
});
|
||||
const bracketEvent =
|
||||
playoffEvents.find((e) => e.bracketTemplateId === FIFA_48.id) ??
|
||||
playoffEvents.toSorted(
|
||||
(a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0)
|
||||
)[0] ??
|
||||
null;
|
||||
|
||||
// 6. Load group stage data for the bracket event (real groups only — no
|
||||
// synthetic fallback; misconfiguration is handled by regime selection).
|
||||
const tournamentGroups = bracketEvent
|
||||
? await db.query.tournamentGroups.findMany({
|
||||
where: eq(schema.tournamentGroups.scoringEventId, bracketEvent.id),
|
||||
|
|
@ -385,57 +550,78 @@ export class WorldCupSimulator implements Simulator {
|
|||
})
|
||||
: [];
|
||||
|
||||
// Build group definitions: list of teams + their completed match data per group.
|
||||
// If no groups set up yet, distribute all participants into 12 synthetic groups of 4.
|
||||
const groupDefs: Array<{
|
||||
groupName: string;
|
||||
teamIds: string[];
|
||||
completedMatches: GroupMatchResult[];
|
||||
}> = [];
|
||||
}> = tournamentGroups.map((group) => ({
|
||||
groupName: normalizeGroupLabel(group.groupName),
|
||||
teamIds: group.members.map((m) => m.participantId),
|
||||
completedMatches: group.matches
|
||||
.filter((m) => m.isComplete)
|
||||
.map((m) => ({
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
participant1Score: m.participant1Score,
|
||||
participant2Score: m.participant2Score,
|
||||
isComplete: m.isComplete,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (tournamentGroups.length > 0) {
|
||||
for (const group of tournamentGroups) {
|
||||
const teamIds = group.members.map((m) => m.participantId);
|
||||
const completedMatches: GroupMatchResult[] = group.matches
|
||||
.filter((m) => m.isComplete)
|
||||
.map((m) => ({
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
participant1Score: m.participant1Score,
|
||||
participant2Score: m.participant2Score,
|
||||
isComplete: m.isComplete,
|
||||
}));
|
||||
groupDefs.push({ groupName: group.groupName, teamIds, completedMatches });
|
||||
}
|
||||
} else {
|
||||
// No groups set up — distribute participants into 12 synthetic groups of 4
|
||||
const chunkSize = 4;
|
||||
const labels = ["A","B","C","D","E","F","G","H","I","J","K","L"];
|
||||
for (let i = 0; i < Math.min(12, labels.length); i++) {
|
||||
const teamIds = participantIds.slice(i * chunkSize, (i + 1) * chunkSize);
|
||||
if (teamIds.length > 0) groupDefs.push({ groupName: labels[i], teamIds, completedMatches: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Load completed knockout matches (to fix results in simulation)
|
||||
const completedKnockoutMatches = bracketEvent
|
||||
// 7. Load all knockout matches: completed ones lock in real results; the
|
||||
// Round of 32 participant slots give us the real draw when populated.
|
||||
const knockoutMatches = bracketEvent
|
||||
? await db.query.playoffMatches.findMany({
|
||||
where: and(
|
||||
eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
eq(schema.playoffMatches.isComplete, true)
|
||||
),
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
})
|
||||
: [];
|
||||
|
||||
const completedByRoundAndNumber = new Map<string, { winnerId: string; loserId: string }>();
|
||||
for (const m of completedKnockoutMatches) {
|
||||
if (m.winnerId && m.loserId) {
|
||||
const r32Drawn = new Map<number, KnockoutMatch>();
|
||||
for (const m of knockoutMatches) {
|
||||
if (m.isComplete && m.winnerId && m.loserId) {
|
||||
completedByRoundAndNumber.set(`${m.round}:${m.matchNumber}`, {
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
});
|
||||
}
|
||||
if (m.round === "Round of 32") {
|
||||
r32Drawn.set(m.matchNumber, { p1: m.participant1Id, p2: m.participant2Id });
|
||||
}
|
||||
}
|
||||
const r32IsDrawn = [...r32Drawn.values()].some((m) => m.p1 && m.p2);
|
||||
// Fully drawn = every template R32 match has both slots populated. When true
|
||||
// the group stage is irrelevant to the sim and can be skipped entirely.
|
||||
const r32IsFullyDrawn = FIFA_2026_R32_TEMPLATE.every((spec) => {
|
||||
const m = r32Drawn.get(spec.dbMatchNumber);
|
||||
return Boolean(m?.p1 && m?.p2);
|
||||
});
|
||||
|
||||
// 8. Choose simulation regime:
|
||||
// A — R32 draw populated → simulate the real bracket.
|
||||
// B — real groups present → simulate remaining group matches, then seed
|
||||
// the R32 with the official 2026 template.
|
||||
// C — neither → futures-seeded bracket fallback ("like other
|
||||
// sports"), flagged via the result source.
|
||||
const groupLabels = FIFA_48.groupStage?.groupLabels ?? [];
|
||||
const canSeedFromTemplate =
|
||||
groupDefs.length === 12 &&
|
||||
groupDefs.every((g) => g.teamIds.length >= 3) &&
|
||||
groupLabels.every((label) => groupDefs.some((g) => g.groupName === label));
|
||||
const regime: "draw" | "groups" | "futures" = r32IsDrawn
|
||||
? "draw"
|
||||
: canSeedFromTemplate
|
||||
? "groups"
|
||||
: "futures";
|
||||
|
||||
if (regime === "futures") {
|
||||
logger.warn(
|
||||
`[WorldCupSimulator] No R32 draw and no canonical 12-group stage for season ${sportsSeasonId} — falling back to a futures-seeded bracket.`
|
||||
);
|
||||
}
|
||||
|
||||
// Futures fallback field: top 32 participants by Elo, standard-seeded once.
|
||||
const futuresR32 = buildFuturesR32(participantIds, eloFn);
|
||||
|
||||
// 7. Monte Carlo simulation
|
||||
const counts = {
|
||||
|
|
@ -453,81 +639,96 @@ export class WorldCupSimulator implements Simulator {
|
|||
counts.qfLoser.set(id, 0);
|
||||
}
|
||||
|
||||
function runKnockoutRound(
|
||||
teams: string[],
|
||||
roundName: string
|
||||
): { winners: string[]; losers: string[] } {
|
||||
const winners: string[] = [];
|
||||
const losers: string[] = [];
|
||||
for (let i = 0; i < teams.length; i += 2) {
|
||||
const a = teams[i];
|
||||
const b = teams[i + 1];
|
||||
if (!a || !b) continue;
|
||||
// Knockout round names, derived from the bracket template (no magic strings).
|
||||
const [R32, R16, QF, SF] = knockoutRoundChain("Round of 32");
|
||||
const THIRD_PLACE_GAME =
|
||||
FIFA_48.rounds.find((r) => r.name === SF)?.loserFeedsInto ?? "Third Place Game";
|
||||
const FINAL = FIFA_48.rounds.find((r) => r.name === SF)?.feedsInto ?? "Finals";
|
||||
|
||||
const matchNum = Math.floor(i / 2) + 1;
|
||||
const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`);
|
||||
if (fixed) {
|
||||
winners.push(fixed.winnerId);
|
||||
losers.push(fixed.loserId);
|
||||
} else {
|
||||
const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb);
|
||||
winners.push(winner);
|
||||
losers.push(loser);
|
||||
}
|
||||
/**
|
||||
* Build the Round-of-32 matchup map for one simulation iteration, per regime.
|
||||
* In regimes that need group results, simGroup is run per group (replaying
|
||||
* completed matches, simulating the rest) and the official 2026 template
|
||||
* seeds the advancers into the real bracket positions.
|
||||
*/
|
||||
const buildR32 = (): Map<number, KnockoutMatch> => {
|
||||
if (regime === "futures") {
|
||||
return new Map([...futuresR32].map(([k, v]) => [k, { ...v }]));
|
||||
}
|
||||
return { winners, losers };
|
||||
}
|
||||
|
||||
for (let sim = 0; sim < this.numSimulations; sim++) {
|
||||
// ── Group stage ──────────────────────────────────────────────
|
||||
const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams)
|
||||
const thirdPlaceTeams: TeamStats[] = []; // 12 third-place teams
|
||||
// Fully-drawn bracket: the real draw is the whole answer — no group sim.
|
||||
if (regime === "draw" && r32IsFullyDrawn) {
|
||||
return new Map(
|
||||
FIFA_2026_R32_TEMPLATE.map((spec) => {
|
||||
const drawn = r32Drawn.get(spec.dbMatchNumber);
|
||||
return [spec.dbMatchNumber, { p1: drawn?.p1 ?? null, p2: drawn?.p2 ?? null }];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate every group and capture finishing order per group letter.
|
||||
const groupResults = new Map<string, TeamStats[]>();
|
||||
const thirdPlace: Array<{ group: string; stats: TeamStats }> = [];
|
||||
for (const group of groupDefs) {
|
||||
if (group.teamIds.length < 3) continue;
|
||||
|
||||
const sorted = simGroup(group.teamIds, eloFn, group.completedMatches);
|
||||
groupResults.set(group.groupName, sorted);
|
||||
if (sorted[2]) thirdPlace.push({ group: group.groupName, stats: sorted[2] });
|
||||
}
|
||||
const qualifyingThirdGroups = thirdPlace
|
||||
.toSorted((a, b) => sortTeams(a.stats, b.stats))
|
||||
.slice(0, 8)
|
||||
.map((t) => t.group);
|
||||
|
||||
advancingFromGroup.push(sorted[0].id, sorted[1].id); // 1st and 2nd
|
||||
if (sorted[2]) thirdPlaceTeams.push(sorted[2]); // 3rd place
|
||||
const seeded = seedR32FromTemplate(groupResults, qualifyingThirdGroups);
|
||||
|
||||
if (regime === "draw") {
|
||||
// Partially-drawn bracket: honor the real draw and fill only the empty
|
||||
// slots from the seeded teams — skipping any participant already placed
|
||||
// by the real draw so no team appears in the R32 twice.
|
||||
const alreadyDrawn = new Set<string>();
|
||||
for (const m of r32Drawn.values()) {
|
||||
if (m.p1) alreadyDrawn.add(m.p1);
|
||||
if (m.p2) alreadyDrawn.add(m.p2);
|
||||
}
|
||||
const fillSlot = (drawn: string | null | undefined, seededTeam: string | null | undefined) => {
|
||||
if (drawn) return drawn;
|
||||
return seededTeam && !alreadyDrawn.has(seededTeam) ? seededTeam : null;
|
||||
};
|
||||
const merged = new Map<number, KnockoutMatch>();
|
||||
for (const spec of FIFA_2026_R32_TEMPLATE) {
|
||||
const drawn = r32Drawn.get(spec.dbMatchNumber);
|
||||
const fallback = seeded.get(spec.dbMatchNumber);
|
||||
merged.set(spec.dbMatchNumber, {
|
||||
p1: fillSlot(drawn?.p1, fallback?.p1),
|
||||
p2: fillSlot(drawn?.p2, fallback?.p2),
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Pick best 8 third-place teams
|
||||
const best8Third = thirdPlaceTeams
|
||||
.toSorted(sortTeams)
|
||||
.slice(0, 8)
|
||||
.map((t) => t.id);
|
||||
|
||||
// Build R32 pool: 24 group advancers + 8 best 3rd-place = 32 teams
|
||||
const r32Pool = [...advancingFromGroup, ...best8Third];
|
||||
return seeded;
|
||||
};
|
||||
|
||||
for (let sim = 0; sim < this.numSimulations; sim++) {
|
||||
// ── Knockout rounds ──────────────────────────────────────────
|
||||
// R32 → R16 → QF → SF → 3PG + Final
|
||||
//
|
||||
// SEEDING NOTE: We pair teams sequentially (1v2, 3v4, …) in arrival order
|
||||
// (group A winner, group A runner-up, group B winner, …, best-8 3rd-place teams).
|
||||
// Real FIFA uses a pre-determined bracket path (e.g. Group A winner vs Group B
|
||||
// runner-up), which varies by edition. This simplified pairing produces correct
|
||||
// aggregate probabilities for fantasy purposes even though simulated bracket paths
|
||||
// may not match the actual draw.
|
||||
//
|
||||
// Completed knockout matches are honoured by round+matchNumber, so as the real
|
||||
// bracket plays out the simulation locks in actual results automatically.
|
||||
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
|
||||
// canonical ceil(n/2) tree. Completed matches lock in real results.
|
||||
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn, normalizedProb);
|
||||
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
||||
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
||||
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb);
|
||||
|
||||
const r32 = runKnockoutRound(r32Pool, "Round of 32");
|
||||
const r16 = runKnockoutRound(r32.winners, "Round of 16");
|
||||
const qf = runKnockoutRound(r16.winners, "Quarterfinals");
|
||||
const sf = runKnockoutRound(qf.winners, "Semifinals");
|
||||
|
||||
// QF losers
|
||||
for (const id of qf.losers) {
|
||||
// QF losers (placements 5–8)
|
||||
for (const id of qf.losersByNumber.values()) {
|
||||
counts.qfLoser.set(id, (counts.qfLoser.get(id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Third place game (SF losers)
|
||||
const [sf1Loser, sf2Loser] = sf.losers;
|
||||
// Third place game (SF losers, ordered by SF match number)
|
||||
const sf1Loser = sf.losersByNumber.get(1);
|
||||
const sf2Loser = sf.losersByNumber.get(2);
|
||||
if (sf1Loser && sf2Loser) {
|
||||
const fixed3pg = completedByRoundAndNumber.get("Third Place Game:1");
|
||||
const fixed3pg = completedByRoundAndNumber.get(`${THIRD_PLACE_GAME}:1`);
|
||||
if (fixed3pg) {
|
||||
counts.thirdPlace.set(fixed3pg.winnerId, (counts.thirdPlace.get(fixed3pg.winnerId) ?? 0) + 1);
|
||||
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
|
||||
|
|
@ -541,9 +742,10 @@ export class WorldCupSimulator implements Simulator {
|
|||
}
|
||||
|
||||
// Final (SF winners)
|
||||
const [sfWinner1, sfWinner2] = sf.winners;
|
||||
const sfWinner1 = sf.winnersByNumber.get(1);
|
||||
const sfWinner2 = sf.winnersByNumber.get(2);
|
||||
if (sfWinner1 && sfWinner2) {
|
||||
const fixedFinal = completedByRoundAndNumber.get("Finals:1");
|
||||
const fixedFinal = completedByRoundAndNumber.get(`${FINAL}:1`);
|
||||
if (fixedFinal) {
|
||||
counts.champion.set(fixedFinal.winnerId, (counts.champion.get(fixedFinal.winnerId) ?? 0) + 1);
|
||||
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
|
||||
|
|
@ -557,10 +759,17 @@ export class WorldCupSimulator implements Simulator {
|
|||
}
|
||||
}
|
||||
|
||||
// 8. Convert counts to probabilities
|
||||
// 9. Convert counts to probabilities
|
||||
const N = this.numSimulations;
|
||||
const numQfLosers = 4; // 4 QF losers per sim
|
||||
|
||||
const source =
|
||||
regime === "draw"
|
||||
? "World Cup Monte Carlo (real bracket)"
|
||||
: regime === "groups"
|
||||
? "World Cup Monte Carlo (group stage + 2026 bracket seeding)"
|
||||
: "World Cup Monte Carlo (futures fallback — no groups)";
|
||||
|
||||
const results: SimulationResult[] = participantIds.map((id) => {
|
||||
const qfProb = (counts.qfLoser.get(id) ?? 0) / (numQfLosers * N);
|
||||
|
||||
|
|
@ -576,7 +785,7 @@ export class WorldCupSimulator implements Simulator {
|
|||
probSeventh: qfProb,
|
||||
probEighth: qfProb,
|
||||
},
|
||||
source: "World Cup Monte Carlo (group stage + knockout)",
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
70
scripts/gen-fifa-third-place.mjs
Normal file
70
scripts/gen-fifa-third-place.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Generate app/lib/fifa-2026-third-place-allocation.ts from the published FIFA
|
||||
* 2026 World Cup Annex C third-place allocation table.
|
||||
*
|
||||
* Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
||||
* Run with: node scripts/gen-fifa-third-place.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SOURCE_URL =
|
||||
"https://en.wikipedia.org/w/index.php?title=Template:2026_FIFA_World_Cup_third-place_table&action=raw";
|
||||
|
||||
const text = await (await fetch(SOURCE_URL)).text();
|
||||
|
||||
// Split into row blocks at each `! scope="row" | N`.
|
||||
const parts = text.split(/!\s*scope="row"\s*\|/).slice(1);
|
||||
if (parts.length !== 495) throw new Error(`expected 495 rows, got ${parts.length}`);
|
||||
|
||||
const out = {};
|
||||
for (const part of parts) {
|
||||
// Cut the block at the next row separator / table end.
|
||||
const block = part.split(/\n\|\}|\n\|-/)[0];
|
||||
const tokens = block.replace(/'''/g, "").split(/\|\||\||\n/).map((t) => t.trim());
|
||||
|
||||
const qualifying = tokens.filter((t) => /^[A-L]$/.test(t));
|
||||
const assignments = tokens.filter((t) => /^3[A-L]$/.test(t)).map((t) => t[1]);
|
||||
|
||||
if (qualifying.length !== 8) throw new Error(`row qualifying != 8: ${qualifying}`);
|
||||
if (assignments.length !== 8) throw new Error(`row assignments != 8: ${assignments}`);
|
||||
|
||||
const set = new Set(assignments);
|
||||
if (set.size !== 8) throw new Error(`duplicate thirds: ${assignments}`);
|
||||
for (const g of assignments) {
|
||||
if (!qualifying.includes(g)) throw new Error(`third ${g} not qualifying`);
|
||||
}
|
||||
|
||||
const key = [...qualifying].sort().join("");
|
||||
if (out[key]) throw new Error(`duplicate key ${key}`);
|
||||
out[key] = assignments.join(""); // thirds in winner-column order [A,B,D,E,G,I,K,L]
|
||||
}
|
||||
|
||||
const keys = Object.keys(out).sort();
|
||||
if (keys.length !== 495) throw new Error(`expected 495 keys, got ${keys.length}`);
|
||||
|
||||
const lines = keys.map((k) => ` ${k}: "${out[k]}",`).join("\n");
|
||||
const ts = `// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
|
||||
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
||||
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
|
||||
//
|
||||
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
|
||||
// Value: the qualifying third-place group facing each group winner, in the
|
||||
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
|
||||
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
|
||||
|
||||
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
|
||||
${lines}
|
||||
};
|
||||
`;
|
||||
|
||||
const target = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"app",
|
||||
"lib",
|
||||
"fifa-2026-third-place-allocation.ts"
|
||||
);
|
||||
fs.writeFileSync(target, ts);
|
||||
console.log(`Wrote ${keys.length} rows to ${target}`);
|
||||
Loading…
Add table
Reference in a new issue