441 行
14 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|