From 975cc5f1d9a669269f39f5ce34a4ef1b19728dee Mon Sep 17 00:00:00 2001 From: NextJS Date: Sat, 23 May 2026 15:03:50 +0000 Subject: [PATCH] analysis complete --- package.json | 2 +- src/components/admin-dashboard.tsx | 34 ++--- src/components/admin-match-analysis.tsx | 133 ++++++++++++++++-- src/components/match-analysis-emote-chart.tsx | 30 ++-- src/lib/match-emoji-labels.ts | 19 +++ src/lib/match-log-analysis-server.ts | 106 ++++++++++---- src/lib/match-log-parser.ts | 33 ++++- 7 files changed, 291 insertions(+), 66 deletions(-) create mode 100644 src/lib/match-emoji-labels.ts diff --git a/package.json b/package.json index cc0cd03..8078c42 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 2613", + "dev": "next dev -p 2614", "build": "next build", "start": "next start -p 2613", "lint": "eslint" diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index 347b504..b41dd35 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -411,6 +411,23 @@ export function AdminDashboard({ : null} ) + 0 + ? analysisPlayerIds.join(",") + : null, + })} + className={tabClass(tab === "analysis")} + scroll={false} + > + Analysis + Ledger - 0 - ? analysisPlayerIds.join(",") - : null, - })} - className={tabClass(tab === "analysis")} - scroll={false} - > - Analysis - 0 + ? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100 + : null; + return (
-

- Aggregates gameplay metrics from per-match log files ( - - MATCH_LOGS_DIR - - /{" "} - <matchId>.txt). Match length is - first-to-last log timestamp; each{" "} - launching puck … force line counts as - one shot. -

