Files
cup26/src/lib/scenarios.ts
T
NilsBriggen 005b4d72ca Group qualification scenarios: who's through, who needs what
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>
2026-06-12 01:01:42 +02:00

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,
};
});
}