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} + +
+ + {highlightId ? ( + + ) : null} + {participantRaw ? ( + + ) : null} + +
+
+ + +
+
+ + +
+
+ +
+ + Players (optional — leave empty for all) + +
+
+ {users.length === 0 ? ( +

No players loaded.

+ ) : ( + users.map((u) => { + const checked = selectedPlayerIds.includes(u.id); + return ( + + ); + }) + )} +
+
+
+ +
+ + + Reset filters + +
+
+ +
+ + {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 ( +
+ + {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 ( + + + {label} + + + + {row.count.toLocaleString("en-US")} + + + ); + })} + +
+ ); +} 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)} + > + { + 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); + }} + > + + + + {dots.map((d, i) => ( + + ))} + + {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, + }; +}