Add market watch and match hub workflows
这个提交包含在:
440
server/match.ts
普通文件
440
server/match.ts
普通文件
@@ -0,0 +1,440 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户