analysis complete
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"><matchId>.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}]`;
|
||||
}
|
||||
@@ -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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user