文件
tennis-training-hub/server/match.ts
2026-04-07 11:00:03 +08:00

441 行
14 KiB
TypeScript

export const MATCH_PLAYER_SLOTS = ["player_a", "player_b"] as const;
export type MatchPlayerSlot = (typeof MATCH_PLAYER_SLOTS)[number];
export type SlotMap<T> = Record<MatchPlayerSlot, T>;
export type MatchScoreboard = {
sets: SlotMap<number>;
games: SlotMap<number>;
points: SlotMap<number>;
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<MatchPlayerMetrics>;
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<T>(factory: () => T): SlotMap<T> {
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<string, unknown>;
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<string, unknown>;
const playersRaw = (source.players && typeof source.players === "object" ? source.players : source) as Record<string, unknown>;
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<string, unknown>;
const sets = (source.sets && typeof source.sets === "object" ? source.sets : source) as Record<string, unknown>;
const games = (source.games && typeof source.games === "object" ? source.games : source) as Record<string, unknown>;
const points = (source.points && typeof source.points === "object" ? source.points : source) as Record<string, unknown>;
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<keyof Pick<MatchScoreboard, "sets" | "games" | "points">> = ["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<MatchPlayerMetrics>) {
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<string>();
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<string, unknown>;
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,
},
};
}