analysis WiP
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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