export const MATCH_PLAYER_SLOTS = ["player_a", "player_b"] as const; export type MatchPlayerSlot = (typeof MATCH_PLAYER_SLOTS)[number]; export type SlotMap = Record; export type MatchScoreboard = { sets: SlotMap; games: SlotMap; points: SlotMap; winnerSlot: MatchPlayerSlot | null; confidence: number; }; export type MatchPlayerMetrics = { pointsWon: number; aces: number; doubleFaults: number; winners: number; unforcedErrors: number; breakPointsWon: number; breakPointsTotal: number; firstServeIn: number; firstServeAttempts: number; firstServePct: number; maxServeKph: number; longestRally: number; }; export type MatchMetrics = { players: SlotMap; totalRallies: number; longestRally: number; sourceCount: number; }; export type MatchSettlement = { winnerSlot: MatchPlayerSlot | null; players: SlotMap<{ setsWon: number; gamesWon: number; pointsWon: number; isWinner: boolean; stats: MatchPlayerMetrics; }>; summary: { totalRallies: number; longestRally: number; }; }; export type MatchEventInput = { eventType: string; winnerSlot?: MatchPlayerSlot | null; confidence?: number | null; payload?: unknown; source?: string | null; eventIndex?: number | null; }; function clampNumber(value: unknown, fallback = 0) { if (typeof value !== "number" || Number.isNaN(value)) { return fallback; } return value; } function toSlotMap(factory: () => T): SlotMap { return { player_a: factory(), player_b: factory(), }; } export function createEmptyMatchPlayerMetrics(): MatchPlayerMetrics { return { pointsWon: 0, aces: 0, doubleFaults: 0, winners: 0, unforcedErrors: 0, breakPointsWon: 0, breakPointsTotal: 0, firstServeIn: 0, firstServeAttempts: 0, firstServePct: 0, maxServeKph: 0, longestRally: 0, }; } export function createEmptyMatchMetrics(): MatchMetrics { return { players: toSlotMap(() => createEmptyMatchPlayerMetrics()), totalRallies: 0, longestRally: 0, sourceCount: 0, }; } export function createEmptyMatchScoreboard(): MatchScoreboard { return { sets: toSlotMap(() => 0), games: toSlotMap(() => 0), points: toSlotMap(() => 0), winnerSlot: null, confidence: 0, }; } function normalizePlayerMetrics(raw: unknown): MatchPlayerMetrics { const source = (raw && typeof raw === "object" ? raw : {}) as Record; const firstServeIn = clampNumber(source.firstServeIn); const firstServeAttempts = clampNumber(source.firstServeAttempts); const firstServePct = firstServeAttempts > 0 ? Math.round((firstServeIn / firstServeAttempts) * 1000) / 10 : clampNumber(source.firstServePct); return { pointsWon: clampNumber(source.pointsWon), aces: clampNumber(source.aces), doubleFaults: clampNumber(source.doubleFaults), winners: clampNumber(source.winners), unforcedErrors: clampNumber(source.unforcedErrors), breakPointsWon: clampNumber(source.breakPointsWon), breakPointsTotal: clampNumber(source.breakPointsTotal), firstServeIn, firstServeAttempts, firstServePct, maxServeKph: clampNumber(source.maxServeKph), longestRally: clampNumber(source.longestRally), }; } export function normalizeMatchMetrics(raw: unknown): MatchMetrics { const source = (raw && typeof raw === "object" ? raw : {}) as Record; const playersRaw = (source.players && typeof source.players === "object" ? source.players : source) as Record; const players = { player_a: normalizePlayerMetrics(playersRaw.player_a), player_b: normalizePlayerMetrics(playersRaw.player_b), }; const totalRallies = clampNumber(source.totalRallies); const longestRally = Math.max( clampNumber(source.longestRally), players.player_a.longestRally, players.player_b.longestRally, ); const sourceCount = clampNumber(source.sourceCount); return { players, totalRallies, longestRally, sourceCount, }; } export function normalizeMatchScoreboard(raw: unknown): MatchScoreboard { const source = (raw && typeof raw === "object" ? raw : {}) as Record; const sets = (source.sets && typeof source.sets === "object" ? source.sets : source) as Record; const games = (source.games && typeof source.games === "object" ? source.games : source) as Record; const points = (source.points && typeof source.points === "object" ? source.points : source) as Record; const winnerSlot = source.winnerSlot === "player_a" || source.winnerSlot === "player_b" ? source.winnerSlot : null; const normalized = { sets: { player_a: clampNumber(sets.player_a ?? source.playerASetCount), player_b: clampNumber(sets.player_b ?? source.playerBSetCount), }, games: { player_a: clampNumber(games.player_a ?? source.playerAGameCount), player_b: clampNumber(games.player_b ?? source.playerBGameCount), }, points: { player_a: clampNumber(points.player_a ?? source.playerAPointCount), player_b: clampNumber(points.player_b ?? source.playerBPointCount), }, winnerSlot, confidence: Math.max(0, Math.min(1, clampNumber(source.confidence))), } satisfies MatchScoreboard; return applyWinnerFallback(normalized); } function applyWinnerFallback(scoreboard: MatchScoreboard): MatchScoreboard { if (scoreboard.winnerSlot) { return scoreboard; } const dimensions: Array> = ["sets", "games", "points"]; for (const key of dimensions) { const a = scoreboard[key].player_a; const b = scoreboard[key].player_b; if (a > b) { return { ...scoreboard, winnerSlot: "player_a" }; } if (b > a) { return { ...scoreboard, winnerSlot: "player_b" }; } } return scoreboard; } function addMetricPatch(base: MatchPlayerMetrics, patch: Partial) { base.pointsWon += clampNumber(patch.pointsWon); base.aces += clampNumber(patch.aces); base.doubleFaults += clampNumber(patch.doubleFaults); base.winners += clampNumber(patch.winners); base.unforcedErrors += clampNumber(patch.unforcedErrors); base.breakPointsWon += clampNumber(patch.breakPointsWon); base.breakPointsTotal += clampNumber(patch.breakPointsTotal); base.firstServeIn += clampNumber(patch.firstServeIn); base.firstServeAttempts += clampNumber(patch.firstServeAttempts); base.maxServeKph = Math.max(base.maxServeKph, clampNumber(patch.maxServeKph)); base.longestRally = Math.max(base.longestRally, clampNumber(patch.longestRally)); } function mergeMetricSnapshot(base: MatchPlayerMetrics, snapshot: MatchPlayerMetrics) { base.pointsWon = Math.max(base.pointsWon, snapshot.pointsWon); base.aces = Math.max(base.aces, snapshot.aces); base.doubleFaults = Math.max(base.doubleFaults, snapshot.doubleFaults); base.winners = Math.max(base.winners, snapshot.winners); base.unforcedErrors = Math.max(base.unforcedErrors, snapshot.unforcedErrors); base.breakPointsWon = Math.max(base.breakPointsWon, snapshot.breakPointsWon); base.breakPointsTotal = Math.max(base.breakPointsTotal, snapshot.breakPointsTotal); base.firstServeIn = Math.max(base.firstServeIn, snapshot.firstServeIn); base.firstServeAttempts = Math.max(base.firstServeAttempts, snapshot.firstServeAttempts); base.maxServeKph = Math.max(base.maxServeKph, snapshot.maxServeKph); base.longestRally = Math.max(base.longestRally, snapshot.longestRally); } function finalizeMetrics(metrics: MatchMetrics) { for (const slot of MATCH_PLAYER_SLOTS) { const row = metrics.players[slot]; row.firstServePct = row.firstServeAttempts > 0 ? Math.round((row.firstServeIn / row.firstServeAttempts) * 1000) / 10 : 0; metrics.longestRally = Math.max(metrics.longestRally, row.longestRally); } return metrics; } function applyScoreSnapshot(current: MatchScoreboard, raw: unknown, confidence: number) { const next = normalizeMatchScoreboard(raw); if (next.confidence <= 0) { next.confidence = confidence; } if (next.confidence >= current.confidence) { return applyWinnerFallback(next); } return current; } export function deriveSuggestedMatchState(events: MatchEventInput[]): { score: MatchScoreboard; metrics: MatchMetrics; eventCount: number; sourceCount: number; } { const ordered = [...events].sort((a, b) => (a.eventIndex ?? 0) - (b.eventIndex ?? 0)); const score = createEmptyMatchScoreboard(); const metrics = createEmptyMatchMetrics(); const sources = new Set(); for (const event of ordered) { if (event.source) { sources.add(event.source); } const confidence = Math.max(0, Math.min(1, event.confidence ?? 0.55)); const payload = (event.payload && typeof event.payload === "object" ? event.payload : {}) as Record; switch (event.eventType) { case "point": { if (event.winnerSlot) { score.points[event.winnerSlot] += 1; metrics.players[event.winnerSlot].pointsWon += 1; } const rallyCount = clampNumber(payload.rallyCount); if (rallyCount > 0) { metrics.totalRallies += 1; metrics.longestRally = Math.max(metrics.longestRally, rallyCount); if (event.winnerSlot) { metrics.players[event.winnerSlot].longestRally = Math.max( metrics.players[event.winnerSlot].longestRally, rallyCount, ); } } if (event.winnerSlot) { addMetricPatch(metrics.players[event.winnerSlot], { aces: payload.isAce ? 1 : 0, winners: payload.isWinner ? 1 : 0, breakPointsWon: payload.isBreakPoint ? 1 : 0, breakPointsTotal: payload.isBreakPoint ? 1 : 0, firstServeIn: payload.firstServeIn ? 1 : 0, firstServeAttempts: payload.firstServeAttempt ? 1 : 0, maxServeKph: clampNumber(payload.serveSpeedKph), longestRally: rallyCount, }); } if (payload.doubleFaultBy === "player_a" || payload.doubleFaultBy === "player_b") { metrics.players[payload.doubleFaultBy].doubleFaults += 1; } if (payload.scoreboard) { const next = applyScoreSnapshot(score, payload.scoreboard, confidence); score.sets = next.sets; score.games = next.games; score.points = next.points; score.winnerSlot = next.winnerSlot; score.confidence = next.confidence; } break; } case "game": { if (event.winnerSlot) { score.games[event.winnerSlot] += 1; score.points.player_a = 0; score.points.player_b = 0; } if (payload.scoreboard) { const next = applyScoreSnapshot(score, payload.scoreboard, confidence); score.sets = next.sets; score.games = next.games; score.points = next.points; score.winnerSlot = next.winnerSlot; score.confidence = next.confidence; } break; } case "set": { if (event.winnerSlot) { score.sets[event.winnerSlot] += 1; } if (payload.scoreboard) { const next = applyScoreSnapshot(score, payload.scoreboard, confidence); score.sets = next.sets; score.games = next.games; score.points = next.points; score.winnerSlot = next.winnerSlot; score.confidence = next.confidence; } break; } case "metric": { const slot: MatchPlayerSlot | null = payload.playerSlot === "player_a" || payload.playerSlot === "player_b" ? payload.playerSlot : event.winnerSlot ?? null; const metricMode = payload.metricMode === "delta" ? "delta" : "snapshot"; if (slot) { const normalized = normalizePlayerMetrics(payload.metrics); if (metricMode === "delta") { addMetricPatch(metrics.players[slot], normalized); } else { mergeMetricSnapshot(metrics.players[slot], normalized); } } if (typeof payload.totalRallies === "number") { metrics.totalRallies = Math.max(metrics.totalRallies, payload.totalRallies); } if (typeof payload.longestRally === "number") { metrics.longestRally = Math.max(metrics.longestRally, payload.longestRally); } break; } case "score_suggestion": case "review_adjustment": case "finalized": { const next = applyScoreSnapshot(score, payload.score ?? payload.scoreboard ?? payload, confidence); score.sets = next.sets; score.games = next.games; score.points = next.points; score.winnerSlot = next.winnerSlot; score.confidence = event.eventType === "finalized" ? 1 : next.confidence; const metricSnapshot = normalizeMatchMetrics(payload.metrics ?? payload.playerMetrics ?? payload); for (const slot of MATCH_PLAYER_SLOTS) { mergeMetricSnapshot(metrics.players[slot], metricSnapshot.players[slot]); } metrics.totalRallies = Math.max(metrics.totalRallies, metricSnapshot.totalRallies); metrics.longestRally = Math.max(metrics.longestRally, metricSnapshot.longestRally); break; } default: break; } } metrics.sourceCount = sources.size; score.confidence = Math.max(score.confidence, ordered.length > 0 ? Math.min(0.98, 0.45 + ordered.length * 0.05) : 0); return { score: applyWinnerFallback(score), metrics: finalizeMetrics(metrics), eventCount: ordered.length, sourceCount: sources.size, }; } export function buildParticipantSettlement(scoreRaw: unknown, metricsRaw: unknown): MatchSettlement { const score = normalizeMatchScoreboard(scoreRaw); const metrics = finalizeMetrics(normalizeMatchMetrics(metricsRaw)); return { winnerSlot: score.winnerSlot, players: { player_a: { setsWon: score.sets.player_a, gamesWon: score.games.player_a, pointsWon: Math.max(score.points.player_a, metrics.players.player_a.pointsWon), isWinner: score.winnerSlot === "player_a", stats: metrics.players.player_a, }, player_b: { setsWon: score.sets.player_b, gamesWon: score.games.player_b, pointsWon: Math.max(score.points.player_b, metrics.players.player_b.pointsWon), isWinner: score.winnerSlot === "player_b", stats: metrics.players.player_b, }, }, summary: { totalRallies: metrics.totalRallies, longestRally: metrics.longestRally, }, }; }