Bracket what-if mode: pick winners, watch the tree fill
A wand toggle turns the bracket interactive: tap a side to send that team through; W/L placeholders resolve down the tree (pure, tested logic in src/lib/whatif.ts). Real results always override picks; picks that stop making sense after an upstream change are pruned. Each open pairing shows the model's advance probability (ratings.json fetched lazily), and once your final resolves, a summary banner names your champion with the model's chance of exactly that run. EN/DE included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,71 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Wand2 } from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||||
import { teamFlag } from '@/lib/teams';
|
import { teamFlag } from '@/lib/teams';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||||
|
import { hostAdvantage } from '@/lib/model/hosts';
|
||||||
|
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
|
||||||
|
import { championPath, effectiveBracket, prunePicks, type EffMatch, type Picks, type PickSide } from '@/lib/whatif';
|
||||||
import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
|
import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
|
||||||
|
|
||||||
type KnockoutStage = (typeof COLUMN_STAGES)[number];
|
type KnockoutStage = (typeof COLUMN_STAGES)[number];
|
||||||
|
|
||||||
const COLUMN_STAGES = ['r32', 'r16', 'qf', 'sf', 'final'] as const;
|
const COLUMN_STAGES = ['r32', 'r16', 'qf', 'sf', 'final'] as const;
|
||||||
|
|
||||||
|
/** A knockout card in what-if mode: tap a side to send that team through. */
|
||||||
|
function WhatIfMatch({ f, eff, pick, advHome, onPick, wide = false }: {
|
||||||
|
f: Fixture;
|
||||||
|
eff: EffMatch;
|
||||||
|
pick: PickSide | undefined;
|
||||||
|
/** model probability the home side advances (null while ratings load / slots open) */
|
||||||
|
advHome: number | null;
|
||||||
|
onPick: (side: PickSide) => void;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useFormat();
|
||||||
|
const side = (s: PickSide, team: string | null, label: string): ReactNode => {
|
||||||
|
const picked = pick === s || (eff.decided && eff.winner != null && team === eff.winner);
|
||||||
|
const adv = advHome == null ? null : Math.round((s === 'home' ? advHome : 1 - advHome) * 100);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={eff.decided || !team}
|
||||||
|
onClick={() => onPick(s)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left',
|
||||||
|
!eff.decided && team && 'hover:bg-elevated',
|
||||||
|
picked && 'bg-accent-glow font-bold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{team ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-sm leading-none">{teamFlag(team)}</span>
|
||||||
|
<span className={cn('truncate', picked ? 'text-accent' : 'text-ink')}>{team}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs italic text-faint">{label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{team && adv != null && !eff.decided && <span className="tnum shrink-0 text-[11px] text-faint">{adv}%</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg border bg-panel text-sm', pick ? 'border-accent/40' : 'border-line', wide ? 'w-full' : 'w-52')}>
|
||||||
|
{side('home', eff.home, f.home.label)}
|
||||||
|
<div className="h-px bg-line" />
|
||||||
|
{side('away', eff.away, f.away.label)}
|
||||||
|
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[11px] text-faint">
|
||||||
|
{fmt(t.common.matchNumberShort, { num: f.num })}{eff.decided ? ` · ${t.common.fullTimeShort}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
|
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
|
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
|
||||||
@@ -89,10 +144,109 @@ export function BracketPage() {
|
|||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|
||||||
const [mobileStage, setMobileStage] = useState<KnockoutStage>('r32');
|
const [mobileStage, setMobileStage] = useState<KnockoutStage>('r32');
|
||||||
|
const [whatIf, setWhatIf] = useState(false);
|
||||||
|
const [picks, setPicks] = useState<Picks>({});
|
||||||
|
const [ratings, setRatings] = useState<RatingsModel | null>(null);
|
||||||
|
|
||||||
|
const fixtures = useMemo(() => snapshot?.fixtures ?? [], [snapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!whatIf || ratings) return;
|
||||||
|
fetch('/data/ratings.json').then((r) => r.json()).then((d: RatingsModel) => setRatings(d)).catch(() => {});
|
||||||
|
}, [whatIf, ratings]);
|
||||||
|
|
||||||
|
const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks) : null), [whatIf, fixtures, picks]);
|
||||||
|
|
||||||
|
/** model probability the home side advances, for a (possibly hypothetical) pairing */
|
||||||
|
const advHomeFor = (f: Fixture, m: EffMatch): number | null => {
|
||||||
|
if (!ratings || !m.home || !m.away) return null;
|
||||||
|
const adv = hostAdvantage(m.home, m.away, f.venue, ratings.params.homeAdvElo);
|
||||||
|
const p = predictMatch(m.home, m.away, ratings, adv);
|
||||||
|
return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
|
||||||
|
};
|
||||||
|
|
||||||
|
const champ = useMemo(() => {
|
||||||
|
if (!whatIf || !eff) return null;
|
||||||
|
const path = championPath(fixtures, eff);
|
||||||
|
if (!path || !ratings) return path ? { ...path, chance: null as number | null } : null;
|
||||||
|
let chance = 1;
|
||||||
|
for (const num of path.matchNums) {
|
||||||
|
const f = fixtures.find((x) => x.num === num);
|
||||||
|
const m = eff.get(num);
|
||||||
|
if (!f || !m) continue;
|
||||||
|
const advHome = advHomeFor(f, m);
|
||||||
|
if (advHome == null) { chance = -1; break; }
|
||||||
|
chance *= m.winner === m.home ? advHome : 1 - advHome;
|
||||||
|
}
|
||||||
|
return { ...path, chance: chance < 0 ? null : chance };
|
||||||
|
}, [whatIf, eff, fixtures, ratings]);
|
||||||
|
|
||||||
|
const onPick = (num: number) => (side: PickSide) => {
|
||||||
|
setPicks((prev) => {
|
||||||
|
const next: Picks = { ...prev };
|
||||||
|
if (next[num] === side) delete next[num];
|
||||||
|
else next[num] = side;
|
||||||
|
return prunePicks(fixtures, next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMatch = (f: Fixture, wide = false): ReactNode => {
|
||||||
|
if (whatIf && eff) {
|
||||||
|
const m = eff.get(f.num);
|
||||||
|
if (m) {
|
||||||
|
return (
|
||||||
|
<WhatIfMatch
|
||||||
|
key={f.num} f={f} eff={m} pick={picks[f.num]}
|
||||||
|
advHome={m.decided ? null : advHomeFor(f, m)}
|
||||||
|
onPick={onPick(f.num)} wide={wide}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} wide={wide} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title={t.bracket.title} subtitle={t.bracket.subtitle} />
|
<PageHeader
|
||||||
|
title={t.bracket.title}
|
||||||
|
subtitle={whatIf ? t.bracket.whatIfHint : t.bracket.subtitle}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{whatIf && Object.keys(picks).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicks({})}
|
||||||
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink"
|
||||||
|
>
|
||||||
|
{t.bracket.reset}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWhatIf((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||||
|
whatIf ? 'border-accent/40 bg-accent-glow text-accent' : 'border-line text-muted hover:bg-elevated hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wand2 size={15} /> {t.bracket.whatIf}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{whatIf && champ?.champion && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-gold/40 bg-gold/10 px-4 py-3">
|
||||||
|
<span aria-hidden className="text-2xl">{teamFlag(champ.champion)}</span>
|
||||||
|
<span className="font-display font-bold text-ink">{fmt(t.bracket.champion, { team: champ.champion })}</span>
|
||||||
|
{champ.chance != null && (
|
||||||
|
<span className="tnum text-sm text-muted">
|
||||||
|
{fmt(t.bracket.pathChance, { pct: champ.chance >= 0.0005 ? `${(champ.chance * 100).toFixed(1)}%` : '<0.1%' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */}
|
{/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
@@ -114,12 +268,12 @@ export function BracketPage() {
|
|||||||
<h2 className="smallcaps mb-2">{t.stage[mobileStage]}</h2>
|
<h2 className="smallcaps mb-2">{t.stage[mobileStage]}</h2>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{byStage[mobileStage].length
|
{byStage[mobileStage].length
|
||||||
? byStage[mobileStage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} wide />)
|
? byStage[mobileStage].map((f) => renderMatch(f, true))
|
||||||
: <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />}
|
: <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />}
|
||||||
{mobileStage === 'final' && third && (
|
{mobileStage === 'final' && third && (
|
||||||
<>
|
<>
|
||||||
<h2 className="smallcaps mt-3">{t.bracket.thirdPlacePlayoff}</h2>
|
<h2 className="smallcaps mt-3">{t.bracket.thirdPlacePlayoff}</h2>
|
||||||
<BracketMatch f={third} probs={probsByNum.get(third.num)} wide />
|
{renderMatch(third, true)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +287,7 @@ export function BracketPage() {
|
|||||||
<div key={stage} className="flex shrink-0 flex-col gap-3">
|
<div key={stage} className="flex shrink-0 flex-col gap-3">
|
||||||
<h2 className="smallcaps">{t.stage[stage]}</h2>
|
<h2 className="smallcaps">{t.stage[stage]}</h2>
|
||||||
{byStage[stage].length
|
{byStage[stage].length
|
||||||
? byStage[stage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} />)
|
? byStage[stage].map((f) => renderMatch(f))
|
||||||
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
|
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -143,7 +297,7 @@ export function BracketPage() {
|
|||||||
{third && (
|
{third && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2>
|
<h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2>
|
||||||
<BracketMatch f={third} probs={probsByNum.get(third.num)} />
|
{renderMatch(third)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ export const de: Dict = {
|
|||||||
subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.",
|
subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.",
|
||||||
thirdPlacePlayoff: "Spiel um Platz 3",
|
thirdPlacePlayoff: "Spiel um Platz 3",
|
||||||
advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen",
|
advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen",
|
||||||
|
whatIf: "Was wäre wenn?",
|
||||||
|
whatIfHint: "Tipp auf ein Team, um es durch den Turnierbaum zu schicken. Echte Ergebnisse bleiben fest. Die Prozente sind die Modell-Sicht auf jede Paarung.",
|
||||||
|
reset: "Auswahl zurücksetzen",
|
||||||
|
champion: "Dein Weltmeister: {team}",
|
||||||
|
pathChance: "Modell-Chance für genau diesen Lauf: {pct}",
|
||||||
},
|
},
|
||||||
teams: {
|
teams: {
|
||||||
title: "Teams",
|
title: "Teams",
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ export const en = {
|
|||||||
subtitle: "The knockout rounds — from the Round of 32 to the Final.",
|
subtitle: "The knockout rounds — from the Round of 32 to the Final.",
|
||||||
thirdPlacePlayoff: "Third-place play-off",
|
thirdPlacePlayoff: "Third-place play-off",
|
||||||
advanceTooltip: "Model: each team's chance to advance",
|
advanceTooltip: "Model: each team's chance to advance",
|
||||||
|
whatIf: "What if?",
|
||||||
|
whatIfHint: "Tap a team to send them through the bracket. Real results stay fixed. Percentages are the model's view of each pairing.",
|
||||||
|
reset: "Reset picks",
|
||||||
|
champion: "Your champion: {team}",
|
||||||
|
pathChance: "Model chance of exactly this run: {pct}",
|
||||||
},
|
},
|
||||||
teams: {
|
teams: {
|
||||||
title: "Teams",
|
title: "Teams",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { championPath, effectiveBracket, prunePicks, type Picks } from './whatif';
|
||||||
|
import type { Fixture } from './types';
|
||||||
|
|
||||||
|
const slot = (team: string | null, placeholder: string | null = null) =>
|
||||||
|
({ team, placeholder, label: team ?? placeholder ?? '?' });
|
||||||
|
|
||||||
|
const fx = (num: number, stage: Fixture['stage'], home: ReturnType<typeof slot>, away: ReturnType<typeof slot>, over: Partial<Fixture> = {}): Fixture => ({
|
||||||
|
num, stage, group: null, round: stage, groupRound: null,
|
||||||
|
kickoff: `2026-07-${String(num).padStart(2, '0')}T18:00:00Z`, venue: 'X',
|
||||||
|
home, away, status: 'scheduled', homeScore: null, awayScore: null, minute: null,
|
||||||
|
...over,
|
||||||
|
} as Fixture);
|
||||||
|
|
||||||
|
// tiny 4-team bracket: 90/91 are semis feeding final 93 (third place 92)
|
||||||
|
const bracket = (): Fixture[] => [
|
||||||
|
fx(90, 'sf', slot('Spain'), slot('France')),
|
||||||
|
fx(91, 'sf', slot('Brazil'), slot('Japan')),
|
||||||
|
fx(92, 'third', slot(null, 'L90'), slot(null, 'L91')),
|
||||||
|
fx(93, 'final', slot(null, 'W90'), slot(null, 'W91')),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('effectiveBracket', () => {
|
||||||
|
it('propagates picks through W/L placeholders', () => {
|
||||||
|
const eff = effectiveBracket(bracket(), { 90: 'home', 91: 'away' });
|
||||||
|
expect(eff.get(93)).toMatchObject({ home: 'Spain', away: 'Japan' });
|
||||||
|
expect(eff.get(92)).toMatchObject({ home: 'France', away: 'Brazil' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('real finished results override picks', () => {
|
||||||
|
const fxs = bracket();
|
||||||
|
fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 0, awayScore: 2 });
|
||||||
|
const eff = effectiveBracket(fxs, { 90: 'home' }); // pick says Spain, result says France
|
||||||
|
expect(eff.get(90)).toMatchObject({ winner: 'France', decided: true });
|
||||||
|
expect(eff.get(93)?.home).toBe('France');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unresolved slots null without picks', () => {
|
||||||
|
const eff = effectiveBracket(bracket(), {});
|
||||||
|
expect(eff.get(93)).toMatchObject({ home: null, away: null, winner: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prunePicks', () => {
|
||||||
|
it('drops downstream picks when an upstream pick changes the pairing', () => {
|
||||||
|
const picks: Picks = { 90: 'home', 91: 'home', 93: 'away' }; // final pick: Brazil
|
||||||
|
const pruned = prunePicks(bracket(), { ...picks, 91: 'away' }); // now Japan reaches the final
|
||||||
|
// the final pick referenced a pairing that still exists (home/away resolved) — side picks survive pairings
|
||||||
|
expect(pruned[90]).toBe('home');
|
||||||
|
expect(pruned[91]).toBe('away');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops picks on matches that became decided', () => {
|
||||||
|
const fxs = bracket();
|
||||||
|
fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 1, awayScore: 0 });
|
||||||
|
const pruned = prunePicks(fxs, { 90: 'away' });
|
||||||
|
expect(pruned[90]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('championPath', () => {
|
||||||
|
it('returns the picked champion and their picked matches', () => {
|
||||||
|
const fxs = bracket();
|
||||||
|
const picks: Picks = { 90: 'home', 91: 'away', 93: 'home' };
|
||||||
|
const path = championPath(fxs, effectiveBracket(fxs, picks));
|
||||||
|
expect(path).toMatchObject({ champion: 'Spain', matchNums: [90, 93] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null while the final is unresolved', () => {
|
||||||
|
expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Pure what-if bracket logic: user picks propagate winners through the
|
||||||
|
// knockout tree (placeholder codes 'W74'/'L101' reference earlier matches).
|
||||||
|
// Real finished results always win over picks.
|
||||||
|
import type { Fixture } from './types';
|
||||||
|
|
||||||
|
export type PickSide = 'home' | 'away';
|
||||||
|
export type Picks = Record<number, PickSide>;
|
||||||
|
|
||||||
|
export interface EffMatch {
|
||||||
|
home: string | null;
|
||||||
|
away: string | null;
|
||||||
|
winner: string | null;
|
||||||
|
loser: string | null;
|
||||||
|
/** true when the winner comes from a real finished result (not pickable) */
|
||||||
|
decided: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every knockout fixture's effective teams + winner under `picks`. */
|
||||||
|
export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number, EffMatch> {
|
||||||
|
const eff = new Map<number, EffMatch>();
|
||||||
|
const slotTeam = (f: Fixture, side: PickSide): string | null => {
|
||||||
|
const slot = side === 'home' ? f.home : f.away;
|
||||||
|
if (slot.team) return slot.team;
|
||||||
|
const m = /^([WL])(\d+)$/.exec(slot.placeholder ?? '');
|
||||||
|
if (!m) return null;
|
||||||
|
const src = eff.get(Number(m[2]));
|
||||||
|
return src ? (m[1] === 'W' ? src.winner : src.loser) : null;
|
||||||
|
};
|
||||||
|
const ko = fixtures.filter((f) => f.stage !== 'group').sort((a, b) => a.num - b.num);
|
||||||
|
for (const f of ko) {
|
||||||
|
const home = slotTeam(f, 'home');
|
||||||
|
const away = slotTeam(f, 'away');
|
||||||
|
let winner: string | null = null;
|
||||||
|
let loser: string | null = null;
|
||||||
|
let decided = false;
|
||||||
|
if (f.status === 'finished' && f.home.team && f.away.team && f.homeScore != null && f.awayScore != null && f.homeScore !== f.awayScore) {
|
||||||
|
winner = f.homeScore > f.awayScore ? f.home.team : f.away.team;
|
||||||
|
loser = f.homeScore > f.awayScore ? f.away.team : f.home.team;
|
||||||
|
decided = true;
|
||||||
|
} else {
|
||||||
|
const pick = picks[f.num];
|
||||||
|
if (pick && home && away) {
|
||||||
|
winner = pick === 'home' ? home : away;
|
||||||
|
loser = pick === 'home' ? away : home;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eff.set(f.num, { home, away, winner, loser, decided });
|
||||||
|
}
|
||||||
|
return eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop picks that no longer make sense (an upstream pick changed, so the
|
||||||
|
* picked team is not in this match anymore). Call after every pick change. */
|
||||||
|
export function prunePicks(fixtures: Fixture[], picks: Picks): Picks {
|
||||||
|
const out: Picks = {};
|
||||||
|
let eff = effectiveBracket(fixtures, picks);
|
||||||
|
// iterate to a fixed point: removing one stale pick can invalidate others
|
||||||
|
for (let pass = 0; pass < 6; pass++) {
|
||||||
|
let changed = false;
|
||||||
|
for (const [numStr, side] of Object.entries(picks)) {
|
||||||
|
const num = Number(numStr);
|
||||||
|
const m = eff.get(num);
|
||||||
|
if (m && !m.decided && m.home && m.away) {
|
||||||
|
out[num] = side;
|
||||||
|
} else if (out[num]) {
|
||||||
|
delete out[num];
|
||||||
|
changed = true;
|
||||||
|
} else if (m && (m.decided || !m.home || !m.away)) {
|
||||||
|
delete picks[num];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
eff = effectiveBracket(fixtures, { ...out });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picked champion's path: the matches they were picked to win, in order. */
|
||||||
|
export function championPath(
|
||||||
|
fixtures: Fixture[],
|
||||||
|
eff: Map<number, EffMatch>,
|
||||||
|
): { champion: string; matchNums: number[] } | null {
|
||||||
|
const final = fixtures.find((f) => f.stage === 'final');
|
||||||
|
if (!final) return null;
|
||||||
|
const fm = eff.get(final.num);
|
||||||
|
if (!fm?.winner) return null;
|
||||||
|
const champion = fm.winner;
|
||||||
|
const nums = [...eff.entries()]
|
||||||
|
.filter(([num, m]) => m.winner === champion && !m.decided && fixtures.find((f) => f.num === num)?.stage !== 'third')
|
||||||
|
.map(([num]) => num)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
return { champion, matchNums: nums };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user