analysis complete

This commit is contained in:
NextJS
2026-05-23 15:03:50 +00:00
parent 9bb0af59b0
commit 975cc5f1d9
7 changed files with 291 additions and 66 deletions
+1 -1
View File
@@ -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"
+17 -17
View File
@@ -411,6 +411,23 @@ export function AdminDashboard({
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
<Link
href={buildDashboardHref({
tab: "matchmaker",
@@ -433,23 +450,6 @@ export function AdminDashboard({
>
Ledger
</Link>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
<Link
href="/settings"
className={tabClass(false)}
+119 -14
View File
@@ -110,20 +110,13 @@ export function AdminMatchAnalysis({
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
);
const winnerRatioPct =
analysis.matchesWithLogFiles > 0
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
return (
<section className="space-y-6">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Aggregates gameplay metrics from per-match log files (
<span className="font-mono text-zinc-700 dark:text-zinc-300">
MATCH_LOGS_DIR
</span>
/{" "}
<span className="font-mono">&lt;matchId&gt;.txt</span>). Match length is
first-to-last log timestamp; each{" "}
<span className="font-mono">launching puck force</span> line counts as
one shot.
</p>
{analysis.configError ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
@@ -276,20 +269,56 @@ export function AdminMatchAnalysis({
</span>
</div>
{analysis.mirrorExceptionDisconnectMatchIds.length > 0 ? (
<div
className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-950 dark:border-red-800 dark:bg-red-950/50 dark:text-red-100"
role="alert"
>
<p className="font-medium">
Mirror exception disconnect (
{analysis.mirrorExceptionDisconnectMatchIds.length.toLocaleString(
"en-US",
)}{" "}
{analysis.mirrorExceptionDisconnectMatchIds.length === 1
? "match"
: "matches"}
)
</p>
<p className="mt-1 text-red-800 dark:text-red-200/90">
These matches include a{" "}
<span className="font-mono">[Mirror/Error]</span> line where a
player was disconnected because handling a command caused an
exception.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{analysis.mirrorExceptionDisconnectMatchIds.map((matchId) => (
<li key={matchId}>
<Link
href={`/match-logs/${matchId}`}
className="inline-flex items-center rounded-md border border-red-400/80 bg-white px-2.5 py-1 font-mono text-xs font-medium text-red-900 shadow-sm hover:bg-red-100 dark:border-red-700 dark:bg-red-950/80 dark:text-red-50 dark:hover:bg-red-900/80"
>
Match {matchId}
</Link>
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-3">
<StatBlock
title="Match length"
avg={durationFmt.avg}
min={durationFmt.min}
max={durationFmt.max}
hint="First to last log line (m:ss)"
hint="Ended matches only · first to last log line (m:ss)"
/>
<StatBlock
title="Shots per match"
avg={shotsFmt.avg}
min={shotsFmt.min}
max={shotsFmt.max}
hint="launching puck lines"
hint="Ended matches only · launching puck lines"
/>
<StatBlock
title="Shot force magnitude"
@@ -326,10 +355,86 @@ export function AdminMatchAnalysis({
rows={analysis.emojiEmotes}
title="Emoji emotes"
barClassName="fill-amber-500 dark:fill-amber-400"
labelWidth={72}
labelMaxLen={12}
labelMonospace={false}
/>
</div>
</div>
</div>
{analysis.matchesWithLogFiles > 0 ? (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Match completion (from logs)
</h3>
<p className="mt-2 text-sm text-zinc-700 dark:text-zinc-300">
Winner logged:{" "}
<span className="font-mono font-semibold tabular-nums">
{analysis.matchesWithWinnerPatch.toLocaleString("en-US")}
</span>
{" / "}
<span className="font-mono tabular-nums">
{analysis.matchesWithLogFiles.toLocaleString("en-US")}
</span>
{winnerRatioPct != null ? (
<>
{" "}
(
<span className="font-mono tabular-nums">
{winnerRatioPct.toLocaleString("en-US", {
maximumFractionDigits: 1,
})}
%
</span>
)
</>
) : null}
</p>
<p className="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400">
A match ended when its log contains{" "}
<span className="font-mono">
Dedicated match winner PATCH success
</span>
.
</p>
</div>
{analysis.notEndedMatchIds.length > 0 ? (
<div
className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
<p className="font-medium">
Not ended (
{analysis.notEndedMatchIds.length.toLocaleString("en-US")}{" "}
{analysis.notEndedMatchIds.length === 1 ? "match" : "matches"})
</p>
<p className="mt-1 text-amber-900/90 dark:text-amber-200/90">
Log file present but no winner PATCH line the match did not
finish normally.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{analysis.notEndedMatchIds.map((matchId) => (
<li key={matchId}>
<Link
href={`/match-logs/${matchId}`}
className="inline-flex items-center rounded-md border border-amber-400/80 bg-white px-2.5 py-1 font-mono text-xs font-medium text-amber-950 shadow-sm hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-50 dark:hover:bg-amber-900/80"
>
Match {matchId}
</Link>
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line.
</p>
)}
</section>
) : null}
</section>
);
}
+22 -8
View File
@@ -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 (
<figure aria-label={`${title} bar chart`}>
@@ -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 (
<g key={row.label}>
@@ -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}
</text>
<rect
x={LABEL_W}
x={labelWidth}
y={y - 6}
width={barW}
height={12}
@@ -65,7 +79,7 @@ export function MatchAnalysisEmoteChart({
className={barClassName}
/>
<text
x={LABEL_W + BAR_MAX_W + 6}
x={labelWidth + BAR_MAX_W + 6}
y={y}
dominantBaseline="middle"
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
+19
View File
@@ -0,0 +1,19 @@
/** Game emoji emote id → Unicode glyph (admin analysis labels). */
const EMOJI_GLYPH_BY_ID: Record<number, string> = {
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}]`;
}
+76 -20
View File
@@ -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<string, number>[] = [];
const emojiMaps: Record<string, number>[] = [];
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;
for (const row of scanResults) {
if (row.kind === "invalid") continue;
if (row.kind === "missing") {
if (row.status === 413) matchesSkippedTooLarge += 1;
else matchesMissingLogs += 1;
return;
continue;
}
const parsed = parseMatchLogContent(res.content);
if (!parsed) {
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;
return;
continue;
}
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);
}),
);
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,
};
}
+32 -1
View File
@@ -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,
};
}