Reflect group stage in World Cup sim; seed R32 from real 2026 bracket
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
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
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>
This commit is contained in:
parent
9480501932
commit
6772079e86
6 changed files with 1328 additions and 107 deletions
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal 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 1–16", () => {
|
||||
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 73–88", () => {
|
||||
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 A–H (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 E–L 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()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
162
app/lib/fifa-2026-bracket.ts
Normal file
162
app/lib/fifa-2026-bracket.ts
Normal 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 (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;
|
||||
}
|
||||
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
506
app/lib/fifa-2026-third-place-allocation.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -78,6 +78,30 @@ function makeParticipants(count = 48) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** 12 canonical groups A–L, 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 2–16 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 5–8)
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
70
scripts/gen-fifa-third-place.mjs
Normal file
70
scripts/gen-fifa-third-place.mjs
Normal 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}`);
|
||||
Loading…
Add table
Reference in a new issue