Reflect group stage in World Cup sim; seed R32 from real 2026 bracket #107

Merged
chrisp merged 1 commit from fix/world-cup-simulator-bracket-seeding into main 2026-06-23 21:02:21 +00:00
6 changed files with 1328 additions and 107 deletions

View file

@ -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 116", () => {
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 7388", () => {
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 AH (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<string>();
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 EL 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<number, Set<string>>(
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
);
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()
);
}
});
});

View file

@ -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 (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;
}

View file

@ -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<string, string> = {
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",
};

View file

@ -78,6 +78,30 @@ function makeParticipants(count = 48) {
}));
}
/** 12 canonical groups AL, 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<string, unknown> | 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 216 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);
});
});

View file

@ -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<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number
): {
winnersByNumber: Map<number, string>;
losersByNumber: Map<number, string>;
nextRound: Map<number, KnockoutMatch>;
} {
const winnersByNumber = new Map<number, string>();
const losersByNumber = new Map<number, string>();
const nextRound = new Map<number, KnockoutMatch>();
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<string, TeamStats[]>,
qualifyingThirdGroups: string[]
): Map<number, KnockoutMatch> {
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<number, KnockoutMatch>();
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<number, KnockoutMatch> {
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<number, KnockoutMatch>();
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<string, { winnerId: string; loserId: string }>();
for (const m of completedKnockoutMatches) {
if (m.winnerId && m.loserId) {
const r32Drawn = new Map<number, KnockoutMatch>();
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<number, KnockoutMatch> => {
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<string, TeamStats[]>();
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<string>();
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<number, KnockoutMatch>();
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 58)
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,
};
});

View file

@ -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<string, string> = {
${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}`);