- {analysis.configError ? (
+ {analysis.mirrorExceptionDisconnectMatchIds.length > 0 ? ( +
+

+ Mirror exception disconnect ( + {analysis.mirrorExceptionDisconnectMatchIds.length.toLocaleString( + "en-US", + )}{" "} + {analysis.mirrorExceptionDisconnectMatchIds.length === 1 + ? "match" + : "matches"} + ) +

+

+ These matches include a{" "} + [Mirror/Error] line where a + player was disconnected because handling a command caused an + exception. +

+
    + {analysis.mirrorExceptionDisconnectMatchIds.map((matchId) => ( +
  • + + Match {matchId} + +
  • + ))} +
+
+ ) : null} +
+ + {analysis.matchesWithLogFiles > 0 ? ( +
+
+

+ Match completion (from logs) +

+

+ Winner logged:{" "} + + {analysis.matchesWithWinnerPatch.toLocaleString("en-US")} + + {" / "} + + {analysis.matchesWithLogFiles.toLocaleString("en-US")} + + {winnerRatioPct != null ? ( + <> + {" "} + ( + + {winnerRatioPct.toLocaleString("en-US", { + maximumFractionDigits: 1, + })} + % + + ) + + ) : null} +

+

+ A match ended when its log contains{" "} + + Dedicated match winner PATCH success + + . +

+
+ + {analysis.notEndedMatchIds.length > 0 ? ( +
+

+ Not ended ( + {analysis.notEndedMatchIds.length.toLocaleString("en-US")}{" "} + {analysis.notEndedMatchIds.length === 1 ? "match" : "matches"}) +

+

+ Log file present but no winner PATCH line — the match did not + finish normally. +

+
    + {analysis.notEndedMatchIds.map((matchId) => ( +
  • + + Match {matchId} + +
  • + ))} +
+
+ ) : ( +

+ Every match with a log file includes a winner PATCH line. +

+ )} +
+ ) : null}
); } diff --git a/src/components/match-analysis-emote-chart.tsx b/src/components/match-analysis-emote-chart.tsx index aa32b92..1eae83c 100644 --- a/src/components/match-analysis-emote-chart.tsx +++ b/src/components/match-analysis-emote-chart.tsx @@ -3,7 +3,6 @@ import type { EmoteBarRow } from "@/lib/match-log-parser"; const ROW_H = 22; -const LABEL_W = 120; const BAR_MAX_W = 180; const PAD = 4; @@ -12,12 +11,21 @@ type Props = { /** e.g. "Text emotes" */ title: string; barClassName?: string; + /** Wider labels for emoji glyphs + `[id]` (default 120). */ + labelWidth?: number; + /** Max label characters before ellipsis (default 16). */ + labelMaxLen?: number; + /** Use monospace for label text (default true). */ + labelMonospace?: boolean; }; export function MatchAnalysisEmoteChart({ rows, title, barClassName = "fill-violet-500 dark:fill-violet-400", + labelWidth = 120, + labelMaxLen = 16, + labelMonospace = true, }: Props) { if (rows.length === 0) { return ( @@ -29,7 +37,7 @@ export function MatchAnalysisEmoteChart({ const max = Math.max(...rows.map((r) => r.count), 1); const vbH = PAD * 2 + rows.length * ROW_H; - const vbW = LABEL_W + BAR_MAX_W + 48; + const vbW = labelWidth + BAR_MAX_W + 48; return (
@@ -42,8 +50,8 @@ export function MatchAnalysisEmoteChart({ const y = PAD + i * ROW_H + ROW_H / 2; const barW = (row.count / max) * BAR_MAX_W; const label = - row.label.length > 16 - ? `${row.label.slice(0, 15)}…` + row.label.length > labelMaxLen + ? `${row.label.slice(0, labelMaxLen - 1)}…` : row.label; return ( @@ -51,13 +59,19 @@ export function MatchAnalysisEmoteChart({ x={0} y={y} dominantBaseline="middle" - className="fill-zinc-600 text-[9px] dark:fill-zinc-400" - style={{ fontFamily: "ui-monospace, monospace" }} + className={`fill-zinc-600 dark:fill-zinc-400 ${ + labelMonospace ? "text-[9px]" : "text-[11px]" + }`} + style={ + labelMonospace + ? { fontFamily: "ui-monospace, monospace" } + : undefined + } > {label} = { + 0: "😂", // laughing crying + 1: "😢", // crying + 2: "💀", // dead skull + 3: "😈", // evil + 4: "🤬", // angry swearing + 5: "🤑", // money face +}; + +/** Chart / aggregate label: glyph plus id in brackets, e.g. `😂 [0]`. */ +export function formatEmojiEmoteLabel(id: string): string { + const n = Number(id); + const glyph = + Number.isInteger(n) && Object.hasOwn(EMOJI_GLYPH_BY_ID, n) + ? EMOJI_GLYPH_BY_ID[n]! + : "?"; + return `${glyph} [${id}]`; +} diff --git a/src/lib/match-log-analysis-server.ts b/src/lib/match-log-analysis-server.ts index 11d235d..8a0b45d 100644 --- a/src/lib/match-log-analysis-server.ts +++ b/src/lib/match-log-analysis-server.ts @@ -3,6 +3,8 @@ import { readMatchLogFile } from "@/lib/match-logs-server"; import { aggregateNumeric, emptyMatchLogAnalysisResult, + matchLogHasMirrorExceptionDisconnect, + matchLogHasWinnerPatch, mergeEmoteCounts, parseMatchLogContent, type MatchLogAnalysisResult, @@ -110,35 +112,85 @@ export async function analyzeMatchLogsForMatches( const textMaps: Record[] = []; const emojiMaps: Record[] = []; + const scanResults = await Promise.all( + toScan.map(async (m) => { + const id = Number(m.id); + if (!Number.isFinite(id)) { + return { + kind: "invalid" as const, + }; + } + const res = await readMatchLogFile(id); + if (!res.ok) { + return { + kind: "missing" as const, + status: res.status, + }; + } + const mirrorExceptionDisconnect = matchLogHasMirrorExceptionDisconnect( + res.content, + ); + const winnerPatchLogged = matchLogHasWinnerPatch(res.content); + const parsed = parseMatchLogContent(res.content); + if (!parsed) { + return { + kind: "unparsed" as const, + matchId: id, + mirrorExceptionDisconnect, + winnerPatchLogged, + }; + } + return { + kind: "ok" as const, + matchId: id, + parsed, + mirrorExceptionDisconnect, + winnerPatchLogged, + }; + }), + ); + let matchesWithLogs = 0; let matchesMissingLogs = 0; let matchesSkippedTooLarge = 0; + let matchesWithLogFiles = 0; + let matchesWithWinnerPatch = 0; + const mirrorExceptionDisconnectMatchIds: number[] = []; + const notEndedMatchIds: number[] = []; - await Promise.all( - toScan.map(async (m) => { - const id = Number(m.id); - if (!Number.isFinite(id)) return; - const res = await readMatchLogFile(id); - if (!res.ok) { - if (res.status === 404) matchesMissingLogs += 1; - else if (res.status === 413) matchesSkippedTooLarge += 1; - else matchesMissingLogs += 1; - return; - } - const parsed = parseMatchLogContent(res.content); - if (!parsed) { - matchesMissingLogs += 1; - return; - } - matchesWithLogs += 1; - durations.push(parsed.durationSeconds); - shotCounts.push(parsed.shotCount); - forceMagnitudes.push(...parsed.forceMagnitudes); - forcePoints.push(...parsed.forceVectors); - textMaps.push(parsed.textEmotes); - emojiMaps.push(parsed.emojiEmotes); - }), - ); + for (const row of scanResults) { + if (row.kind === "invalid") continue; + if (row.kind === "missing") { + if (row.status === 413) matchesSkippedTooLarge += 1; + else matchesMissingLogs += 1; + continue; + } + matchesWithLogFiles += 1; + if (row.winnerPatchLogged) { + matchesWithWinnerPatch += 1; + } else { + notEndedMatchIds.push(row.matchId); + } + if (row.mirrorExceptionDisconnect) { + mirrorExceptionDisconnectMatchIds.push(row.matchId); + } + if (row.kind === "unparsed") { + matchesMissingLogs += 1; + continue; + } + matchesWithLogs += 1; + if (row.winnerPatchLogged) { + durations.push(row.parsed.durationSeconds); + shotCounts.push(row.parsed.shotCount); + } + forceMagnitudes.push(...row.parsed.forceMagnitudes); + forcePoints.push(...row.parsed.forceVectors); + textMaps.push(row.parsed.textEmotes); + emojiMaps.push(row.parsed.emojiEmotes); + } + + mirrorExceptionDisconnectMatchIds.sort((a, b) => a - b); + notEndedMatchIds.sort((a, b) => a - b); return { matchesInFilter: filtered.length, @@ -152,6 +204,10 @@ export async function analyzeMatchLogsForMatches( forcePoints, textEmotes: mergeEmoteCounts(textMaps), emojiEmotes: mergeEmoteCounts(emojiMaps), + mirrorExceptionDisconnectMatchIds, + matchesWithLogFiles, + matchesWithWinnerPatch, + notEndedMatchIds, configError: null, }; } diff --git a/src/lib/match-log-parser.ts b/src/lib/match-log-parser.ts index 230a96d..942775f 100644 --- a/src/lib/match-log-parser.ts +++ b/src/lib/match-log-parser.ts @@ -1,3 +1,5 @@ +import { formatEmojiEmoteLabel } from "@/lib/match-emoji-labels"; + /** Parsed metrics from a single match log file. */ export type ParsedMatchLog = { durationSeconds: number; @@ -18,6 +20,23 @@ const TEXT_EMOTE = /Client \d+ sent text emote (.+)$/; const EMOJI_EMOTE = /Client \d+ sent emoji emote (\d+)$/; +/** Mirror disconnect after a command handler threw (see `[Mirror/Error]` log lines). */ +const MIRROR_EXCEPTION_DISCONNECT = + /\[Mirror\/Error\][^\n]*Disconnecting connection[^\n]*caused an Exception/i; + +/** True when the log records a player disconnect caused by a Mirror command exception. */ +export function matchLogHasMirrorExceptionDisconnect(content: string): boolean { + return MIRROR_EXCEPTION_DISCONNECT.test(content); +} + +/** Log line written when the dedicated server records the match winner. */ +const WINNER_PATCH_SUCCESS = /Dedicated match winner PATCH success/; + +/** True when the match log includes the post-win winner PATCH line. */ +export function matchLogHasWinnerPatch(content: string): boolean { + return WINNER_PATCH_SUCCESS.test(content); +} + function parseLogTimestamp( month: string, day: string, @@ -106,7 +125,7 @@ export function parseMatchLogContent(content: string): ParsedMatchLog | null { const emoji = EMOJI_EMOTE.exec(body); if (emoji) { const id = emoji[1]!; - const key = `Emoji ${id}`; + const key = formatEmojiEmoteLabel(id); emojiEmotes[key] = (emojiEmotes[key] ?? 0) + 1; } } @@ -189,6 +208,14 @@ export type MatchLogAnalysisResult = { forcePoints: { x: number; y: number }[]; textEmotes: EmoteBarRow[]; emojiEmotes: EmoteBarRow[]; + /** Match ids (in filter range, among scanned logs) with a Mirror exception disconnect. */ + mirrorExceptionDisconnectMatchIds: number[]; + /** Matches with a log file read (parsed or not). */ + matchesWithLogFiles: number; + /** Subset of `matchesWithLogFiles` that include the winner PATCH line. */ + matchesWithWinnerPatch: number; + /** Log read but no winner PATCH — match did not end normally. */ + notEndedMatchIds: number[]; configError: string | null; }; @@ -207,6 +234,10 @@ export function emptyMatchLogAnalysisResult( forcePoints: [], textEmotes: [], emojiEmotes: [], + mirrorExceptionDisconnectMatchIds: [], + matchesWithLogFiles: 0, + matchesWithWinnerPatch: 0, + notEndedMatchIds: [], configError, }; }