import { readFileSync, existsSync } from 'node:fs'; import { canonicalTeam } from '../../src/lib/teams'; import { computeGroupTables } from '../../src/lib/standings'; import type { Fixture, FixturesFile, MatchStatus, Snapshot, Stage } from '../../src/lib/types'; /** A normalized live match from any source (football-data, sofascore, dev). */ export interface LiveMatch { home: string; away: string; group: string | null; stage: Stage; /** ISO kickoff if the source provides it (helps match knockout fixtures). */ kickoff: string | null; homeScore: number | null; awayScore: number | null; status: MatchStatus; minute: number | null; } export type Source = Snapshot['source']; /** Candidate locations for the generated fixtures.json (prod dist, then dev public). */ function loadFixturesFile(staticDir: string): FixturesFile { const candidates = [ `${staticDir}/data/fixtures.json`, `${process.cwd()}/public/data/fixtures.json`, `${process.cwd()}/dist/data/fixtures.json`, ]; for (const p of candidates) { if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as FixturesFile; } throw new Error(`fixtures.json not found. Run "npm run data:fixtures". Looked in: ${candidates.join(', ')}`); } const utcDate = (iso: string): string => iso.slice(0, 10); /** In-memory tournament: the static seed plus any live overlay. */ export class TournamentState { readonly season: string; private fixtures: Fixture[]; private byNum: Map; updatedAt: string | null = null; source: Source = 'seed'; constructor(staticDir: string) { const file = loadFixturesFile(staticDir); this.season = file.season; this.fixtures = file.fixtures; this.byNum = new Map(this.fixtures.map((f) => [f.num, f])); } snapshot(): Snapshot { return { season: this.season, updatedAt: this.updatedAt, source: this.source, fixtures: this.fixtures, tables: computeGroupTables(this.fixtures), }; } getFixture(num: number): Fixture | undefined { return this.byNum.get(num); } /** The live fixtures array (for the model engine). */ allFixtures(): Fixture[] { return this.fixtures; } /** Which fixture number does a (home, away) pairing map to? Used by the * ingestion layer to key provider event ids against our fixtures. */ matchFixtureNum(home: string, away: string, stage: Fixture['stage'] = 'group', kickoff: string | null = null): number | null { const f = this.findFixture({ home, away, group: null, stage, kickoff, homeScore: null, awayScore: null, status: 'scheduled', minute: null, }); return f?.num ?? null; } /** True if any match is live or kicks off within `windowMs` of now — used to * poll the APIs often during match windows and rarely when nothing is on. */ hasLiveActivity(windowMs: number): boolean { const now = Date.now(); return this.fixtures.some( (f) => f.status === 'live' || Math.abs(new Date(f.kickoff).getTime() - now) <= windowMs, ); } /** True only while a match is actually in play. */ hasLive(): boolean { return this.fixtures.some((f) => f.status === 'live'); } /** Directly set a fixture's score/status (used by the dev injection endpoint). */ applyScore( num: number, homeScore: number | null, awayScore: number | null, status: MatchStatus, minute: number | null, source: Source, ): Fixture | null { const f = this.byNum.get(num); if (!f) return null; f.homeScore = homeScore; f.awayScore = awayScore; f.status = status; f.minute = minute; this.updatedAt = new Date().toISOString(); this.source = source; return f; } /** * Merge a batch of live matches onto the seed. Group matches match by group + * unordered team pair; knockout matches by kickoff date + team overlap. Returns * the fixtures that actually changed. */ mergeLive(matches: LiveMatch[], source: Source): Fixture[] { const changed: Fixture[] = []; for (const lm of matches) { const f = this.findFixture(lm); if (!f) continue; if (this.applyLive(f, lm)) changed.push(f); } if (changed.length) { this.updatedAt = new Date().toISOString(); this.source = source; } return changed; } private findFixture(lm: LiveMatch): Fixture | undefined { const home = canonicalTeam(lm.home); const away = canonicalTeam(lm.away); // 1) Strongest signal: an unordered match of two known team names. Works for // every group game and any already-resolved knockout, regardless of the // source's stage hint (SofaScore's is unreliable). const byPair = this.fixtures.find( (f) => f.home.team && f.away.team && ((f.home.team === home && f.away.team === away) || (f.home.team === away && f.away.team === home)), ); if (byPair) return byPair; // 2) Knockout slot not yet filled in the seed: match by stage + kickoff date, // preferring a fixture whose slots are still unresolved. if (lm.stage !== 'group') { const sameStage = this.fixtures.filter( (f) => f.stage === lm.stage && (lm.kickoff ? utcDate(f.kickoff) === utcDate(lm.kickoff) : true), ); if (sameStage.length === 1) return sameStage[0]; return sameStage.find((f) => !f.home.team || !f.away.team) ?? sameStage[0]; } return undefined; } /** Overlay a live match onto a fixture, orienting scores to the seed's home/away. */ private applyLive(f: Fixture, lm: LiveMatch): boolean { const home = canonicalTeam(lm.home); const away = canonicalTeam(lm.away); // Fill concrete teams for resolved knockout slots. if (!f.home.team && !f.away.team) { f.home = { team: home, placeholder: f.home.placeholder, label: home }; f.away = { team: away, placeholder: f.away.placeholder, label: away }; } const swapped = f.home.team === away && f.away.team === home; const hs = swapped ? lm.awayScore : lm.homeScore; const as = swapped ? lm.homeScore : lm.awayScore; const before = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`; f.homeScore = hs; f.awayScore = as; f.status = lm.status; f.minute = lm.minute; const after = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`; return before !== after; } }