brackt/app/lib/fifa-2026-bracket.ts
Chris Parsons 6772079e86
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
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

162 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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