analysis complete
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user