From 9bb0af59b078bf9d0a97af3c99dc298b57d4680a Mon Sep 17 00:00:00 2001
From: Sewmina
Date: Sat, 23 May 2026 20:07:14 +0530
Subject: [PATCH] analysis WiP
---
src/app/actions/add-system-supply.ts | 4 +-
src/app/actions/update-user-cc-rc.ts | 4 +-
src/app/page.tsx | 36 +-
src/components/admin-dashboard.tsx | 37 ++
src/components/admin-match-analysis.tsx | 335 ++++++++++++++++++
src/components/edit-user-cc-rc-overlay.tsx | 4 +-
src/components/match-analysis-emote-chart.tsx | 82 +++++
src/components/match-analysis-force-chart.tsx | 151 ++++++++
src/lib/dashboard-search-url.ts | 14 +-
src/lib/match-log-analysis-server.ts | 157 ++++++++
src/lib/match-log-parser.ts | 212 +++++++++++
11 files changed, 1031 insertions(+), 5 deletions(-)
create mode 100644 src/components/admin-match-analysis.tsx
create mode 100644 src/components/match-analysis-emote-chart.tsx
create mode 100644 src/components/match-analysis-force-chart.tsx
create mode 100644 src/lib/match-log-analysis-server.ts
create mode 100644 src/lib/match-log-parser.ts
diff --git a/src/app/actions/add-system-supply.ts b/src/app/actions/add-system-supply.ts
index bface9c..f1a6301 100644
--- a/src/app/actions/add-system-supply.ts
+++ b/src/app/actions/add-system-supply.ts
@@ -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();
diff --git a/src/app/actions/update-user-cc-rc.ts b/src/app/actions/update-user-cc-rc.ts
index e2150d5..7c7afb4 100644
--- a/src/app/actions/update-user-cc-rc.ts
+++ b/src/app/actions/update-user-cc-rc.ts
@@ -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;
diff --git a/src/app/page.tsx b/src/app/page.tsx
index b496c41..8b73b9a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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> | 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 ? (
("wins");
@@ -423,6 +433,23 @@ export function AdminDashboard({
>
Ledger
+ 0
+ ? analysisPlayerIds.join(",")
+ : null,
+ })}
+ className={tabClass(tab === "analysis")}
+ scroll={false}
+ >
+ Analysis
+
+ ) : tab === "analysis" ? (
+
) : tab === "ledger" ? (
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 (
+
+
+ {title}
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+
+
- Avg
+ -
+ {avg}
+
+
+
+
- Min
+ -
+ {min}
+
+
+
+
- Max
+ -
+ {max}
+
+
+
+
+ );
+}
+
+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();
+ 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 (
+
+
+ Aggregates gameplay metrics from per-match log files (
+
+ MATCH_LOGS_DIR
+
+ /{" "}
+ <matchId>.txt). Match length is
+ first-to-last log timestamp; each{" "}
+ launching puck … force line counts as
+ one shot.
+
+
+ {analysis.configError ? (
+
+ {analysis.configError}
+
+ ) : null}
+
+
+
+
+
+ {analysisFrom}–{analysisTo}
+ {" "}
+ UTC
+ {selectedPlayerIds.length > 0 ? (
+ <>
+ {" "}
+ · players{" "}
+ {selectedPlayerIds.map((id) => (
+
+ {id}
+ {usernameById.get(id) ? ` (${usernameById.get(id)})` : ""}
+ {" "}
+
+ ))}
+ >
+ ) : (
+ " · all players"
+ )}
+
+
+ {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}
+
+
+
+
+
+
+
+
+
+
+
+
+ Force distribution (Fx, Fy)
+
+
+
+
+
+
+ Text emotes
+
+
+
+
+
+ Emoji emotes
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/edit-user-cc-rc-overlay.tsx b/src/components/edit-user-cc-rc-overlay.tsx
index 1dba805..3674811 100644
--- a/src/components/edit-user-cc-rc-overlay.tsx
+++ b/src/components/edit-user-cc-rc-overlay.tsx
@@ -90,7 +90,9 @@ export function EditUserCcRcOverlay({
? "matchmaker"
: tab === "ledger"
? "ledger"
- : "dashboard"
+ : tab === "analysis"
+ ? "analysis"
+ : "dashboard"
}
/>
+ No {title.toLowerCase()} in range.
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+}
diff --git a/src/components/match-analysis-force-chart.tsx b/src/components/match-analysis-force-chart.tsx
new file mode 100644
index 0000000..a51ee42
--- /dev/null
+++ b/src/components/match-analysis-force-chart.tsx
@@ -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(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 (
+
+ No shot force vectors in the selected matches.
+
+ );
+ }
+
+ return (
+
+ setHover(null)}
+ >
+
+ {hover ? (
+
+ ({hover.x.toFixed(1)}, {hover.y.toFixed(1)})
+
+ ) : null}
+
+
+
+ Fx {xExt.min.toFixed(0)} … {xExt.max.toFixed(0)}
+
+
+ Fy {yExt.min.toFixed(0)} … {yExt.max.toFixed(0)}
+
+
+
+ {points.length.toLocaleString("en-US")} shots · magnitude √(x² + y²)
+
+
+ );
+}
diff --git a/src/lib/dashboard-search-url.ts b/src/lib/dashboard-search-url.ts
index 5e92ce8..8cc401e 100644
--- a/src/lib/dashboard-search-url.ts
+++ b/src/lib/dashboard-search-url.ts
@@ -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));
}
diff --git a/src/lib/match-log-analysis-server.ts b/src/lib/match-log-analysis-server.ts
new file mode 100644
index 0000000..11d235d
--- /dev/null
+++ b/src/lib/match-log-analysis-server.ts
@@ -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 {
+ 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[] = [];
+ const emojiMaps: Record[] = [];
+
+ 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,
+ };
+}
diff --git a/src/lib/match-log-parser.ts b/src/lib/match-log-parser.ts
new file mode 100644
index 0000000..230a96d
--- /dev/null
+++ b/src/lib/match-log-parser.ts
@@ -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;
+ emojiEmotes: Record;
+};
+
+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 = {};
+ const emojiEmotes: Record = {};
+ 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[],
+): EmoteBarRow[] {
+ const merged: Record = {};
+ 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,
+ };
+}