005b4d72ca
Once a group reaches its closing stretch (≤3 open matches), every remaining win/draw/loss combination is enumerated and ranked with the real tiebreaker logic. Under each table: Top 2 secured / Top 2 with a win / Still possible (n of N outcomes) / Can't reach the top 2 — with an honest footnote that tight cases hinge on goal difference and third place runs through the cross-group best-thirds race. Pure, tested enumeration in src/lib/scenarios.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
// Group qualification scenarios: enumerate every outcome combination of the
|
|
// remaining group matches (W/D/L with representative one-goal margins) and
|
|
// rank each hypothetical table. Top-2 is decidable inside a group; third place
|
|
// runs through the cross-group best-thirds race and is NOT claimed here.
|
|
import { rankGroup, type Result } from './standings';
|
|
import type { Fixture } from './types';
|
|
|
|
export interface TeamScenario {
|
|
team: string;
|
|
/** outcome combinations in which the team finishes in the top 2 */
|
|
top2Count: number;
|
|
totalCombos: number;
|
|
guaranteed: boolean;
|
|
alive: boolean;
|
|
/** top-2 in every combination where the team wins all its remaining matches
|
|
* (null when the team has nothing left to play) */
|
|
safeWithWin: boolean | null;
|
|
}
|
|
|
|
const OUTCOMES: [number, number][] = [[1, 0], [0, 0], [0, 1]];
|
|
|
|
/** Scenarios for one group, or null when enumeration isn't meaningful yet
|
|
* (nothing left to play, or more than 3 group matches still open). */
|
|
export function groupScenarios(teams: string[], groupFixtures: Fixture[]): TeamScenario[] | null {
|
|
const played: Result[] = [];
|
|
const remaining: { home: string; away: string }[] = [];
|
|
for (const f of groupFixtures) {
|
|
if (!f.home.team || !f.away.team) return null; // group slots must be known
|
|
if (f.status === 'finished' && f.homeScore != null && f.awayScore != null) {
|
|
played.push({ home: f.home.team, away: f.away.team, homeScore: f.homeScore, awayScore: f.awayScore });
|
|
} else {
|
|
remaining.push({ home: f.home.team, away: f.away.team });
|
|
}
|
|
}
|
|
if (remaining.length === 0 || remaining.length > 3) return null;
|
|
|
|
const total = 3 ** remaining.length;
|
|
const top2 = new Map<string, number>(teams.map((t) => [t, 0]));
|
|
const winCombos = new Map<string, { total: number; top2: number }>(teams.map((t) => [t, { total: 0, top2: 0 }]));
|
|
|
|
for (let combo = 0; combo < total; combo++) {
|
|
const assumed: Result[] = [];
|
|
const wonBy = new Set<string>();
|
|
const lostOrDrew = new Set<string>();
|
|
let c = combo;
|
|
for (const m of remaining) {
|
|
const [hs, as] = OUTCOMES[c % 3]!;
|
|
c = Math.floor(c / 3);
|
|
assumed.push({ home: m.home, away: m.away, homeScore: hs, awayScore: as });
|
|
if (hs > as) { wonBy.add(m.home); lostOrDrew.add(m.away); }
|
|
else if (as > hs) { wonBy.add(m.away); lostOrDrew.add(m.home); }
|
|
else { lostOrDrew.add(m.home); lostOrDrew.add(m.away); }
|
|
}
|
|
const table = rankGroup(teams, [...played, ...assumed]);
|
|
const topTwo = new Set(table.slice(0, 2).map((r) => r.team));
|
|
for (const t of teams) {
|
|
if (topTwo.has(t)) top2.set(t, top2.get(t)! + 1);
|
|
// a combo counts toward "with a win" when the team won every remaining match it plays in
|
|
const plays = remaining.some((m) => m.home === t || m.away === t);
|
|
if (plays && wonBy.has(t) && !lostOrDrew.has(t)) {
|
|
const w = winCombos.get(t)!;
|
|
w.total++;
|
|
if (topTwo.has(t)) w.top2++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return teams.map((team) => {
|
|
const n = top2.get(team)!;
|
|
const w = winCombos.get(team)!;
|
|
return {
|
|
team,
|
|
top2Count: n,
|
|
totalCombos: total,
|
|
guaranteed: n === total,
|
|
alive: n > 0,
|
|
safeWithWin: w.total === 0 ? null : w.top2 === w.total,
|
|
};
|
|
});
|
|
}
|