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
+3 -1
View File
@@ -40,7 +40,9 @@ export async function addSystemSupply(formData: FormData) {
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: "dashboard";
: tabRaw === "analysis"
? "analysis"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
+3 -1
View File
@@ -35,7 +35,9 @@ export async function updateUserCcRc(formData: FormData) {
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: "dashboard";
: tabRaw === "analysis"
? "analysis"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
const highlightId = highlightRaw === "" ? null : highlightRaw;
+35 -1
View File
@@ -19,6 +19,12 @@ import {
} from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import {
analyzeMatchLogsForMatches,
parseAnalysisPlayerIds,
} from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
import type {
AdminMatchRow,
DbMatch,
@@ -63,6 +69,9 @@ export default async function Home({
lsize?: string | string[];
lsort?: string | string[];
lorder?: string | string[];
afrom?: string | string[];
ato?: string | string[];
aplayers?: string | string[];
}>;
}) {
const sp = await searchParams;
@@ -76,7 +85,9 @@ export default async function Home({
? "matchmaker"
: tabParam === "ledger"
? "ledger"
: "dashboard";
: tabParam === "analysis"
? "analysis"
: "dashboard";
const highlightId = firstSearchParam(sp.highlight);
const participantRaw = firstSearchParam(sp.participant);
const editRaw = firstSearchParam(sp.edit);
@@ -103,6 +114,10 @@ export default async function Home({
let ledgerSortOrderView: LedgerSortOrder = "desc";
let ledgerFrom = utcLast30DaysDateRange().from;
let ledgerTo = utcLast30DaysDateRange().to;
let analysisFrom = utcLast30DaysDateRange().from;
let analysisTo = utcLast30DaysDateRange().to;
let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null;
@@ -229,6 +244,21 @@ export default async function Home({
ledgerTotalPagesView = slice.totalPages;
}
}
if (tab === "analysis") {
const afromRaw = firstSearchParam(sp.afrom);
const atoRaw = firstSearchParam(sp.ato);
const range = normalizeLedgerDateRange(afromRaw, atoRaw);
analysisFrom = range.from;
analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
matchAnalysis = await analyzeMatchLogsForMatches(
matches,
analysisFrom,
analysisTo,
analysisPlayerIds,
);
}
}
let editUser: DbUser | null = null;
@@ -275,6 +305,10 @@ export default async function Home({
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
/>
{editUser ? (
<EditUserCcRcOverlay
+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>
);
}
+13 -1
View File
@@ -6,7 +6,8 @@ export type AdminDashboardTab =
| "players"
| "matches"
| "matchmaker"
| "ledger";
| "ledger"
| "analysis";
export type DashboardUrlQuery = {
tab: AdminDashboardTab;
@@ -26,6 +27,11 @@ export type DashboardUrlQuery = {
ledgerPageSize?: number | null;
ledgerSort?: string | null;
ledgerOrder?: "asc" | "desc" | null;
/** Analysis tab: UTC date-only bounds (`afrom` / `ato`). */
analysisFrom?: string | null;
analysisTo?: string | null;
/** Analysis tab: comma-separated player ids (`aplayers`). */
analysisPlayers?: string | null;
};
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
@@ -35,6 +41,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
else if (q.tab === "players") p.set("tab", "players");
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
else if (q.tab === "ledger") p.set("tab", "ledger");
else if (q.tab === "analysis") p.set("tab", "analysis");
if (q.highlightId) p.set("highlight", q.highlightId);
if (q.participantRaw) p.set("participant", q.participantRaw);
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
@@ -60,6 +67,11 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
p.set("lsize", String(Math.trunc(q.ledgerPageSize)));
}
}
if (q.tab === "analysis") {
if (q.analysisFrom) p.set("afrom", q.analysisFrom);
if (q.analysisTo) p.set("ato", q.analysisTo);
if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers);
}
if (q.editId != null && Number.isFinite(q.editId)) {
p.set("edit", String(q.editId));
}
+157
View File
@@ -0,0 +1,157 @@
import type { DbMatch } from "@/types/database";
import { readMatchLogFile } from "@/lib/match-logs-server";
import {
aggregateNumeric,
emptyMatchLogAnalysisResult,
mergeEmoteCounts,
parseMatchLogContent,
type MatchLogAnalysisResult,
} from "@/lib/match-log-parser";
function coalesceUserId(v: number | string | null): number | null {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
export function matchInvolvesAnyPlayer(
m: DbMatch,
playerIds: number[],
): boolean {
if (playerIds.length === 0) return true;
const red = coalesceUserId(m.user_red);
const blue = coalesceUserId(m.user_blue);
for (const id of playerIds) {
if (red === id || blue === id) return true;
}
return false;
}
export function matchInUtcDateRange(
m: DbMatch,
from: string,
to: string,
): boolean {
const created = m.created_at;
if (!created) return false;
const day = created.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
try {
const d = new Date(created);
if (Number.isNaN(d.getTime())) return false;
const isoDay = d.toISOString().slice(0, 10);
return isoDay >= from && isoDay <= to;
} catch {
return false;
}
}
return day >= from && day <= to;
}
/** Parse `aplayers` query param(s): repeated keys and/or comma-separated ids. */
export function parseAnalysisPlayerIds(
raw: string | string[] | null | undefined,
): number[] {
const parts: string[] = [];
if (raw == null) return [];
if (Array.isArray(raw)) {
for (const item of raw) {
if (typeof item === "string" && item.trim()) parts.push(item);
}
} else if (typeof raw === "string" && raw.trim()) {
parts.push(raw);
}
const out: number[] = [];
for (const chunk of parts) {
for (const segment of chunk.split(",")) {
const t = segment.trim();
if (!t) continue;
const n = Number(t);
if (Number.isInteger(n) && n >= 1) out.push(n);
}
}
return [...new Set(out)].sort((a, b) => a - b);
}
const MAX_MATCHES_TO_SCAN = 500;
/**
* Read and aggregate match logs for matches in the given filter.
* Uses filesystem logs via MATCH_LOGS_DIR.
*/
export async function analyzeMatchLogsForMatches(
matches: DbMatch[],
from: string,
to: string,
playerIds: number[],
): Promise<MatchLogAnalysisResult> {
const filtered = matches.filter(
(m) =>
matchInUtcDateRange(m, from, to) &&
matchInvolvesAnyPlayer(m, playerIds),
);
const toScan = filtered.slice(0, MAX_MATCHES_TO_SCAN);
const dirConfigured = Boolean(process.env.MATCH_LOGS_DIR?.trim());
if (!dirConfigured) {
return {
...emptyMatchLogAnalysisResult(
"Match logs directory is not configured (set MATCH_LOGS_DIR on the server).",
),
matchesInFilter: filtered.length,
};
}
const durations: number[] = [];
const shotCounts: number[] = [];
const forceMagnitudes: number[] = [];
const forcePoints: { x: number; y: number }[] = [];
const textMaps: Record<string, number>[] = [];
const emojiMaps: Record<string, number>[] = [];
let matchesWithLogs = 0;
let matchesMissingLogs = 0;
let matchesSkippedTooLarge = 0;
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);
}),
);
return {
matchesInFilter: filtered.length,
matchesScanned: toScan.length,
matchesWithLogs,
matchesMissingLogs,
matchesSkippedTooLarge,
duration: aggregateNumeric(durations),
shots: aggregateNumeric(shotCounts),
force: aggregateNumeric(forceMagnitudes),
forcePoints,
textEmotes: mergeEmoteCounts(textMaps),
emojiEmotes: mergeEmoteCounts(emojiMaps),
configError: null,
};
}
+212
View File
@@ -0,0 +1,212 @@
/** Parsed metrics from a single match log file. */
export type ParsedMatchLog = {
durationSeconds: number;
shotCount: number;
forceMagnitudes: number[];
forceVectors: { x: number; y: number }[];
textEmotes: Record<string, number>;
emojiEmotes: Record<string, number>;
};
const LINE_TS =
/^\[(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})\]\s*(.*)$/;
const LAUNCH_PUCK =
/launching puck at \(([-\d.]+),\s*([-\d.]+)\) with \(([-\d.]+),\s*([-\d.]+)\) force/;
const TEXT_EMOTE = /Client \d+ sent text emote (.+)$/;
const EMOJI_EMOTE = /Client \d+ sent emoji emote (\d+)$/;
function parseLogTimestamp(
month: string,
day: string,
year: string,
hour: string,
minute: string,
second: string,
): number | null {
const m = Number(month);
const d = Number(day);
const y = Number(year);
const h = Number(hour);
const min = Number(minute);
const s = Number(second);
if (
!Number.isFinite(m) ||
!Number.isFinite(d) ||
!Number.isFinite(y) ||
!Number.isFinite(h) ||
!Number.isFinite(min) ||
!Number.isFinite(s)
) {
return null;
}
const ms = Date.UTC(y, m - 1, d, h, min, s);
const dt = new Date(ms);
if (
dt.getUTCFullYear() !== y ||
dt.getUTCMonth() !== m - 1 ||
dt.getUTCDate() !== d
) {
return null;
}
return ms;
}
function forceMagnitude(fx: number, fy: number): number {
return Math.hypot(fx, fy);
}
/** Parse match log text into per-match gameplay metrics. */
export function parseMatchLogContent(content: string): ParsedMatchLog | null {
const lines = content.split(/\r?\n/);
let firstMs: number | null = null;
let lastMs: number | null = null;
const forceMagnitudes: number[] = [];
const forceVectors: { x: number; y: number }[] = [];
const textEmotes: Record<string, number> = {};
const emojiEmotes: Record<string, number> = {};
let shotCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const tsMatch = LINE_TS.exec(trimmed);
if (tsMatch) {
const ms = parseLogTimestamp(
tsMatch[1]!,
tsMatch[2]!,
tsMatch[3]!,
tsMatch[4]!,
tsMatch[5]!,
tsMatch[6]!,
);
if (ms != null) {
if (firstMs == null) firstMs = ms;
lastMs = ms;
}
const body = tsMatch[7] ?? "";
const launch = LAUNCH_PUCK.exec(body);
if (launch) {
shotCount += 1;
const fx = Number(launch[3]);
const fy = Number(launch[4]);
if (Number.isFinite(fx) && Number.isFinite(fy)) {
forceVectors.push({ x: fx, y: fy });
forceMagnitudes.push(forceMagnitude(fx, fy));
}
} else {
const text = TEXT_EMOTE.exec(body);
if (text) {
const label = text[1]!.trim();
if (label) textEmotes[label] = (textEmotes[label] ?? 0) + 1;
} else {
const emoji = EMOJI_EMOTE.exec(body);
if (emoji) {
const id = emoji[1]!;
const key = `Emoji ${id}`;
emojiEmotes[key] = (emojiEmotes[key] ?? 0) + 1;
}
}
}
continue;
}
const launchBare = LAUNCH_PUCK.exec(trimmed);
if (launchBare) {
shotCount += 1;
const fx = Number(launchBare[3]);
const fy = Number(launchBare[4]);
if (Number.isFinite(fx) && Number.isFinite(fy)) {
forceVectors.push({ x: fx, y: fy });
forceMagnitudes.push(forceMagnitude(fx, fy));
}
}
}
if (firstMs == null || lastMs == null) return null;
const durationSeconds = Math.max(0, (lastMs - firstMs) / 1000);
return {
durationSeconds,
shotCount,
forceMagnitudes,
forceVectors,
textEmotes,
emojiEmotes,
};
}
export type NumericAggregate = {
avg: number;
min: number;
max: number;
};
export function aggregateNumeric(values: number[]): NumericAggregate | null {
if (values.length === 0) return null;
let min = values[0]!;
let max = values[0]!;
let sum = 0;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
sum += v;
}
return { avg: sum / values.length, min, max };
}
export type EmoteBarRow = { label: string; count: number };
export function mergeEmoteCounts(
maps: Record<string, number>[],
): EmoteBarRow[] {
const merged: Record<string, number> = {};
for (const map of maps) {
for (const [label, count] of Object.entries(map)) {
merged[label] = (merged[label] ?? 0) + count;
}
}
return Object.entries(merged)
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/** JSON-serializable analysis bundle for the admin UI. */
export type MatchLogAnalysisResult = {
matchesInFilter: number;
/** Matches actually read (capped for performance). */
matchesScanned: number;
matchesWithLogs: number;
matchesMissingLogs: number;
matchesSkippedTooLarge: number;
duration: NumericAggregate | null;
shots: NumericAggregate | null;
force: NumericAggregate | null;
forcePoints: { x: number; y: number }[];
textEmotes: EmoteBarRow[];
emojiEmotes: EmoteBarRow[];
configError: string | null;
};
export function emptyMatchLogAnalysisResult(
configError: string | null = null,
): MatchLogAnalysisResult {
return {
matchesInFilter: 0,
matchesScanned: 0,
matchesWithLogs: 0,
matchesMissingLogs: 0,
matchesSkippedTooLarge: 0,
duration: null,
shots: null,
force: null,
forcePoints: [],
textEmotes: [],
emojiEmotes: [],
configError,
};
}