diff --git a/app/lib/__tests__/fifa-2026-bracket.test.ts b/app/lib/__tests__/fifa-2026-bracket.test.ts new file mode 100644 index 0000000..ecc6717 --- /dev/null +++ b/app/lib/__tests__/fifa-2026-bracket.test.ts @@ -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(); + 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>( + THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set()]) + ); + 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() + ); + } + }); +}); diff --git a/app/lib/fifa-2026-bracket.ts b/app/lib/fifa-2026-bracket.ts new file mode 100644 index 0000000..dbf5062 --- /dev/null +++ b/app/lib/fifa-2026-bracket.ts @@ -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 => 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( + 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 { + const groups = qualifyingGroups.map((g) => g.toUpperCase()); + const key = groups.toSorted().join(""); + const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key]; + + const assignment = new Map(); + 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 { + 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(); + const used = new Set(); + + 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; +} diff --git a/app/lib/fifa-2026-third-place-allocation.ts b/app/lib/fifa-2026-third-place-allocation.ts new file mode 100644 index 0000000..7868129 --- /dev/null +++ b/app/lib/fifa-2026-third-place-allocation.ts @@ -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 = { + 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", +}; diff --git a/app/services/simulations/__tests__/world-cup-simulator.test.ts b/app/services/simulations/__tests__/world-cup-simulator.test.ts index 599127c..e3f596f 100644 --- a/app/services/simulations/__tests__/world-cup-simulator.test.ts +++ b/app/services/simulations/__tests__/world-cup-simulator.test.ts @@ -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 | 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); + }); }); diff --git a/app/services/simulations/world-cup-simulator.ts b/app/services/simulations/world-cup-simulator.ts index ce3542a..86d0c90 100644 --- a/app/services/simulations/world-cup-simulator.ts +++ b/app/services/simulations/world-cup-simulator.ts @@ -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, + completed: Map, + eloFn: (id: string) => number, + normalizedProb: (id: string) => number +): { + winnersByNumber: Map; + losersByNumber: Map; + nextRound: Map; +} { + const winnersByNumber = new Map(); + const losersByNumber = new Map(); + const nextRound = new Map(); + + 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, + qualifyingThirdGroups: string[] +): Map { + 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(); + 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 { + 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(); + 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(); - for (const m of completedKnockoutMatches) { - if (m.winnerId && m.loserId) { + const r32Drawn = new Map(); + 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 => { + 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(); + 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(); + 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(); + 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, }; }); diff --git a/scripts/gen-fifa-third-place.mjs b/scripts/gen-fifa-third-place.mjs new file mode 100644 index 0000000..78cd0ba --- /dev/null +++ b/scripts/gen-fifa-third-place.mjs @@ -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 = { +${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}`);