analysis WiP
This commit is contained in:
@@ -40,7 +40,9 @@ export async function addSystemSupply(formData: FormData) {
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tabRaw === "ledger"
|
: tabRaw === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
: "dashboard";
|
: tabRaw === "analysis"
|
||||||
|
? "analysis"
|
||||||
|
: "dashboard";
|
||||||
|
|
||||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export async function updateUserCcRc(formData: FormData) {
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tabRaw === "ledger"
|
: tabRaw === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
: "dashboard";
|
: tabRaw === "analysis"
|
||||||
|
? "analysis"
|
||||||
|
: "dashboard";
|
||||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||||
|
|||||||
+35
-1
@@ -19,6 +19,12 @@ import {
|
|||||||
} from "@/lib/matchmaker-log-source";
|
} from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
|
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 {
|
import type {
|
||||||
AdminMatchRow,
|
AdminMatchRow,
|
||||||
DbMatch,
|
DbMatch,
|
||||||
@@ -63,6 +69,9 @@ export default async function Home({
|
|||||||
lsize?: string | string[];
|
lsize?: string | string[];
|
||||||
lsort?: string | string[];
|
lsort?: string | string[];
|
||||||
lorder?: string | string[];
|
lorder?: string | string[];
|
||||||
|
afrom?: string | string[];
|
||||||
|
ato?: string | string[];
|
||||||
|
aplayers?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
@@ -76,7 +85,9 @@ export default async function Home({
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tabParam === "ledger"
|
: tabParam === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
: "dashboard";
|
: tabParam === "analysis"
|
||||||
|
? "analysis"
|
||||||
|
: "dashboard";
|
||||||
const highlightId = firstSearchParam(sp.highlight);
|
const highlightId = firstSearchParam(sp.highlight);
|
||||||
const participantRaw = firstSearchParam(sp.participant);
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
const editRaw = firstSearchParam(sp.edit);
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
@@ -103,6 +114,10 @@ export default async function Home({
|
|||||||
let ledgerSortOrderView: LedgerSortOrder = "desc";
|
let ledgerSortOrderView: LedgerSortOrder = "desc";
|
||||||
let ledgerFrom = utcLast30DaysDateRange().from;
|
let ledgerFrom = utcLast30DaysDateRange().from;
|
||||||
let ledgerTo = utcLast30DaysDateRange().to;
|
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 =
|
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@@ -229,6 +244,21 @@ export default async function Home({
|
|||||||
ledgerTotalPagesView = slice.totalPages;
|
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;
|
let editUser: DbUser | null = null;
|
||||||
@@ -275,6 +305,10 @@ export default async function Home({
|
|||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
supplyError={supplyError}
|
supplyError={supplyError}
|
||||||
|
analysisFrom={analysisFrom}
|
||||||
|
analysisTo={analysisTo}
|
||||||
|
analysisPlayerIds={analysisPlayerIds}
|
||||||
|
matchAnalysis={matchAnalysis}
|
||||||
/>
|
/>
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<EditUserCcRcOverlay
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { AdminLedger } from "@/components/admin-ledger";
|
|||||||
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||||||
|
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
|
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||||
import {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
@@ -217,6 +219,10 @@ type Props = {
|
|||||||
matchmakerError: string | null;
|
matchmakerError: string | null;
|
||||||
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||||||
supplyError: boolean;
|
supplyError: boolean;
|
||||||
|
analysisFrom: string;
|
||||||
|
analysisTo: string;
|
||||||
|
analysisPlayerIds: number[];
|
||||||
|
matchAnalysis: MatchLogAnalysisResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
@@ -267,6 +273,10 @@ export function AdminDashboard({
|
|||||||
matchmakerContent,
|
matchmakerContent,
|
||||||
matchmakerError,
|
matchmakerError,
|
||||||
supplyError,
|
supplyError,
|
||||||
|
analysisFrom,
|
||||||
|
analysisTo,
|
||||||
|
analysisPlayerIds,
|
||||||
|
matchAnalysis,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||||
@@ -423,6 +433,23 @@ 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)}
|
||||||
@@ -721,6 +748,16 @@ export function AdminDashboard({
|
|||||||
errorMessage={matchmakerError}
|
errorMessage={matchmakerError}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
) : tab === "analysis" ? (
|
||||||
|
<AdminMatchAnalysis
|
||||||
|
analysis={matchAnalysis}
|
||||||
|
users={users}
|
||||||
|
analysisFrom={analysisFrom}
|
||||||
|
analysisTo={analysisTo}
|
||||||
|
selectedPlayerIds={analysisPlayerIds}
|
||||||
|
highlightId={highlightId}
|
||||||
|
participantRaw={participantRaw}
|
||||||
|
/>
|
||||||
) : tab === "ledger" ? (
|
) : tab === "ledger" ? (
|
||||||
<AdminLedger
|
<AdminLedger
|
||||||
ledgerGlobalSummary={ledgerGlobalSummary}
|
ledgerGlobalSummary={ledgerGlobalSummary}
|
||||||
|
|||||||
@@ -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"><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"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,7 +90,9 @@ export function EditUserCcRcOverlay({
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tab === "ledger"
|
: tab === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
: "dashboard"
|
: tab === "analysis"
|
||||||
|
? "analysis"
|
||||||
|
: "dashboard"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ export type AdminDashboardTab =
|
|||||||
| "players"
|
| "players"
|
||||||
| "matches"
|
| "matches"
|
||||||
| "matchmaker"
|
| "matchmaker"
|
||||||
| "ledger";
|
| "ledger"
|
||||||
|
| "analysis";
|
||||||
|
|
||||||
export type DashboardUrlQuery = {
|
export type DashboardUrlQuery = {
|
||||||
tab: AdminDashboardTab;
|
tab: AdminDashboardTab;
|
||||||
@@ -26,6 +27,11 @@ export type DashboardUrlQuery = {
|
|||||||
ledgerPageSize?: number | null;
|
ledgerPageSize?: number | null;
|
||||||
ledgerSort?: string | null;
|
ledgerSort?: string | null;
|
||||||
ledgerOrder?: "asc" | "desc" | 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. */
|
/** 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 === "players") p.set("tab", "players");
|
||||||
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
||||||
else if (q.tab === "ledger") p.set("tab", "ledger");
|
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.highlightId) p.set("highlight", q.highlightId);
|
||||||
if (q.participantRaw) p.set("participant", q.participantRaw);
|
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||||
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
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)));
|
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)) {
|
if (q.editId != null && Number.isFinite(q.editId)) {
|
||||||
p.set("edit", String(q.editId));
|
p.set("edit", String(q.editId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user