show escrow accounts
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"supabase": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ export default async function LoginPage({ searchParams }: Props) {
|
|||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
autoComplete="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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
@@ -45,6 +48,9 @@ export default async function LoginPage({ searchParams }: Props) {
|
|||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
+14
-1
@@ -30,6 +30,7 @@ import {
|
|||||||
type MatchmakerLogSource,
|
type MatchmakerLogSource,
|
||||||
} from "@/lib/matchmaker-log-source";
|
} from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
import { replayFileExists } from "@/lib/match-replays-server";
|
||||||
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
|
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
|
||||||
import {
|
import {
|
||||||
analyzeMatchLogsForMatches,
|
analyzeMatchLogsForMatches,
|
||||||
@@ -202,6 +203,7 @@ export default async function Home({
|
|||||||
let matchmakerError: string | null = null;
|
let matchmakerError: string | null = null;
|
||||||
let auditEntries: AuditLogEntry[] = [];
|
let auditEntries: AuditLogEntry[] = [];
|
||||||
let auditError: string | null = null;
|
let auditError: string | null = null;
|
||||||
|
let matchIdsWithReplay: number[] = [];
|
||||||
if (tab === "matchmaker") {
|
if (tab === "matchmaker") {
|
||||||
const mkRaw = firstSearchParam(sp.mklog);
|
const mkRaw = firstSearchParam(sp.mklog);
|
||||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
||||||
@@ -228,7 +230,7 @@ export default async function Home({
|
|||||||
} else {
|
} else {
|
||||||
const usersRes = await supabase
|
const usersRes = await supabase
|
||||||
.from("users")
|
.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 })
|
.order("id", { ascending: false })
|
||||||
.limit(500);
|
.limit(500);
|
||||||
|
|
||||||
@@ -307,6 +309,16 @@ export default async function Home({
|
|||||||
|
|
||||||
statsBundle = await loadDashboardStatsBundle(supabase);
|
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") {
|
if (tab === "ledger") {
|
||||||
const lfromRaw = firstSearchParam(sp.lfrom);
|
const lfromRaw = firstSearchParam(sp.lfrom);
|
||||||
const ltoRaw = firstSearchParam(sp.lto);
|
const ltoRaw = firstSearchParam(sp.lto);
|
||||||
@@ -455,6 +467,7 @@ export default async function Home({
|
|||||||
canWritePlayers={canWritePlayers}
|
canWritePlayers={canWritePlayers}
|
||||||
canWriteLedger={canWriteLedger}
|
canWriteLedger={canWriteLedger}
|
||||||
isAdmin={account.isAdmin}
|
isAdmin={account.isAdmin}
|
||||||
|
matchIdsWithReplay={matchIdsWithReplay}
|
||||||
/>
|
/>
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<EditUserCcRcOverlay
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ReplayViewer } from "@/components/replay-viewer";
|
||||||
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
|
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||||
|
import { requireSession } from "@/lib/auth/require-session";
|
||||||
|
import { parseMatchIdParam } from "@/lib/match-logs-server";
|
||||||
|
import { readMatchReplayFile } from "@/lib/match-replays-server";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ matchId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
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 (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
|
||||||
|
<header className="flex shrink-0 items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||||
|
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||||
|
</div>
|
||||||
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||||
|
<span className="text-emerald-400/90">soccar</span>
|
||||||
|
<span className="text-zinc-500"> — </span>
|
||||||
|
<span className="text-zinc-100">match_{matchId}.json</span>
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/?tab=matches"
|
||||||
|
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to matches
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||||
|
{!result.ok ? (
|
||||||
|
<p className="font-mono text-sm text-red-400">
|
||||||
|
<span className="text-red-500/80">error:</span> {result.message}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ReplayViewer
|
||||||
|
initialReplay={result.replay}
|
||||||
|
initialFileName={result.fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
function playerSortKey(row: LeaderboardRow): string {
|
||||||
const t = row.username?.trim();
|
const t = row.username?.trim();
|
||||||
@@ -94,6 +94,10 @@ function playerSortKey(row: LeaderboardRow): string {
|
|||||||
return "\uffff";
|
return "\uffff";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEscrowUsername(username: string | null): boolean {
|
||||||
|
return (username ?? "").toLowerCase().startsWith("match_escrow_");
|
||||||
|
}
|
||||||
|
|
||||||
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
|
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
|
||||||
if (key === "player" || key === "rank") return "asc";
|
if (key === "player" || key === "rank") return "asc";
|
||||||
return "desc";
|
return "desc";
|
||||||
@@ -130,6 +134,15 @@ function sortLeaderboardRows(
|
|||||||
else cmp = a.rcBalance! - b.rcBalance!;
|
else cmp = a.rcBalance! - b.rcBalance!;
|
||||||
break;
|
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": {
|
case "winRate": {
|
||||||
const aOk =
|
const aOk =
|
||||||
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
|
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
|
||||||
@@ -252,6 +265,8 @@ type Props = {
|
|||||||
canWritePlayers: boolean;
|
canWritePlayers: boolean;
|
||||||
canWriteLedger: boolean;
|
canWriteLedger: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** Match IDs that have a replay JSON file on disk. */
|
||||||
|
matchIdsWithReplay: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
@@ -318,10 +333,16 @@ export function AdminDashboard({
|
|||||||
canWritePlayers,
|
canWritePlayers,
|
||||||
canWriteLedger,
|
canWriteLedger,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
matchIdsWithReplay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const replaySet = useMemo(
|
||||||
|
() => new Set(matchIdsWithReplay),
|
||||||
|
[matchIdsWithReplay],
|
||||||
|
);
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
const [playersSearch, setPlayersSearch] = useState("");
|
const [playersSearch, setPlayersSearch] = useState("");
|
||||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
|
||||||
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("mmr");
|
||||||
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
/** Once we know the browser offset, reload with mtz so day bounds are local. */
|
/** 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;
|
if (!hideNoWinner) return filteredMatches;
|
||||||
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
||||||
}, [filteredMatches, hideNoWinner]);
|
}, [filteredMatches, hideNoWinner]);
|
||||||
|
const playersPool = useMemo(() => {
|
||||||
|
if (showEscrowAccounts) return users;
|
||||||
|
return users.filter((u) => !isEscrowUsername(u.username));
|
||||||
|
}, [users, showEscrowAccounts]);
|
||||||
const filteredUsers = useMemo(() => {
|
const filteredUsers = useMemo(() => {
|
||||||
const q = playersSearch.trim().toLowerCase();
|
const q = playersSearch.trim().toLowerCase();
|
||||||
if (!q) return users;
|
if (!q) return playersPool;
|
||||||
return users.filter((u) => {
|
return playersPool.filter((u) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
String(u.id),
|
String(u.id),
|
||||||
u.username ?? "",
|
u.username ?? "",
|
||||||
u.email ?? "",
|
u.email ?? "",
|
||||||
u.cc == null ? "" : String(u.cc),
|
u.cc == null ? "" : String(u.cc),
|
||||||
u.rc == null ? "" : String(u.rc),
|
u.rc == null ? "" : String(u.rc),
|
||||||
|
u.mmr == null ? "" : String(u.mmr),
|
||||||
formatTs(u.created_at),
|
formatTs(u.created_at),
|
||||||
formatTs(u.last_logged_at),
|
formatTs(u.last_logged_at),
|
||||||
u.ip_address ?? "",
|
u.ip_address ?? "",
|
||||||
@@ -417,7 +443,7 @@ export function AdminDashboard({
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return haystack.includes(q);
|
return haystack.includes(q);
|
||||||
});
|
});
|
||||||
}, [users, playersSearch]);
|
}, [playersPool, playersSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "players" || !highlightId) return;
|
if (tab !== "players" || !highlightId) return;
|
||||||
@@ -564,15 +590,6 @@ export function AdminDashboard({
|
|||||||
System logs
|
System logs
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{pageAccess.matches || pageAccess.analysis ? (
|
|
||||||
<Link
|
|
||||||
href="/replays"
|
|
||||||
className={tabClass(false)}
|
|
||||||
scroll={false}
|
|
||||||
>
|
|
||||||
Replays
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
@@ -679,6 +696,14 @@ export function AdminDashboard({
|
|||||||
activeDir={lbSortDir}
|
activeDir={lbSortDir}
|
||||||
onActivate={onLbSort}
|
onActivate={onLbSort}
|
||||||
/>
|
/>
|
||||||
|
<LeaderboardSortTh
|
||||||
|
label="MMR"
|
||||||
|
sortKey="mmr"
|
||||||
|
activeKey={lbSortKey}
|
||||||
|
activeDir={lbSortDir}
|
||||||
|
onActivate={onLbSort}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
<LeaderboardSortTh
|
<LeaderboardSortTh
|
||||||
label="Win rate"
|
label="Win rate"
|
||||||
sortKey="winRate"
|
sortKey="winRate"
|
||||||
@@ -701,7 +726,7 @@ export function AdminDashboard({
|
|||||||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
className="px-4 py-8 text-center text-zinc-500"
|
className="px-4 py-8 text-center text-zinc-500"
|
||||||
>
|
>
|
||||||
No wins recorded yet (no rows with a winner).
|
No wins recorded yet (no rows with a winner).
|
||||||
@@ -735,6 +760,11 @@ export function AdminDashboard({
|
|||||||
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
|
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
|
||||||
{formatRcBalanceWithCoins(row.rcBalance)}
|
{formatRcBalanceWithCoins(row.rcBalance)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||||||
|
{row.mmr == null
|
||||||
|
? "—"
|
||||||
|
: row.mmr.toLocaleString("en-US")}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||||||
{row.winRatePercent == null
|
{row.winRatePercent == null
|
||||||
? "—"
|
? "—"
|
||||||
@@ -767,12 +797,25 @@ export function AdminDashboard({
|
|||||||
type="search"
|
type="search"
|
||||||
value={playersSearch}
|
value={playersSearch}
|
||||||
onChange={(e) => setPlayersSearch(e.target.value)}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label
|
||||||
|
htmlFor="admin-show-escrow-accounts"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 text-sm whitespace-nowrap text-zinc-700 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="admin-show-escrow-accounts"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showEscrowAccounts}
|
||||||
|
onChange={(e) => setShowEscrowAccounts(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
|
||||||
|
/>
|
||||||
|
Show escrow accounts
|
||||||
|
</label>
|
||||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
Showing {filteredUsers.length} of {users.length}
|
Showing {filteredUsers.length} of {playersPool.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
@@ -784,6 +827,7 @@ export function AdminDashboard({
|
|||||||
<th className="px-4 py-3 font-medium">Email</th>
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
<th className="px-4 py-3 font-medium">CC</th>
|
<th className="px-4 py-3 font-medium">CC</th>
|
||||||
<th className="px-4 py-3 font-medium">RC</th>
|
<th className="px-4 py-3 font-medium">RC</th>
|
||||||
|
<th className="px-4 py-3 font-medium">MMR</th>
|
||||||
<th className="px-4 py-3 font-medium">Created</th>
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
<th className="px-4 py-3 font-medium">Last seen</th>
|
<th className="px-4 py-3 font-medium">Last seen</th>
|
||||||
<th className="px-4 py-3 font-medium">IP</th>
|
<th className="px-4 py-3 font-medium">IP</th>
|
||||||
@@ -794,11 +838,13 @@ export function AdminDashboard({
|
|||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={10}
|
||||||
className="px-4 py-8 text-center text-zinc-500"
|
className="px-4 py-8 text-center text-zinc-500"
|
||||||
>
|
>
|
||||||
{users.length === 0
|
{playersPool.length === 0
|
||||||
|
? users.length === 0
|
||||||
? "No players yet."
|
? "No players yet."
|
||||||
|
: "No players left after hiding escrow accounts."
|
||||||
: "No players match this search."}
|
: "No players match this search."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -826,6 +872,7 @@ export function AdminDashboard({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">{u.cc ?? "—"}</td>
|
<td className="px-4 py-2">{u.cc ?? "—"}</td>
|
||||||
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2">{u.mmr ?? "—"}</td>
|
||||||
<td className="px-4 py-2 whitespace-nowrap">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
{formatTs(u.created_at)}
|
{formatTs(u.created_at)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1119,6 +1166,7 @@ export function AdminDashboard({
|
|||||||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||||||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||||||
winnerId={m.winner_id}
|
winnerId={m.winner_id}
|
||||||
|
hasReplay={replaySet.has(Number(m.id))}
|
||||||
left={{
|
left={{
|
||||||
id: redId,
|
id: redId,
|
||||||
username: leftUser?.username ?? null,
|
username: leftUser?.username ?? null,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type Props = {
|
|||||||
right: PlayerSide;
|
right: PlayerSide;
|
||||||
/** May be string when `bigint` is JSON-serialized. */
|
/** May be string when `bigint` is JSON-serialized. */
|
||||||
winnerId: number | string | null;
|
winnerId: number | string | null;
|
||||||
|
hasReplay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function idsMatch(
|
function idsMatch(
|
||||||
@@ -190,6 +191,7 @@ export function MatchHistoryBattleCard({
|
|||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
winnerId,
|
winnerId,
|
||||||
|
hasReplay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const entryLine = entryCombinedPerPlayerTimesTwoLine(
|
const entryLine = entryCombinedPerPlayerTimesTwoLine(
|
||||||
entryFeeCoins,
|
entryFeeCoins,
|
||||||
@@ -259,6 +261,21 @@ export function MatchHistoryBattleCard({
|
|||||||
Entry fee {entryFeeLine}
|
Entry fee {entryFeeLine}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{hasReplay ? (
|
||||||
|
<Link
|
||||||
|
href={`/replays/${matchId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-md border border-zinc-400 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-800 shadow-sm transition hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
View replay
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="px-1 text-xs text-zinc-400 dark:text-zinc-600">
|
||||||
|
No replay
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/match-logs/${matchId}`}
|
href={`/match-logs/${matchId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -268,6 +285,7 @@ export function MatchHistoryBattleCard({
|
|||||||
Show logs
|
Show logs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,13 +353,15 @@ export function ReplayViewer({
|
|||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const cutsRef = useRef<number[]>([]);
|
const cutsRef = useRef<number[]>(
|
||||||
|
initialReplay ? discontinuityTimes(initialReplay.events) : [],
|
||||||
|
);
|
||||||
const timeRef = useRef(0);
|
const timeRef = useRef(0);
|
||||||
const playingRef = useRef(false);
|
const playingRef = useRef(false);
|
||||||
const speedRef = useRef(1);
|
const speedRef = useRef(1);
|
||||||
const lastTsRef = useRef<number | null>(null);
|
const lastTsRef = useRef<number | null>(null);
|
||||||
const eventCursorRef = useRef(0);
|
const eventCursorRef = useRef(0);
|
||||||
const replayRef = useRef<ReplayFile | null>(null);
|
const replayRef = useRef<ReplayFile | null>(initialReplay);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
timeRef.current = time;
|
timeRef.current = time;
|
||||||
@@ -532,7 +534,7 @@ export function ReplayViewer({
|
|||||||
<p className="mt-2 max-w-md text-sm text-zinc-400">
|
<p className="mt-2 max-w-md text-sm text-zinc-400">
|
||||||
From the game server:{" "}
|
From the game server:{" "}
|
||||||
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
|
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
|
||||||
Logs/{matchId}_replay.json
|
Logs/{matchId}.json
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
|
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
|
||||||
@@ -547,7 +549,7 @@ export function ReplayViewer({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-mono text-sm text-zinc-300">
|
<p className="truncate font-mono text-sm text-zinc-300">
|
||||||
{fileName ?? `match_${replay.matchId}_replay.json`}
|
{fileName ?? `${replay.matchId}.json`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-zinc-500">
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
Match {replay.matchId}
|
Match {replay.matchId}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const PAGE_LABELS: Record<string, string> = {
|
|||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
"ledger-book": "Ledger book",
|
"ledger-book": "Ledger book",
|
||||||
"match-log": "Match log",
|
"match-log": "Match log",
|
||||||
|
"match-replay": "Match replay",
|
||||||
"matchmaker-logs": "Matchmaker logs",
|
"matchmaker-logs": "Matchmaker logs",
|
||||||
"replay-viewer": "Replay viewer",
|
"replay-viewer": "Replay viewer",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type LeaderboardRow = {
|
|||||||
/** Matches with `winner_id` equal to this user (same as player card). */
|
/** Matches with `winner_id` equal to this user (same as player card). */
|
||||||
matchesWon: number;
|
matchesWon: number;
|
||||||
rcBalance: number | null;
|
rcBalance: number | null;
|
||||||
|
mmr: number | null;
|
||||||
winRatePercent: number | null;
|
winRatePercent: number | null;
|
||||||
/** 1 = most wins in this snapshot (stable order for the “#” column sort). */
|
/** 1 = most wins in this snapshot (stable order for the “#” column sort). */
|
||||||
winsLeaderboardRank: number;
|
winsLeaderboardRank: number;
|
||||||
@@ -106,7 +107,7 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [usersRes, ...playedRes] = await Promise.all([
|
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,
|
...playedPromises,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
const nameById = new Map<number, string | null>();
|
const nameById = new Map<number, string | null>();
|
||||||
const emailById = new Map<number, string | null>();
|
const emailById = new Map<number, string | null>();
|
||||||
const rcById = new Map<number, number | null>();
|
const rcById = new Map<number, number | null>();
|
||||||
|
const mmrById = new Map<number, number | null>();
|
||||||
for (const u of usersRes.data ?? []) {
|
for (const u of usersRes.data ?? []) {
|
||||||
const uid = u.id as number;
|
const uid = u.id as number;
|
||||||
nameById.set(uid, (u.username as string | null) ?? null);
|
nameById.set(uid, (u.username as string | null) ?? null);
|
||||||
@@ -126,6 +128,11 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
uid,
|
uid,
|
||||||
rawRc != null && Number.isFinite(rawRc) ? rawRc : null,
|
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[] = [];
|
const playedErrs: string[] = [];
|
||||||
@@ -152,6 +159,7 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
email: emailById.get(userId) ?? null,
|
email: emailById.get(userId) ?? null,
|
||||||
matchesWon,
|
matchesWon,
|
||||||
rcBalance: rcById.get(userId) ?? null,
|
rcBalance: rcById.get(userId) ?? null,
|
||||||
|
mmr: mmrById.get(userId) ?? null,
|
||||||
winRatePercent,
|
winRatePercent,
|
||||||
winsLeaderboardRank: index + 1,
|
winsLeaderboardRank: index + 1,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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<ReadMatchReplayResult> {
|
||||||
|
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<ReturnType<typeof fs.stat>>;
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export type DbUser = {
|
|||||||
ip_address: string | null;
|
ip_address: string | null;
|
||||||
cc: number | null;
|
cc: number | null;
|
||||||
rc: number | null;
|
rc: number | null;
|
||||||
|
mmr: number;
|
||||||
last_logged_at: string | null;
|
last_logged_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user