From 4dc88f8e0f89e9ff9661d23abb9afbe3c7c008d3 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 24 Jun 2026 09:52:03 -0500 Subject: [PATCH] simulation --- app/components/Bracket.tsx | 4 +- app/components/Simulation.tsx | 267 ++++++++++++++++++++++++++++++++++ app/page.tsx | 9 +- lib/simulation.ts | 110 ++++++++++++++ 4 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 app/components/Simulation.tsx create mode 100644 lib/simulation.ts diff --git a/app/components/Bracket.tsx b/app/components/Bracket.tsx index 59ad9c6..50d7f84 100644 --- a/app/components/Bracket.tsx +++ b/app/components/Bracket.tsx @@ -65,11 +65,13 @@ function Tie({ export default function Bracket({ matches, teams, tables, thirds, assignment, annexResolved, + preResolved, }: { matches: Match[]; teams: Team[]; tables: GroupTable[]; thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean; + preResolved?: { r32: ResolvedTie[]; later: Record }; }) { - const { r32: r32Array, later } = resolveBracket( + const { r32: r32Array, later } = preResolved ?? resolveBracket( matches, teams, tables, thirds, assignment, annexResolved, ); diff --git a/app/components/Simulation.tsx b/app/components/Simulation.tsx new file mode 100644 index 0000000..a41809b --- /dev/null +++ b/app/components/Simulation.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState, useMemo, useCallback, useEffect } from "react"; +import { Match, Team } from "@/lib/types"; +import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; +import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket"; +import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket"; +import { + loadOverrides, saveOverrides, buildSimMatches, + propagateSimWinners, defaultScore, SimOverrides, +} from "@/lib/simulation"; +import Bracket from "./Bracket"; +import Flag from "./Flag"; + +function teamById(teams: Team[], id: string | null) { + return id ? teams.find((t) => t.id === id) : undefined; +} + +function fmtDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleString("de-DE", { + weekday: "short", day: "2-digit", month: "2-digit", + hour: "2-digit", minute: "2-digit", + }); +} + +// Label für die Spielphase in der Liste +function phaseLabel(match: Match): string { + if (match.stage === "GROUP" && match.group) return `Gruppe ${match.group}`; + if (match.stage === "R32") return "Sechzehntelfinale"; + if (match.stage === "R16") return "Achtelfinale"; + if (match.stage === "QF") return "Viertelfinale"; + if (match.stage === "SF") return "Halbfinale"; + if (match.stage === "3RD") return "Spiel um Platz 3"; + if (match.stage === "FINAL") return "Finale"; + return match.stage; +} + +export default function Simulation({ teams, matches }: { teams: Team[]; matches: Match[] }) { + const [overrides, setOverrides] = useState({}); + + useEffect(() => { + setOverrides(loadOverrides()); + }, []); + + const handleScore = useCallback((matchId: string, homeScore: number, awayScore: number) => { + setOverrides((prev) => { + const next = { ...prev, [matchId]: { homeScore, awayScore } }; + saveOverrides(next); + return next; + }); + }, []); + + const handleReset = useCallback(() => { + setOverrides({}); + saveOverrides({}); + }, []); + + const handleClear = useCallback((matchId: string) => { + setOverrides((prev) => { + const next = { ...prev }; + delete next[matchId]; + saveOverrides(next); + return next; + }); + }, []); + + // ---- Simulations-Pipeline ---- + const simMatches = useMemo(() => buildSimMatches(matches, overrides), [matches, overrides]); + const simTables = useMemo(() => computeGroupTables(teams, simMatches), [teams, simMatches]); + const simThirds = useMemo(() => computeThirdPlaceTable(simTables), [simTables]); + const simQGroups = useMemo(() => qualifiedThirdGroups(simThirds), [simThirds]); + const simAnnex = useMemo( + () => (simQGroups.length === 8 ? resolveAnnexC(simQGroups) : null), + [simQGroups], + ); + const simAnnexResolved = simAnnex != null; + + const { r32, later: laterRaw } = useMemo( + () => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved), + [simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved], + ); + const later = useMemo(() => propagateSimWinners(r32, laterRaw), [r32, laterRaw]); + const preResolved = useMemo(() => ({ r32, later }), [r32, later]); + + // ---- Eingabeliste: offene Spiele aus dem echten Feed ---- + const openMatches = useMemo( + () => + matches + .filter((m) => m.status !== "FINISHED") + .sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate)), + [matches], + ); + + const groupPhase = openMatches.filter((m) => m.stage === "GROUP"); + const koPhase = openMatches.filter((m) => m.stage !== "GROUP"); + + const hasChanges = Object.keys(overrides).length > 0; + + return ( +
+
+ Simulation — Ergebnisse frei wählbar, beeinflusst nicht die echten Daten. + Alle ungespielten Spiele sind vorbelegt mit dem Polymarket-Favoriten. + Eingaben werden im Browser gespeichert. +
+ + {/* ---- Eingabeliste ---- */} +
+
+

+ Offene Spiele ({openMatches.length}) +

+ {hasChanges && ( + + )} +
+ + {groupPhase.length > 0 && ( +
+
+ Gruppenphase +
+
+ {groupPhase.map((m) => ( + + ))} +
+
+ )} + + {koPhase.length > 0 && ( +
+
+ K.o.-Phase +
+
+ {koPhase.map((m) => ( + + ))} +
+
+ )} +
+ + {/* ---- Simulierter K.o.-Baum ---- */} +

+ Simulierter K.o.-Baum +

+ +
+ ); +} + +// ---- Einzelne Eingabezeile ---- +function SimRow({ + match, teams, overrides, onScore, onClear, +}: { + match: Match; teams: Team[]; overrides: SimOverrides; onScore: (id: string, h: number, a: number) => void; + onClear: (id: string) => void; +}) { + const home = teamById(teams, match.homeTeamId); + const away = teamById(teams, match.awayTeamId); + const def = defaultScore(match); + const ov = overrides[match.id]; + const homeVal = ov?.homeScore ?? def?.homeScore; + const awayVal = ov?.awayScore ?? def?.awayScore; + const homeChanged = ov != null; + const isKO = match.stage !== "GROUP"; + + const inputStyle = (changed: boolean): React.CSSProperties => ({ + width: 36, + height: 28, + textAlign: "center", + fontFamily: "var(--font-mono)", + fontSize: 14, + fontWeight: 700, + color: changed ? "var(--turf)" : "var(--ink)", + background: "var(--bg-raised)", + border: `1px solid ${changed ? "var(--turf)" : "var(--line)"}`, + borderRadius: "var(--radius-sm)", + outline: "none", + MozAppearance: "textfield", + }); + + return ( +
+ + {phaseLabel(match)} + + + + + {home?.name ?? "—"} + + + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 0) { + onScore(match.id, v, awayVal ?? (isKO ? 0 : 1)); + } else if (e.target.value === "") { + // Bei Leerung: Standard wiederherstellen + const fallback = def; + if (fallback) onScore(match.id, fallback.homeScore, fallback.awayScore); + } + }} + /> + : + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 0) { + onScore(match.id, homeVal ?? (isKO ? 0 : 1), v); + } else if (e.target.value === "") { + const fallback = def; + if (fallback) onScore(match.id, fallback.homeScore, fallback.awayScore); + else onClear(match.id); + } + }} + /> + + + {away?.name ?? "—"} + + + + + {fmtDate(match.utcDate)} + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index e3afbdc..e30e4e1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,6 +7,7 @@ import Groups from "./components/Groups"; import ThirdPlace from "./components/ThirdPlace"; import Bracket from "./components/Bracket"; import Fixtures from "./components/Fixtures"; +import Simulation from "./components/Simulation"; interface ApiData { updatedAt: string; @@ -19,7 +20,7 @@ interface ApiData { annexResolved: boolean; } -type Tab = "groups" | "fixtures" | "thirds" | "bracket"; +type Tab = "groups" | "fixtures" | "thirds" | "bracket" | "sim"; export default function Home() { const [data, setData] = useState(null); @@ -90,6 +91,9 @@ export default function Home() { + @@ -133,6 +137,9 @@ export default function Home() { annexResolved={data.annexResolved} /> )} + {data && tab === "sim" && ( + + )} diff --git a/lib/simulation.ts b/lib/simulation.ts new file mode 100644 index 0000000..613417b --- /dev/null +++ b/lib/simulation.ts @@ -0,0 +1,110 @@ +import { Match, Team, GroupTable, ThirdPlaceRow } from "./types"; +import { ThirdAssignment, LATER_ROUNDS } from "./bracket"; +import { ResolvedSide, ResolvedTie } from "./resolve-bracket"; + +export type SimOverrides = Record; + +const STORAGE_KEY = "wm2026-sim-overrides"; + +export function loadOverrides(): SimOverrides { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +export function saveOverrides(overrides: SimOverrides): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); + } catch { /* quota exceeded */ } +} + +// Plausibles Standardergebnis aus Polymarket-Wahrscheinlichkeiten. +// Favorit gewinnt 1:0; bei hoher W'keit (>=0.65) 2:0. +// Ohne prob: Gruppe → 1:1, K.o. → null (Nutzer muss selbst eingeben). +export function defaultScore(match: Match): { homeScore: number; awayScore: number } | null { + const prob = match.prob; + if (!prob) { + if (match.stage === "GROUP") return { homeScore: 1, awayScore: 1 }; + return null; + } + const homeFav = prob.home >= prob.away; + const favProb = Math.max(prob.home, prob.away); + const goals = favProb >= 0.65 ? 2 : 1; + if (homeFav) return { homeScore: goals, awayScore: 0 }; + return { homeScore: 0, awayScore: goals }; +} + +// Baut die simulierte Match-Liste: gespielte Spiele unverändert, +// offene Spiele mit Override oder Polymarket-Default, status = "FINISHED". +export function buildSimMatches(realMatches: Match[], overrides: SimOverrides): Match[] { + return realMatches.map((m) => { + if (m.status === "FINISHED") return m; + const override = overrides[m.id]; + if (override) { + return { ...m, homeScore: override.homeScore, awayScore: override.awayScore, status: "FINISHED" as const }; + } + const def = defaultScore(m); + if (def) { + return { ...m, homeScore: def.homeScore, awayScore: def.awayScore, status: "FINISHED" as const }; + } + return m; + }); +} + +// Ermittelt die Gewinner-Seite eines aufgelösten Ties anhand der Scores. +function winningSide(tie: ResolvedTie): ResolvedSide | null { + if (tie.home.score == null || tie.away.score == null) return null; + if (tie.home.score > tie.away.score) return tie.home; + if (tie.away.score > tie.home.score) return tie.away; + return tie.home; // unentschieden → Heimseite gewinnt +} + +function losingSide(tie: ResolvedTie): ResolvedSide | null { + if (tie.home.score == null || tie.away.score == null) return null; + if (tie.home.score > tie.away.score) return tie.away; + if (tie.away.score > tie.home.score) return tie.home; + return tie.away; +} + +// Propagiert Gewinner aus R32 durch alle Folgerunden (R16→QF→SF→Finale). +// Füllt teamId/label/code in den späteren Runden mit den Daten des Gewinners. +export function propagateSimWinners( + r32: ResolvedTie[], + later: Record, +): Record { + const winners = new Map(); + const losers = new Map(); + + function update(tie: ResolvedTie) { + const w = winningSide(tie); + const l = losingSide(tie); + if (w?.teamId) winners.set(tie.matchNumber, { teamId: w.teamId, label: w.label, code: w.code }); + if (l?.teamId) losers.set(tie.matchNumber, { teamId: l.teamId, label: l.label, code: l.code }); + } + + for (const tie of r32) update(tie); + + const result: Record = {}; + for (const km of LATER_ROUNDS) { + const tie = later[km.matchNumber]; + if (!tie) continue; + const src = km.losers ? losers : winners; + const hw = src.get(km.fromHome); + const aw = src.get(km.fromAway); + const home = hw + ? { ...tie.home, teamId: hw.teamId, label: hw.label, code: hw.code, isWinner: false, provisional: false } + : tie.home; + const away = aw + ? { ...tie.away, teamId: aw.teamId, label: aw.label, code: aw.code, isWinner: false, provisional: false } + : tie.away; + const newTie: ResolvedTie = { ...tie, home, away }; + update(newTie); + result[km.matchNumber] = newTie; + } + return result; +}