/** * 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; }