analysis complete
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 2613",
|
"dev": "next dev -p 2614",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 2613",
|
"start": "next start -p 2613",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
@@ -411,6 +411,23 @@ export function AdminDashboard({
|
|||||||
: null}
|
: null}
|
||||||
)
|
)
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "matchmaker",
|
tab: "matchmaker",
|
||||||
@@ -433,23 +450,6 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Ledger
|
Ledger
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={tabClass(false)}
|
className={tabClass(false)}
|
||||||
|
|||||||
@@ -110,20 +110,13 @@ export function AdminMatchAnalysis({
|
|||||||
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
|
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const winnerRatioPct =
|
||||||
|
analysis.matchesWithLogFiles > 0
|
||||||
|
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<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 ? (
|
{analysis.configError ? (
|
||||||
<div
|
<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"
|
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>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<StatBlock
|
<StatBlock
|
||||||
title="Match length"
|
title="Match length"
|
||||||
avg={durationFmt.avg}
|
avg={durationFmt.avg}
|
||||||
min={durationFmt.min}
|
min={durationFmt.min}
|
||||||
max={durationFmt.max}
|
max={durationFmt.max}
|
||||||
hint="First to last log line (m:ss)"
|
hint="Ended matches only · first to last log line (m:ss)"
|
||||||
/>
|
/>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
title="Shots per match"
|
title="Shots per match"
|
||||||
avg={shotsFmt.avg}
|
avg={shotsFmt.avg}
|
||||||
min={shotsFmt.min}
|
min={shotsFmt.min}
|
||||||
max={shotsFmt.max}
|
max={shotsFmt.max}
|
||||||
hint="launching puck lines"
|
hint="Ended matches only · launching puck lines"
|
||||||
/>
|
/>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
title="Shot force magnitude"
|
title="Shot force magnitude"
|
||||||
@@ -326,10 +355,86 @@ export function AdminMatchAnalysis({
|
|||||||
rows={analysis.emojiEmotes}
|
rows={analysis.emojiEmotes}
|
||||||
title="Emoji emotes"
|
title="Emoji emotes"
|
||||||
barClassName="fill-amber-500 dark:fill-amber-400"
|
barClassName="fill-amber-500 dark:fill-amber-400"
|
||||||
|
labelWidth={72}
|
||||||
|
labelMaxLen={12}
|
||||||
|
labelMonospace={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { EmoteBarRow } from "@/lib/match-log-parser";
|
import type { EmoteBarRow } from "@/lib/match-log-parser";
|
||||||
|
|
||||||
const ROW_H = 22;
|
const ROW_H = 22;
|
||||||
const LABEL_W = 120;
|
|
||||||
const BAR_MAX_W = 180;
|
const BAR_MAX_W = 180;
|
||||||
const PAD = 4;
|
const PAD = 4;
|
||||||
|
|
||||||
@@ -12,12 +11,21 @@ type Props = {
|
|||||||
/** e.g. "Text emotes" */
|
/** e.g. "Text emotes" */
|
||||||
title: string;
|
title: string;
|
||||||
barClassName?: 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({
|
export function MatchAnalysisEmoteChart({
|
||||||
rows,
|
rows,
|
||||||
title,
|
title,
|
||||||
barClassName = "fill-violet-500 dark:fill-violet-400",
|
barClassName = "fill-violet-500 dark:fill-violet-400",
|
||||||
|
labelWidth = 120,
|
||||||
|
labelMaxLen = 16,
|
||||||
|
labelMonospace = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +37,7 @@ export function MatchAnalysisEmoteChart({
|
|||||||
|
|
||||||
const max = Math.max(...rows.map((r) => r.count), 1);
|
const max = Math.max(...rows.map((r) => r.count), 1);
|
||||||
const vbH = PAD * 2 + rows.length * ROW_H;
|
const vbH = PAD * 2 + rows.length * ROW_H;
|
||||||
const vbW = LABEL_W + BAR_MAX_W + 48;
|
const vbW = labelWidth + BAR_MAX_W + 48;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure aria-label={`${title} bar chart`}>
|
<figure aria-label={`${title} bar chart`}>
|
||||||
@@ -42,8 +50,8 @@ export function MatchAnalysisEmoteChart({
|
|||||||
const y = PAD + i * ROW_H + ROW_H / 2;
|
const y = PAD + i * ROW_H + ROW_H / 2;
|
||||||
const barW = (row.count / max) * BAR_MAX_W;
|
const barW = (row.count / max) * BAR_MAX_W;
|
||||||
const label =
|
const label =
|
||||||
row.label.length > 16
|
row.label.length > labelMaxLen
|
||||||
? `${row.label.slice(0, 15)}…`
|
? `${row.label.slice(0, labelMaxLen - 1)}…`
|
||||||
: row.label;
|
: row.label;
|
||||||
return (
|
return (
|
||||||
<g key={row.label}>
|
<g key={row.label}>
|
||||||
@@ -51,13 +59,19 @@ export function MatchAnalysisEmoteChart({
|
|||||||
x={0}
|
x={0}
|
||||||
y={y}
|
y={y}
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
className="fill-zinc-600 text-[9px] dark:fill-zinc-400"
|
className={`fill-zinc-600 dark:fill-zinc-400 ${
|
||||||
style={{ fontFamily: "ui-monospace, monospace" }}
|
labelMonospace ? "text-[9px]" : "text-[11px]"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
labelMonospace
|
||||||
|
? { fontFamily: "ui-monospace, monospace" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
<rect
|
<rect
|
||||||
x={LABEL_W}
|
x={labelWidth}
|
||||||
y={y - 6}
|
y={y - 6}
|
||||||
width={barW}
|
width={barW}
|
||||||
height={12}
|
height={12}
|
||||||
@@ -65,7 +79,7 @@ export function MatchAnalysisEmoteChart({
|
|||||||
className={barClassName}
|
className={barClassName}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={LABEL_W + BAR_MAX_W + 6}
|
x={labelWidth + BAR_MAX_W + 6}
|
||||||
y={y}
|
y={y}
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
|
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 {
|
import {
|
||||||
aggregateNumeric,
|
aggregateNumeric,
|
||||||
emptyMatchLogAnalysisResult,
|
emptyMatchLogAnalysisResult,
|
||||||
|
matchLogHasMirrorExceptionDisconnect,
|
||||||
|
matchLogHasWinnerPatch,
|
||||||
mergeEmoteCounts,
|
mergeEmoteCounts,
|
||||||
parseMatchLogContent,
|
parseMatchLogContent,
|
||||||
type MatchLogAnalysisResult,
|
type MatchLogAnalysisResult,
|
||||||
@@ -110,35 +112,85 @@ export async function analyzeMatchLogsForMatches(
|
|||||||
const textMaps: Record<string, number>[] = [];
|
const textMaps: Record<string, number>[] = [];
|
||||||
const emojiMaps: 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 matchesWithLogs = 0;
|
||||||
let matchesMissingLogs = 0;
|
let matchesMissingLogs = 0;
|
||||||
let matchesSkippedTooLarge = 0;
|
let matchesSkippedTooLarge = 0;
|
||||||
|
let matchesWithLogFiles = 0;
|
||||||
|
let matchesWithWinnerPatch = 0;
|
||||||
|
const mirrorExceptionDisconnectMatchIds: number[] = [];
|
||||||
|
const notEndedMatchIds: number[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
for (const row of scanResults) {
|
||||||
toScan.map(async (m) => {
|
if (row.kind === "invalid") continue;
|
||||||
const id = Number(m.id);
|
if (row.kind === "missing") {
|
||||||
if (!Number.isFinite(id)) return;
|
if (row.status === 413) matchesSkippedTooLarge += 1;
|
||||||
const res = await readMatchLogFile(id);
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 404) matchesMissingLogs += 1;
|
|
||||||
else if (res.status === 413) matchesSkippedTooLarge += 1;
|
|
||||||
else matchesMissingLogs += 1;
|
else matchesMissingLogs += 1;
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
const parsed = parseMatchLogContent(res.content);
|
matchesWithLogFiles += 1;
|
||||||
if (!parsed) {
|
if (row.winnerPatchLogged) {
|
||||||
|
matchesWithWinnerPatch += 1;
|
||||||
|
} else {
|
||||||
|
notEndedMatchIds.push(row.matchId);
|
||||||
|
}
|
||||||
|
if (row.mirrorExceptionDisconnect) {
|
||||||
|
mirrorExceptionDisconnectMatchIds.push(row.matchId);
|
||||||
|
}
|
||||||
|
if (row.kind === "unparsed") {
|
||||||
matchesMissingLogs += 1;
|
matchesMissingLogs += 1;
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
matchesWithLogs += 1;
|
matchesWithLogs += 1;
|
||||||
durations.push(parsed.durationSeconds);
|
if (row.winnerPatchLogged) {
|
||||||
shotCounts.push(parsed.shotCount);
|
durations.push(row.parsed.durationSeconds);
|
||||||
forceMagnitudes.push(...parsed.forceMagnitudes);
|
shotCounts.push(row.parsed.shotCount);
|
||||||
forcePoints.push(...parsed.forceVectors);
|
}
|
||||||
textMaps.push(parsed.textEmotes);
|
forceMagnitudes.push(...row.parsed.forceMagnitudes);
|
||||||
emojiMaps.push(parsed.emojiEmotes);
|
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 {
|
return {
|
||||||
matchesInFilter: filtered.length,
|
matchesInFilter: filtered.length,
|
||||||
@@ -152,6 +204,10 @@ export async function analyzeMatchLogsForMatches(
|
|||||||
forcePoints,
|
forcePoints,
|
||||||
textEmotes: mergeEmoteCounts(textMaps),
|
textEmotes: mergeEmoteCounts(textMaps),
|
||||||
emojiEmotes: mergeEmoteCounts(emojiMaps),
|
emojiEmotes: mergeEmoteCounts(emojiMaps),
|
||||||
|
mirrorExceptionDisconnectMatchIds,
|
||||||
|
matchesWithLogFiles,
|
||||||
|
matchesWithWinnerPatch,
|
||||||
|
notEndedMatchIds,
|
||||||
configError: null,
|
configError: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatEmojiEmoteLabel } from "@/lib/match-emoji-labels";
|
||||||
|
|
||||||
/** Parsed metrics from a single match log file. */
|
/** Parsed metrics from a single match log file. */
|
||||||
export type ParsedMatchLog = {
|
export type ParsedMatchLog = {
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
@@ -18,6 +20,23 @@ const TEXT_EMOTE = /Client \d+ sent text emote (.+)$/;
|
|||||||
|
|
||||||
const EMOJI_EMOTE = /Client \d+ sent emoji emote (\d+)$/;
|
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(
|
function parseLogTimestamp(
|
||||||
month: string,
|
month: string,
|
||||||
day: string,
|
day: string,
|
||||||
@@ -106,7 +125,7 @@ export function parseMatchLogContent(content: string): ParsedMatchLog | null {
|
|||||||
const emoji = EMOJI_EMOTE.exec(body);
|
const emoji = EMOJI_EMOTE.exec(body);
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
const id = emoji[1]!;
|
const id = emoji[1]!;
|
||||||
const key = `Emoji ${id}`;
|
const key = formatEmojiEmoteLabel(id);
|
||||||
emojiEmotes[key] = (emojiEmotes[key] ?? 0) + 1;
|
emojiEmotes[key] = (emojiEmotes[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +208,14 @@ export type MatchLogAnalysisResult = {
|
|||||||
forcePoints: { x: number; y: number }[];
|
forcePoints: { x: number; y: number }[];
|
||||||
textEmotes: EmoteBarRow[];
|
textEmotes: EmoteBarRow[];
|
||||||
emojiEmotes: 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;
|
configError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,6 +234,10 @@ export function emptyMatchLogAnalysisResult(
|
|||||||
forcePoints: [],
|
forcePoints: [],
|
||||||
textEmotes: [],
|
textEmotes: [],
|
||||||
emojiEmotes: [],
|
emojiEmotes: [],
|
||||||
|
mirrorExceptionDisconnectMatchIds: [],
|
||||||
|
matchesWithLogFiles: 0,
|
||||||
|
matchesWithWinnerPatch: 0,
|
||||||
|
notEndedMatchIds: [],
|
||||||
configError,
|
configError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user