Files
wm-projekt/lib/bracket.ts
2026-07-01 11:21:29 -05:00

138 lines
6.7 KiB
TypeScript

import { ANNEX_C } from "./annexc-data";
import { GroupId, GroupTable, ThirdPlaceRow } from "./types";
import { Dictionary } from "./i18n";
// Die festen Paarungen der Runde der letzten 32 (FIFA-Spielplan, Annex zur Auslosung).
// Quelle: FIFA WM 2026 Wettbewerbsregeln, Spiele 73-88.
// "W"=Sieger, "R"=Zweiter, "3"=bester Drittplatzierter (Gruppe per Annex C bestimmt).
export interface BracketSlot {
type: "W" | "R" | "3";
group?: GroupId; // bei W/R: feste Gruppe
thirdPool?: GroupId[]; // bei 3: mögliche Quellgruppen (Info / UI)
}
export interface R32Match {
matchNumber: number;
home: BracketSlot;
away: BracketSlot;
}
// Die acht Gruppensieger, die einen Drittplatzierten bekommen — in der
// Spaltenreihenfolge von Annex C: 1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L.
export const ANNEX_WINNER_ORDER: GroupId[] = ["A", "B", "D", "E", "G", "I", "K", "L"];
// Statische R32-Struktur. thirdPool dient nur der Anzeige vor Turnierende.
export const R32: R32Match[] = [
{ matchNumber: 73, home: { type: "R", group: "A" }, away: { type: "R", group: "B" } },
{ matchNumber: 74, home: { type: "W", group: "E" }, away: { type: "3", thirdPool: ["A","B","C","D","F"] } },
{ matchNumber: 75, home: { type: "W", group: "F" }, away: { type: "R", group: "C" } },
{ matchNumber: 76, home: { type: "W", group: "C" }, away: { type: "R", group: "F" } },
{ matchNumber: 77, home: { type: "W", group: "I" }, away: { type: "3", thirdPool: ["C","D","F","G","H"] } },
{ matchNumber: 78, home: { type: "R", group: "E" }, away: { type: "R", group: "I" } },
{ matchNumber: 79, home: { type: "W", group: "A" }, away: { type: "3", thirdPool: ["C","E","F","H","I"] } },
{ matchNumber: 80, home: { type: "W", group: "L" }, away: { type: "3", thirdPool: ["E","H","I","J","K"] } },
{ matchNumber: 81, home: { type: "W", group: "D" }, away: { type: "3", thirdPool: ["B","E","F","I","J"] } },
{ matchNumber: 82, home: { type: "W", group: "G" }, away: { type: "3", thirdPool: ["A","E","H","I","J"] } },
{ matchNumber: 83, home: { type: "R", group: "K" }, away: { type: "R", group: "L" } },
{ matchNumber: 84, home: { type: "W", group: "H" }, away: { type: "R", group: "J" } },
{ matchNumber: 85, home: { type: "W", group: "B" }, away: { type: "3", thirdPool: ["E","F","G","I","J"] } },
{ matchNumber: 86, home: { type: "W", group: "J" }, away: { type: "R", group: "H" } },
{ matchNumber: 87, home: { type: "W", group: "K" }, away: { type: "3", thirdPool: ["D","E","I","J","L"] } },
{ matchNumber: 88, home: { type: "R", group: "D" }, away: { type: "R", group: "G" } },
];
// Folgerunden: jedes Spiel speist sich aus zwei Vorspielen (FIFA-Spielplan).
export interface KnockoutMatch {
matchNumber: number;
stage: "R16" | "QF" | "SF" | "3RD" | "FINAL";
fromHome: number; // Match-Nr des Vorspiels (Sieger), bei 3RD: Verlierer
fromAway: number;
losers?: boolean; // true beim Spiel um Platz 3
}
export const LATER_ROUNDS: KnockoutMatch[] = [
{ matchNumber: 89, stage: "R16", fromHome: 74, fromAway: 77 },
{ matchNumber: 90, stage: "R16", fromHome: 73, fromAway: 75 },
{ matchNumber: 91, stage: "R16", fromHome: 76, fromAway: 78 },
{ matchNumber: 92, stage: "R16", fromHome: 79, fromAway: 80 },
{ matchNumber: 93, stage: "R16", fromHome: 83, fromAway: 84 },
{ matchNumber: 94, stage: "R16", fromHome: 81, fromAway: 82 },
{ matchNumber: 95, stage: "R16", fromHome: 86, fromAway: 88 },
{ matchNumber: 96, stage: "R16", fromHome: 85, fromAway: 87 },
{ matchNumber: 97, stage: "QF", fromHome: 89, fromAway: 90 },
{ matchNumber: 98, stage: "QF", fromHome: 93, fromAway: 94 },
{ matchNumber: 99, stage: "QF", fromHome: 91, fromAway: 92 },
{ matchNumber: 100, stage: "QF", fromHome: 95, fromAway: 96 },
{ matchNumber: 101, stage: "SF", fromHome: 97, fromAway: 98 },
{ matchNumber: 102, stage: "SF", fromHome: 99, fromAway: 100 },
{ matchNumber: 103, stage: "3RD", fromHome: 101, fromAway: 102, losers: true },
{ matchNumber: 104, stage: "FINAL", fromHome: 101, fromAway: 102 },
];
// Ergebnis der Annex-C-Auflösung: pro Gruppensieger der zugeordnete Dritte.
export type ThirdAssignment = Partial<Record<GroupId, GroupId>>;
// Normalisiert die qualifizierten Gruppen zum Annex-C-Schlüssel (alphabetisch).
export function annexKey(qualifiedGroups: GroupId[]): string {
return [...qualifiedGroups].sort().join("");
}
// Schlägt die Zuordnung in Annex C nach. Gibt {Gruppensieger -> 3.Gruppe} zurück.
// Liefert null, wenn nicht genau 8 Gruppen oder kein Treffer.
export function resolveAnnexC(qualifiedGroups: GroupId[]): ThirdAssignment | null {
if (qualifiedGroups.length !== 8) return null;
const key = annexKey(qualifiedGroups);
const value = ANNEX_C[key];
if (!value) return null;
const assignment: ThirdAssignment = {};
for (let i = 0; i < ANNEX_WINNER_ORDER.length; i++) {
assignment[ANNEX_WINNER_ORDER[i]] = value[i] as GroupId;
}
return assignment;
}
// Bestimmt die qualifizierten Drittplatzierten-Gruppen aus der Tabelle.
export function qualifiedThirdGroups(thirds: ThirdPlaceRow[]): GroupId[] {
return thirds.filter((t) => t.qualifies).map((t) => t.group);
}
// Hilfslabel für einen Slot, abhängig davon ob die Annex-C-Zuordnung schon feststeht.
export function slotLabel(
slot: BracketSlot,
assignment: ThirdAssignment | null,
winnerGroupForThisMatch?: GroupId,
dict?: Dictionary,
): string {
const s = dict?.slot;
if (slot.type === "W") return s?.winner(slot.group!) ?? `Sieger ${slot.group}`;
if (slot.type === "R") return s?.runnerUp(slot.group!) ?? `Zweiter ${slot.group}`;
// 3. Platz
if (assignment && winnerGroupForThisMatch && assignment[winnerGroupForThisMatch]) {
return s?.thirdOfGroup(assignment[winnerGroupForThisMatch]!) ?? `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`;
}
return s?.thirdPlacePool(slot.thirdPool?.join("/") ?? "?") ?? `3. ${slot.thirdPool?.join("/") ?? "?"}`;
}
// Lookup: matchNumber → Quellspiele (fromHome, fromAway, losers).
// Exkludiert "Spiel um Platz 3" (losers: true), weil es nicht im Siegerbaum mitläuft.
const SOURCE_MAP: Map<number, KnockoutMatch> = (() => {
const m = new Map<number, KnockoutMatch>();
for (const km of LATER_ROUNDS) {
if (!km.losers) m.set(km.matchNumber, km);
}
return m;
})();
// Berechnet die vertikale Anzeige-Reihenfolge einer früheren Runde aus der
// Reihenfolge der Folgerunde. parentRound sind die Match-Nummern der Folgerunde
// in Anzeige-Reihenfolge. Gibt die Nummern der Quellspiele in korrekter
// Baum-Topologie-Reihenfolge zurück.
export function orderedRound(parentRound: number[]): number[] {
const result: number[] = [];
for (const parent of parentRound) {
const src = SOURCE_MAP.get(parent);
if (src) result.push(src.fromHome, src.fromAway);
}
return result;
}