analysis WiP

This commit is contained in:
2026-05-23 20:07:14 +05:30
parent 0bb486223e
commit 9bb0af59b0
11 changed files with 1031 additions and 5 deletions
+37
View File
@@ -7,7 +7,9 @@ import { AdminLedger } from "@/components/admin-ledger";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
buildDashboardHref,
type AdminDashboardTab,
@@ -217,6 +219,10 @@ type Props = {
matchmakerError: string | null;
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
supplyError: boolean;
analysisFrom: string;
analysisTo: string;
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
};
function StatCard({
@@ -267,6 +273,10 @@ export function AdminDashboard({
matchmakerContent,
matchmakerError,
supplyError,
analysisFrom,
analysisTo,
analysisPlayerIds,
matchAnalysis,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
@@ -423,6 +433,23 @@ 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)}
@@ -721,6 +748,16 @@ export function AdminDashboard({
errorMessage={matchmakerError}
/>
</section>
) : tab === "analysis" ? (
<AdminMatchAnalysis
analysis={matchAnalysis}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
selectedPlayerIds={analysisPlayerIds}
highlightId={highlightId}
participantRaw={participantRaw}
/>
) : tab === "ledger" ? (
<AdminLedger
ledgerGlobalSummary={ledgerGlobalSummary}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { DbUser } from "@/types/database";
function formatDuration(seconds: number): string {
const s = Math.round(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function formatAggregate(
agg: NumericAggregate | null,
formatValue: (n: number) => string,
): { avg: string; min: string; max: string } {
if (!agg) {
return { avg: "—", min: "—", max: "—" };
}
return {
avg: formatValue(agg.avg),
min: formatValue(agg.min),
max: formatValue(agg.max),
};
}
function StatBlock({
title,
avg,
min,
max,
hint,
}: {
title: string;
avg: string;
min: string;
max: string;
hint?: string;
}) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{title}
</p>
{hint ? (
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
{hint}
</p>
) : null}
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<div>
<dt className="text-[10px] uppercase text-zinc-500">Avg</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{avg}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Min</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{min}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Max</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{max}
</dd>
</div>
</dl>
</div>
);
}
type Props = {
analysis: MatchLogAnalysisResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
selectedPlayerIds: number[];
highlightId: string | null;
participantRaw: string | null;
};
export function AdminMatchAnalysis({
analysis,
users,
analysisFrom,
analysisTo,
selectedPlayerIds,
highlightId,
participantRaw,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) m.set(u.id, u.username);
return m;
}, [users]);
const durationFmt = formatAggregate(analysis.duration, formatDuration);
const shotsFmt = formatAggregate(analysis.shots, (n) =>
Math.round(n).toLocaleString("en-US"),
);
const forceFmt = formatAggregate(analysis.force, (n) =>
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
);
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"
role="status"
>
{analysis.configError}
</div>
) : null}
<form
method="get"
action="/"
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="tab" value="analysis" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-afrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (UTC)
</label>
<input
id="analysis-afrom"
name="afrom"
type="date"
defaultValue={analysisFrom}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-ato"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (UTC)
</label>
<input
id="analysis-ato"
name="ato"
type="date"
defaultValue={analysisTo}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
</div>
<fieldset>
<legend className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
Players (optional leave empty for all)
</legend>
<div className="mt-2 max-h-48 overflow-y-auto rounded-md border border-zinc-200 p-2 dark:border-zinc-700">
<div className="flex flex-wrap gap-x-4 gap-y-2">
{users.length === 0 ? (
<p className="text-sm text-zinc-500">No players loaded.</p>
) : (
users.map((u) => {
const checked = selectedPlayerIds.includes(u.id);
return (
<label
key={u.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
name="aplayers"
value={String(u.id)}
defaultChecked={checked}
className="rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600"
/>
<span className="font-mono text-xs">
<ClickableUserId id={u.id} />
{u.username ? (
<span className="text-zinc-600 dark:text-zinc-400">
{" "}
({u.username})
</span>
) : null}
</span>
</label>
);
})
)}
</div>
</div>
</fieldset>
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset filters
</Link>
</div>
</form>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300">
<span className="font-mono">
{analysisFrom}{analysisTo}
</span>{" "}
UTC
{selectedPlayerIds.length > 0 ? (
<>
{" "}
· players{" "}
{selectedPlayerIds.map((id) => (
<span key={id} className="font-mono">
{id}
{usernameById.get(id) ? ` (${usernameById.get(id)})` : ""}
{" "}
</span>
))}
</>
) : (
" · all players"
)}
<br />
<span className="text-zinc-600 dark:text-zinc-400">
{analysis.matchesInFilter.toLocaleString("en-US")} matches in filter
{analysis.matchesScanned < analysis.matchesInFilter
? ` · scanned ${analysis.matchesScanned.toLocaleString("en-US")} (cap)`
: null}
· {analysis.matchesWithLogs.toLocaleString("en-US")} with parsed logs
{analysis.matchesMissingLogs > 0
? ` · ${analysis.matchesMissingLogs.toLocaleString("en-US")} missing or empty`
: null}
{analysis.matchesSkippedTooLarge > 0
? ` · ${analysis.matchesSkippedTooLarge.toLocaleString("en-US")} too large`
: null}
</span>
</div>
<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)"
/>
<StatBlock
title="Shots per match"
avg={shotsFmt.avg}
min={shotsFmt.min}
max={shotsFmt.max}
hint="launching puck lines"
/>
<StatBlock
title="Shot force magnitude"
avg={forceFmt.avg}
min={forceFmt.min}
max={forceFmt.max}
hint="√(fx² + fy²) per shot"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Force distribution (Fx, Fy)
</h3>
<MatchAnalysisForceChart points={analysis.forcePoints} />
</div>
<div className="space-y-6">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Text emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.textEmotes}
title="Text emotes"
barClassName="fill-violet-500 dark:fill-violet-400"
/>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Emoji emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.emojiEmotes}
title="Emoji emotes"
barClassName="fill-amber-500 dark:fill-amber-400"
/>
</div>
</div>
</div>
</section>
);
}
+3 -1
View File
@@ -90,7 +90,9 @@ export function EditUserCcRcOverlay({
? "matchmaker"
: tab === "ledger"
? "ledger"
: "dashboard"
: tab === "analysis"
? "analysis"
: "dashboard"
}
/>
<input
@@ -0,0 +1,82 @@
"use client";
import type { EmoteBarRow } from "@/lib/match-log-parser";
const ROW_H = 22;
const LABEL_W = 120;
const BAR_MAX_W = 180;
const PAD = 4;
type Props = {
rows: EmoteBarRow[];
/** e.g. "Text emotes" */
title: string;
barClassName?: string;
};
export function MatchAnalysisEmoteChart({
rows,
title,
barClassName = "fill-violet-500 dark:fill-violet-400",
}: Props) {
if (rows.length === 0) {
return (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No {title.toLowerCase()} in range.
</p>
);
}
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;
return (
<figure aria-label={`${title} bar chart`}>
<svg
viewBox={`0 0 ${vbW} ${vbH}`}
className="h-auto w-full max-w-lg text-zinc-800 dark:text-zinc-200"
role="img"
>
{rows.map((row, i) => {
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;
return (
<g key={row.label}>
<text
x={0}
y={y}
dominantBaseline="middle"
className="fill-zinc-600 text-[9px] dark:fill-zinc-400"
style={{ fontFamily: "ui-monospace, monospace" }}
>
{label}
</text>
<rect
x={LABEL_W}
y={y - 6}
width={barW}
height={12}
rx={2}
className={barClassName}
/>
<text
x={LABEL_W + BAR_MAX_W + 6}
y={y}
dominantBaseline="middle"
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
style={{ fontFamily: "ui-monospace, monospace" }}
>
{row.count.toLocaleString("en-US")}
</text>
</g>
);
})}
</svg>
</figure>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
const VB = 320;
const PAD = 18;
type Point = { x: number; y: number };
function extent(values: number[]): { min: number; max: number } {
if (values.length === 0) return { min: -1, max: 1 };
let min = values[0]!;
let max = values[0]!;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
if (min === max) {
const pad = Math.abs(min) * 0.1 + 1;
return { min: min - pad, max: max + pad };
}
const margin = (max - min) * 0.05;
return { min: min - margin, max: max + margin };
}
type Props = {
points: Point[];
};
export function MatchAnalysisForceChart({ points }: Props) {
const [hover, setHover] = useState<Point | null>(null);
const { dots, xExt, yExt } = useMemo(() => {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const xExt = extent(xs);
const yExt = extent(ys);
const inner = VB - PAD * 2;
const dots = points.map((p) => {
const tx =
xExt.max === xExt.min
? 0.5
: (p.x - xExt.min) / (xExt.max - xExt.min);
const ty =
yExt.max === yExt.min
? 0.5
: (p.y - yExt.min) / (yExt.max - yExt.min);
return {
cx: PAD + tx * inner,
cy: PAD + (1 - ty) * inner,
raw: p,
};
});
return { dots, xExt, yExt };
}, [points]);
if (points.length === 0) {
return (
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
No shot force vectors in the selected matches.
</p>
);
}
return (
<figure aria-label="Force vector scatter plot (x and y components)">
<div
className="relative cursor-crosshair"
onPointerLeave={() => setHover(null)}
>
<svg
viewBox={`0 0 ${VB} ${VB}`}
className="h-auto w-full max-w-md text-sky-600 dark:text-sky-400"
role="img"
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const sx =
((e.clientX - rect.left) / rect.width) * VB;
const sy =
((e.clientY - rect.top) / rect.height) * VB;
let best: (typeof dots)[0] | null = null;
let bestD = 12;
for (const d of dots) {
const dist = Math.hypot(d.cx - sx, d.cy - sy);
if (dist < bestD) {
bestD = dist;
best = d;
}
}
setHover(best?.raw ?? null);
}}
>
<rect
x={PAD}
y={PAD}
width={VB - PAD * 2}
height={VB - PAD * 2}
className="fill-zinc-50 stroke-zinc-200 dark:fill-zinc-900/50 dark:stroke-zinc-700"
strokeWidth={1}
/>
<line
x1={PAD + (VB - PAD * 2) / 2}
y1={PAD}
x2={PAD + (VB - PAD * 2) / 2}
y2={VB - PAD}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
<line
x1={PAD}
y1={PAD + (VB - PAD * 2) / 2}
x2={VB - PAD}
y2={PAD + (VB - PAD * 2) / 2}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
{dots.map((d, i) => (
<circle
key={i}
cx={d.cx}
cy={d.cy}
r={hover === d.raw ? 3.5 : 2}
className="fill-current opacity-70"
/>
))}
</svg>
{hover ? (
<div
className="pointer-events-none absolute left-2 top-2 rounded-md border border-zinc-200 bg-white/95 px-2 py-1 text-[10px] font-mono tabular-nums shadow-sm dark:border-zinc-700 dark:bg-zinc-900/95"
role="status"
>
({hover.x.toFixed(1)}, {hover.y.toFixed(1)})
</div>
) : null}
</div>
<figcaption className="mt-2 flex justify-between gap-2 font-mono text-[10px] text-zinc-500 dark:text-zinc-400">
<span>
Fx {xExt.min.toFixed(0)} {xExt.max.toFixed(0)}
</span>
<span>
Fy {yExt.min.toFixed(0)} {yExt.max.toFixed(0)}
</span>
</figcaption>
<p className="mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{points.length.toLocaleString("en-US")} shots · magnitude (x² + y²)
</p>
</figure>
);
}