brackt/app/lib/fifa-2026-bracket.ts

163 lines
7.5 KiB
TypeScript
Raw Normal View History

Reflect group stage in World Cup sim; seed R32 from real 2026 bracket The World Cup Monte Carlo simulator ignored the admin-populated knockout draw and re-paired its own simulated group output sequentially, so bracket paths from the Round of 32 onward did not match the real tournament. It also silently invented 12 synthetic groups when none were configured, discarding real group-stage results. Rework the simulator around three regimes: - draw set → simulate the real R32 matchups, advancing through the bracket tree via the canonical ceil(matchNumber/2) rule (matching advanceWinnerTemplate) so sim paths match the admin tooling. - groups only → simulate remaining group matches, then seed the R32 with the official 2026 group-position template + FIFA's exact Annex C best-third-place allocation (495-row published table). - neither → futures-seeded bracket fallback, flagged via result source. Completed group and knockout matches still lock in real results at every round. New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template (official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table is generated deterministically by scripts/gen-fifa-third-place.mjs into app/lib/fifa-2026-third-place-allocation.ts. Tests assert exact Annex C values, cross-check the template's eligible groups against all 495 combinations, and cover each regime including the partial-draw dedup and fully-drawn fast path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:15:45 -07:00
/**
* 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 (7388 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 116 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 {
/** 116; drives the codebase's ceil(n/2) bracket tree. */
dbMatchNumber: number;
/** Official FIFA match number (7388); 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;
}