163 lines
7.5 KiB
TypeScript
163 lines
7.5 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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;
|
|||
|
|
}
|