From c5084170b82a88c5680b75b6985139b63aa69de7 Mon Sep 17 00:00:00 2001 From: Web Date: Sun, 6 Sep 2026 14:04:16 +0000 Subject: [PATCH] show escrow accounts --- .cursor/settings.json | 7 ++ src/app/login/page.tsx | 6 ++ src/app/page.tsx | 15 ++- src/app/replays/[matchId]/page.tsx | 80 ++++++++++++++ src/components/admin-dashboard.tsx | 88 +++++++++++---- src/components/match-history-battle-card.tsx | 34 ++++-- src/components/replay-viewer.tsx | 10 +- src/lib/auth/page-access-labels.ts | 1 + src/lib/dashboard-stats.ts | 10 +- src/lib/match-replays-server.ts | 107 +++++++++++++++++++ src/types/database.ts | 1 + 11 files changed, 325 insertions(+), 34 deletions(-) create mode 100644 .cursor/settings.json create mode 100644 src/app/replays/[matchId]/page.tsx create mode 100644 src/lib/match-replays-server.ts diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..54ec55a --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "supabase": { + "enabled": true + } + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 3675921..a10ce29 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -29,6 +29,9 @@ export default async function LoginPage({ searchParams }: Props) { id="username" name="username" autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500" required /> @@ -45,6 +48,9 @@ export default async function LoginPage({ searchParams }: Props) { name="password" type="password" autoComplete="current-password" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500" required /> diff --git a/src/app/page.tsx b/src/app/page.tsx index e270d86..98d85e1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -30,6 +30,7 @@ import { type MatchmakerLogSource, } from "@/lib/matchmaker-log-source"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; +import { replayFileExists } from "@/lib/match-replays-server"; import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger"; import { analyzeMatchLogsForMatches, @@ -202,6 +203,7 @@ export default async function Home({ let matchmakerError: string | null = null; let auditEntries: AuditLogEntry[] = []; let auditError: string | null = null; + let matchIdsWithReplay: number[] = []; if (tab === "matchmaker") { const mkRaw = firstSearchParam(sp.mklog); matchmakerSource = parseMatchmakerLogParam(mkRaw); @@ -228,7 +230,7 @@ export default async function Home({ } else { const usersRes = await supabase .from("users") - .select("id, created_at, username, email, ip_address, cc, rc, last_logged_at") + .select("id, created_at, username, email, ip_address, cc, rc, mmr, last_logged_at") .order("id", { ascending: false }) .limit(500); @@ -307,6 +309,16 @@ export default async function Home({ statsBundle = await loadDashboardStatsBundle(supabase); + if (tab === "matches" && pageAccess.matches) { + const checks = await Promise.all( + matches.map(async (m) => { + const id = Number(m.id); + return (await replayFileExists(id)) ? id : null; + }), + ); + matchIdsWithReplay = checks.filter((id): id is number => id !== null); + } + if (tab === "ledger") { const lfromRaw = firstSearchParam(sp.lfrom); const ltoRaw = firstSearchParam(sp.lto); @@ -455,6 +467,7 @@ export default async function Home({ canWritePlayers={canWritePlayers} canWriteLedger={canWriteLedger} isAdmin={account.isAdmin} + matchIdsWithReplay={matchIdsWithReplay} /> {editUser ? ( ; +}): Promise { + const { matchId } = await params; + return { + title: `Match ${matchId} replay · Kick Kings Admin`, + }; +} + +export default async function MatchReplayPage({ + params, +}: { + params: Promise<{ matchId: string }>; +}) { + const account = await requireSession(); + if ( + !canReadPage(account, "matches") && + !canReadPage(account, "analysis") + ) { + redirect("/"); + } + + const { matchId: raw } = await params; + const matchId = parseMatchIdParam(raw); + if (matchId == null) { + notFound(); + } + + await logPageAccess(account, "match-replay", { matchId }); + + const result = await readMatchReplayFile(matchId); + + return ( +
+
+ +

+ soccar + + match_{matchId}.json +

+ + Back to matches + +
+ +
+ {!result.ok ? ( +

+ error: {result.message} +

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index 1812284..a1540e5 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -86,7 +86,7 @@ function formatPrizeCcChip(v: DbMatch["prize_cc"]): string { } } -type LeaderboardSortKey = "rank" | "player" | "rc" | "winRate" | "wins"; +type LeaderboardSortKey = "rank" | "player" | "rc" | "mmr" | "winRate" | "wins"; function playerSortKey(row: LeaderboardRow): string { const t = row.username?.trim(); @@ -94,6 +94,10 @@ function playerSortKey(row: LeaderboardRow): string { return "\uffff"; } +function isEscrowUsername(username: string | null): boolean { + return (username ?? "").toLowerCase().startsWith("match_escrow_"); +} + function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" { if (key === "player" || key === "rank") return "asc"; return "desc"; @@ -130,6 +134,15 @@ function sortLeaderboardRows( else cmp = a.rcBalance! - b.rcBalance!; break; } + case "mmr": { + const aOk = a.mmr != null && Number.isFinite(a.mmr); + const bOk = b.mmr != null && Number.isFinite(b.mmr); + if (!aOk && !bOk) cmp = 0; + else if (!aOk) cmp = 1; + else if (!bOk) cmp = -1; + else cmp = a.mmr! - b.mmr!; + break; + } case "winRate": { const aOk = a.winRatePercent != null && Number.isFinite(a.winRatePercent); @@ -252,6 +265,8 @@ type Props = { canWritePlayers: boolean; canWriteLedger: boolean; isAdmin: boolean; + /** Match IDs that have a replay JSON file on disk. */ + matchIdsWithReplay: number[]; }; function StatCard({ @@ -318,10 +333,16 @@ export function AdminDashboard({ canWritePlayers, canWriteLedger, isAdmin, + matchIdsWithReplay, }: Props) { + const replaySet = useMemo( + () => new Set(matchIdsWithReplay), + [matchIdsWithReplay], + ); const [hideNoWinner, setHideNoWinner] = useState(true); const [playersSearch, setPlayersSearch] = useState(""); - const [lbSortKey, setLbSortKey] = useState("wins"); + const [showEscrowAccounts, setShowEscrowAccounts] = useState(false); + const [lbSortKey, setLbSortKey] = useState("mmr"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); /** Once we know the browser offset, reload with mtz so day bounds are local. */ @@ -399,16 +420,21 @@ export function AdminDashboard({ if (!hideNoWinner) return filteredMatches; return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id)); }, [filteredMatches, hideNoWinner]); + const playersPool = useMemo(() => { + if (showEscrowAccounts) return users; + return users.filter((u) => !isEscrowUsername(u.username)); + }, [users, showEscrowAccounts]); const filteredUsers = useMemo(() => { const q = playersSearch.trim().toLowerCase(); - if (!q) return users; - return users.filter((u) => { + if (!q) return playersPool; + return playersPool.filter((u) => { const haystack = [ String(u.id), u.username ?? "", u.email ?? "", u.cc == null ? "" : String(u.cc), u.rc == null ? "" : String(u.rc), + u.mmr == null ? "" : String(u.mmr), formatTs(u.created_at), formatTs(u.last_logged_at), u.ip_address ?? "", @@ -417,7 +443,7 @@ export function AdminDashboard({ .toLowerCase(); return haystack.includes(q); }); - }, [users, playersSearch]); + }, [playersPool, playersSearch]); useEffect(() => { if (tab !== "players" || !highlightId) return; @@ -564,15 +590,6 @@ export function AdminDashboard({ System logs ) : null} - {pageAccess.matches || pageAccess.analysis ? ( - - Replays - - ) : null} {isAdmin ? ( + No wins recorded yet (no rows with a winner). @@ -735,6 +760,11 @@ export function AdminDashboard({ {formatRcBalanceWithCoins(row.rcBalance)} + + {row.mmr == null + ? "—" + : row.mmr.toLocaleString("en-US")} + {row.winRatePercent == null ? "—" @@ -767,12 +797,25 @@ export function AdminDashboard({ type="search" value={playersSearch} onChange={(e) => setPlayersSearch(e.target.value)} - placeholder="Search by any field (id, username, email, CC, RC, timestamps)" + placeholder="Search by any field (id, username, email, CC, RC, MMR, timestamps)" className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400" /> + - Showing {filteredUsers.length} of {users.length} + Showing {filteredUsers.length} of {playersPool.length}
@@ -784,6 +827,7 @@ export function AdminDashboard({ Email CC RC + MMR Created Last seen IP @@ -794,11 +838,13 @@ export function AdminDashboard({ {filteredUsers.length === 0 ? ( - {users.length === 0 - ? "No players yet." + {playersPool.length === 0 + ? users.length === 0 + ? "No players yet." + : "No players left after hiding escrow accounts." : "No players match this search."} @@ -826,6 +872,7 @@ export function AdminDashboard({ {u.cc ?? "—"} {u.rc ?? "—"} + {u.mmr ?? "—"} {formatTs(u.created_at)} @@ -1119,6 +1166,7 @@ export function AdminDashboard({ entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} prizeCcLabel={formatPrizeCcChip(m.prize_cc)} winnerId={m.winner_id} + hasReplay={replaySet.has(Number(m.id))} left={{ id: redId, username: leftUser?.username ?? null, diff --git a/src/components/match-history-battle-card.tsx b/src/components/match-history-battle-card.tsx index 2c74d36..9a53027 100644 --- a/src/components/match-history-battle-card.tsx +++ b/src/components/match-history-battle-card.tsx @@ -61,6 +61,7 @@ type Props = { right: PlayerSide; /** May be string when `bigint` is JSON-serialized. */ winnerId: number | string | null; + hasReplay: boolean; }; function idsMatch( @@ -190,6 +191,7 @@ export function MatchHistoryBattleCard({ left, right, winnerId, + hasReplay, }: Props) { const entryLine = entryCombinedPerPlayerTimesTwoLine( entryFeeCoins, @@ -259,14 +261,30 @@ export function MatchHistoryBattleCard({ Entry fee {entryFeeLine}
- - Show logs - +
+ {hasReplay ? ( + + View replay + + ) : ( + + No replay + + )} + + Show logs + +
); diff --git a/src/components/replay-viewer.tsx b/src/components/replay-viewer.tsx index 4d23782..1536a46 100644 --- a/src/components/replay-viewer.tsx +++ b/src/components/replay-viewer.tsx @@ -353,13 +353,15 @@ export function ReplayViewer({ const [dragOver, setDragOver] = useState(false); const canvasRef = useRef(null); - const cutsRef = useRef([]); + const cutsRef = useRef( + initialReplay ? discontinuityTimes(initialReplay.events) : [], + ); const timeRef = useRef(0); const playingRef = useRef(false); const speedRef = useRef(1); const lastTsRef = useRef(null); const eventCursorRef = useRef(0); - const replayRef = useRef(null); + const replayRef = useRef(initialReplay); useEffect(() => { timeRef.current = time; @@ -532,7 +534,7 @@ export function ReplayViewer({

From the game server:{" "} - Logs/{matchId}_replay.json + Logs/{matchId}.json

@@ -547,7 +549,7 @@ export function ReplayViewer({

- {fileName ?? `match_${replay.matchId}_replay.json`} + {fileName ?? `${replay.matchId}.json`}

Match {replay.matchId} diff --git a/src/lib/auth/page-access-labels.ts b/src/lib/auth/page-access-labels.ts index 210ece4..b7c8ee2 100644 --- a/src/lib/auth/page-access-labels.ts +++ b/src/lib/auth/page-access-labels.ts @@ -9,6 +9,7 @@ const PAGE_LABELS: Record = { settings: "Settings", "ledger-book": "Ledger book", "match-log": "Match log", + "match-replay": "Match replay", "matchmaker-logs": "Matchmaker logs", "replay-viewer": "Replay viewer", }; diff --git a/src/lib/dashboard-stats.ts b/src/lib/dashboard-stats.ts index dc54c20..ccf74bc 100644 --- a/src/lib/dashboard-stats.ts +++ b/src/lib/dashboard-stats.ts @@ -18,6 +18,7 @@ export type LeaderboardRow = { /** Matches with `winner_id` equal to this user (same as player card). */ matchesWon: number; rcBalance: number | null; + mmr: number | null; winRatePercent: number | null; /** 1 = most wins in this snapshot (stable order for the “#” column sort). */ winsLeaderboardRank: number; @@ -106,7 +107,7 @@ async function fetchTopPlayersByMatchWins( ); const [usersRes, ...playedRes] = await Promise.all([ - supabase.from("users").select("id, username, email, rc").in("id", ids), + supabase.from("users").select("id, username, email, rc, mmr").in("id", ids), ...playedPromises, ]); @@ -117,6 +118,7 @@ async function fetchTopPlayersByMatchWins( const nameById = new Map(); const emailById = new Map(); const rcById = new Map(); + const mmrById = new Map(); for (const u of usersRes.data ?? []) { const uid = u.id as number; nameById.set(uid, (u.username as string | null) ?? null); @@ -126,6 +128,11 @@ async function fetchTopPlayersByMatchWins( uid, rawRc != null && Number.isFinite(rawRc) ? rawRc : null, ); + const rawMmr = u.mmr as number | null; + mmrById.set( + uid, + rawMmr != null && Number.isFinite(rawMmr) ? rawMmr : null, + ); } const playedErrs: string[] = []; @@ -152,6 +159,7 @@ async function fetchTopPlayersByMatchWins( email: emailById.get(userId) ?? null, matchesWon, rcBalance: rcById.get(userId) ?? null, + mmr: mmrById.get(userId) ?? null, winRatePercent, winsLeaderboardRank: index + 1, }; diff --git a/src/lib/match-replays-server.ts b/src/lib/match-replays-server.ts new file mode 100644 index 0000000..34a6079 --- /dev/null +++ b/src/lib/match-replays-server.ts @@ -0,0 +1,107 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseReplayJsonText } from "@/lib/replay-parse"; +import type { ReplayFile } from "@/types/replay"; + +/** Replay JSON can be several MB; sample match 534 is ~4.6 MB. */ +const MAX_REPLAY_BYTES = 32 * 1024 * 1024; + +export type ReadMatchReplayResult = + | { ok: true; replay: ReplayFile; fileName: string } + | { + ok: false; + status: 400 | 401 | 404 | 413 | 500 | 503; + message: string; + }; + +/** Fast existence check — does not parse the file. */ +export async function replayFileExists(matchId: number): Promise { + const dirRaw = process.env.MATCH_LOGS_DIR?.trim(); + if (!dirRaw) return false; + const base = path.resolve(dirRaw); + const filePath = path.resolve(base, `${matchId}.json`); + if (!filePath.startsWith(base + path.sep)) return false; + try { + const st = await fs.stat(filePath); + return st.isFile(); + } catch { + return false; + } +} + +export async function readMatchReplayFile( + matchId: number, +): Promise { + const dirRaw = process.env.MATCH_LOGS_DIR?.trim(); + if (!dirRaw) { + return { + ok: false, + status: 503, + message: + "Match logs directory is not configured (set MATCH_LOGS_DIR on the server).", + }; + } + + const base = path.resolve(dirRaw); + const fileName = `${matchId}.json`; + const filePath = path.resolve(base, fileName); + if (!filePath.startsWith(base + path.sep)) { + return { ok: false, status: 500, message: "Invalid replay path." }; + } + + let st: Awaited>; + try { + st = await fs.stat(filePath); + } catch (e: unknown) { + const code = + e && typeof e === "object" && "code" in e + ? String((e as { code: unknown }).code) + : ""; + if (code === "ENOENT") { + return { + ok: false, + status: 404, + message: "No replay file for this match.", + }; + } + return { + ok: false, + status: 500, + message: "Could not access replay file.", + }; + } + + if (!st.isFile()) { + return { ok: false, status: 404, message: "Replay path is not a file." }; + } + + if (st.size > MAX_REPLAY_BYTES) { + return { + ok: false, + status: 413, + message: `Replay file is too large (max ${MAX_REPLAY_BYTES} bytes).`, + }; + } + + let text: string; + try { + const buf = await fs.readFile(filePath); + text = buf.toString("utf8"); + } catch { + return { ok: false, status: 500, message: "Could not read replay file." }; + } + + try { + const replay = parseReplayJsonText(text); + return { ok: true, replay, fileName }; + } catch (e) { + return { + ok: false, + status: 500, + message: + e instanceof Error + ? `Invalid replay JSON: ${e.message}` + : "Invalid replay JSON.", + }; + } +} diff --git a/src/types/database.ts b/src/types/database.ts index 96e7630..7c88886 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -8,6 +8,7 @@ export type DbUser = { ip_address: string | null; cc: number | null; rc: number | null; + mmr: number; last_logged_at: string | null; };