third place
This commit is contained in:
@@ -1,73 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
import { useMemo } from "react";
|
||||||
import { Match, Team } from "@/lib/types";
|
import { Match, Team } from "@/lib/types";
|
||||||
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
|
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
|
||||||
import { qualifiedThirdGroups, resolveAnnexC, LATER_ROUNDS } from "@/lib/bracket";
|
import { qualifiedThirdGroups, resolveAnnexC, LATER_ROUNDS } from "@/lib/bracket";
|
||||||
import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket";
|
import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket";
|
||||||
import {
|
import { buildSimMatches } from "@/lib/simulation";
|
||||||
loadOverrides, saveOverrides, buildSimMatches,
|
|
||||||
defaultScore, SimOverrides,
|
|
||||||
} from "@/lib/simulation";
|
|
||||||
import Bracket from "./Bracket";
|
import Bracket from "./Bracket";
|
||||||
import ThirdPlace from "./ThirdPlace";
|
|
||||||
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[] }) {
|
export default function Simulation({ teams, matches }: { teams: Team[]; matches: Match[] }) {
|
||||||
const [overrides, setOverrides] = useState<SimOverrides>({});
|
// ---- Simulations-Pipeline (Polymarket-Defaults, keine manuellen Overrides) ----
|
||||||
|
const simMatches = useMemo(() => buildSimMatches(matches, {}), [matches]);
|
||||||
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 simTables = useMemo(() => computeGroupTables(teams, simMatches), [teams, simMatches]);
|
||||||
const simThirds = useMemo(() => computeThirdPlaceTable(simTables), [simTables]);
|
const simThirds = useMemo(() => computeThirdPlaceTable(simTables), [simTables]);
|
||||||
const simQGroups = useMemo(() => qualifiedThirdGroups(simThirds), [simThirds]);
|
const simQGroups = useMemo(() => qualifiedThirdGroups(simThirds), [simThirds]);
|
||||||
@@ -149,95 +92,14 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
|
|||||||
return { r32: r32Fixed, later: laterFixed };
|
return { r32: r32Fixed, later: laterFixed };
|
||||||
}, [simBracket, realProvisional, realDecided]);
|
}, [simBracket, realProvisional, realDecided]);
|
||||||
|
|
||||||
// ---- 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");
|
|
||||||
|
|
||||||
// KO-Eingaben erst freigeben, wenn alle Gruppenspiele beendet sind.
|
|
||||||
const groupPhaseComplete = matches
|
|
||||||
.filter((m) => m.group != null)
|
|
||||||
.every((m) => m.status === "FINISHED");
|
|
||||||
|
|
||||||
const hasChanges = Object.keys(overrides).length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="notice" style={{ marginBottom: 20 }}>
|
<div className="notice" style={{ marginBottom: 20 }}>
|
||||||
Simulation — Ergebnisse frei wählbar, beeinflusst nicht die echten Daten.
|
Simulation — alle ungespielten Spiele sind mit dem Polymarket-Favoriten
|
||||||
Alle ungespielten Spiele sind vorbelegt mit dem Polymarket-Favoriten.
|
vorbelegt. Die angezeigten Wahrscheinlichkeiten stammen von Polymarket.
|
||||||
Eingaben werden im Browser gespeichert.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- Eingabeliste ---- */}
|
|
||||||
<div style={{ marginBottom: 28 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
|
|
||||||
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: 0 }}>
|
|
||||||
Offene Spiele ({openMatches.length})
|
|
||||||
</h3>
|
|
||||||
{hasChanges && (
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)", fontSize: 11,
|
|
||||||
color: "var(--ink-faint)", background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--line)", borderRadius: "var(--radius-sm)",
|
|
||||||
padding: "5px 12px", cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Auf Polymarket-Defaults zurücksetzen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{groupPhase.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-faint)", marginBottom: 8 }}>
|
|
||||||
Gruppenphase
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
||||||
{groupPhase.map((m) => (
|
|
||||||
<SimRow key={m.id} match={m} teams={teams} overrides={overrides} onScore={handleScore} onClear={handleClear} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{koPhase.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-faint)", marginBottom: 8 }}>
|
|
||||||
K.o.-Phase
|
|
||||||
</div>
|
|
||||||
{groupPhaseComplete ? (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
||||||
{koPhase.map((m) => (
|
|
||||||
<SimRow key={m.id} match={m} teams={teams} overrides={overrides} onScore={handleScore} onClear={handleClear} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="notice" style={{ fontSize: 13 }}>
|
|
||||||
K.o.-Spiele werden zur Eingabe freigegeben, sobald die Gruppenphase abgeschlossen ist.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---- Drittplatzierte (simuliert) ---- */}
|
|
||||||
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "0 0 12px" }}>
|
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "0 0 12px" }}>
|
||||||
Drittplatzierte (simuliert)
|
|
||||||
</h3>
|
|
||||||
<ThirdPlace rows={simThirds} teams={teams} />
|
|
||||||
|
|
||||||
{/* ---- Simulierter K.o.-Baum ---- */}
|
|
||||||
<h3 style={{ fontFamily: "var(--font-display)", fontSize: 15, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "28px 0 12px" }}>
|
|
||||||
Simulierter K.o.-Baum
|
Simulierter K.o.-Baum
|
||||||
</h3>
|
</h3>
|
||||||
<Bracket
|
<Bracket
|
||||||
@@ -252,99 +114,3 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 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 (
|
|
||||||
<div className="sim-row" style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
|
||||||
padding: "6px 12px",
|
|
||||||
background: "var(--bg-card)", border: "1px solid var(--line-soft)",
|
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}>
|
|
||||||
<span className="sim-row-phase" style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)", minWidth: 75 }}>
|
|
||||||
{phaseLabel(match)}
|
|
||||||
</span>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1, justifyContent: "flex-end" }}>
|
|
||||||
<Flag team={home} size={18} />
|
|
||||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 140 }}>
|
|
||||||
{home?.localisedName ?? home?.name ?? "—"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
style={inputStyle(homeChanged)}
|
|
||||||
value={homeVal ?? ""}
|
|
||||||
placeholder="–"
|
|
||||||
onChange={(e) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontWeight: 700, color: "var(--ink-faint)" }}>:</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
style={inputStyle(homeChanged)}
|
|
||||||
value={awayVal ?? ""}
|
|
||||||
placeholder="–"
|
|
||||||
onChange={(e) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1 }}>
|
|
||||||
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 140 }}>
|
|
||||||
{away?.localisedName ?? away?.name ?? "—"}
|
|
||||||
</span>
|
|
||||||
<Flag team={away} size={18} />
|
|
||||||
</span>
|
|
||||||
<span className="sim-row-date" style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)", minWidth: 100, textAlign: "right" }}>
|
|
||||||
{fmtDate(match.utcDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GroupId, GROUP_IDS, Match, Team } from "./types";
|
import { GroupId, GROUP_IDS, Match, Team } from "./types";
|
||||||
import { computeGroupTables } from "./standings";
|
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
|
||||||
import { resolveAnnexC } from "./bracket";
|
import { resolveAnnexC, qualifiedThirdGroups } from "./bracket";
|
||||||
|
|
||||||
// Repräsentative Ergebnis-Varianten je Restspiel (decken alle Punktkombinationen ab).
|
// Repräsentative Ergebnis-Varianten je Restspiel (decken alle Punktkombinationen ab).
|
||||||
const OUTCOMES: Array<[number, number]> = [
|
const OUTCOMES: Array<[number, number]> = [
|
||||||
@@ -174,7 +174,21 @@ export function thirdSlotIsSecure(winnerGroup: GroupId, matches: Match[], teams:
|
|||||||
const states = buildThirdStates(matches, teams);
|
const states = buildThirdStates(matches, teams);
|
||||||
const { lockedIn, contested } = classifyGroups(states);
|
const { lockedIn, contested } = classifyGroups(states);
|
||||||
|
|
||||||
if (lockedIn.size > 8) return false;
|
if (lockedIn.size > 8) {
|
||||||
|
// Alle Gruppen fertig oder zu viele lockedIn → die 8 tatsächlich qualifizierten
|
||||||
|
// Dritten bestimmen (nicht die 12 lockedIn-Gruppen).
|
||||||
|
const tables = computeGroupTables(teams, matches);
|
||||||
|
const thirds = computeThirdPlaceTable(tables);
|
||||||
|
const qGroups = qualifiedThirdGroups(thirds);
|
||||||
|
if (qGroups.length !== 8) return false;
|
||||||
|
const assignment = resolveAnnexC(qGroups);
|
||||||
|
if (!assignment) return false;
|
||||||
|
const ag = assignment[winnerGroup];
|
||||||
|
if (!ag) return false;
|
||||||
|
const ss = states.get(ag);
|
||||||
|
if (!ss) return false;
|
||||||
|
return ss.finished && ss.third != null && ss.possibleTeamIds.size === 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (lockedIn.size === 8) {
|
if (lockedIn.size === 8) {
|
||||||
const assignment = resolveAnnexC([...lockedIn]);
|
const assignment = resolveAnnexC([...lockedIn]);
|
||||||
|
|||||||
Reference in New Issue
Block a user