show escrow accounts
This commit is contained in:
@@ -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<LeaderboardSortKey>("wins");
|
||||
const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
|
||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("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
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches || pageAccess.analysis ? (
|
||||
<Link
|
||||
href="/replays"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Replays
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/settings"
|
||||
@@ -679,6 +696,14 @@ export function AdminDashboard({
|
||||
activeDir={lbSortDir}
|
||||
onActivate={onLbSort}
|
||||
/>
|
||||
<LeaderboardSortTh
|
||||
label="MMR"
|
||||
sortKey="mmr"
|
||||
activeKey={lbSortKey}
|
||||
activeDir={lbSortDir}
|
||||
onActivate={onLbSort}
|
||||
align="right"
|
||||
/>
|
||||
<LeaderboardSortTh
|
||||
label="Win rate"
|
||||
sortKey="winRate"
|
||||
@@ -701,7 +726,7 @@ export function AdminDashboard({
|
||||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="px-4 py-8 text-center text-zinc-500"
|
||||
>
|
||||
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">
|
||||
{formatRcBalanceWithCoins(row.rcBalance)}
|
||||
</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">
|
||||
{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"
|
||||
/>
|
||||
</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">
|
||||
Showing {filteredUsers.length} of {users.length}
|
||||
Showing {filteredUsers.length} of {playersPool.length}
|
||||
</span>
|
||||
</div>
|
||||
<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">CC</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">Last seen</th>
|
||||
<th className="px-4 py-3 font-medium">IP</th>
|
||||
@@ -794,11 +838,13 @@ export function AdminDashboard({
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={9}
|
||||
colSpan={10}
|
||||
className="px-4 py-8 text-center text-zinc-500"
|
||||
>
|
||||
{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."}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -826,6 +872,7 @@ export function AdminDashboard({
|
||||
</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.mmr ?? "—"}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{formatTs(u.created_at)}
|
||||
</td>
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/match-logs/${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"
|
||||
>
|
||||
Show logs
|
||||
</Link>
|
||||
<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
|
||||
href={`/match-logs/${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"
|
||||
>
|
||||
Show logs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -353,13 +353,15 @@ export function ReplayViewer({
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const cutsRef = useRef<number[]>([]);
|
||||
const cutsRef = useRef<number[]>(
|
||||
initialReplay ? discontinuityTimes(initialReplay.events) : [],
|
||||
);
|
||||
const timeRef = useRef(0);
|
||||
const playingRef = useRef(false);
|
||||
const speedRef = useRef(1);
|
||||
const lastTsRef = useRef<number | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const replayRef = useRef<ReplayFile | null>(null);
|
||||
const replayRef = useRef<ReplayFile | null>(initialReplay);
|
||||
|
||||
useEffect(() => {
|
||||
timeRef.current = time;
|
||||
@@ -532,7 +534,7 @@ export function ReplayViewer({
|
||||
<p className="mt-2 max-w-md text-sm text-zinc-400">
|
||||
From the game server:{" "}
|
||||
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
|
||||
Logs/{matchId}_replay.json
|
||||
Logs/{matchId}.json
|
||||
</code>
|
||||
</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">
|
||||
@@ -547,7 +549,7 @@ export function ReplayViewer({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm text-zinc-300">
|
||||
{fileName ?? `match_${replay.matchId}_replay.json`}
|
||||
{fileName ?? `${replay.matchId}.json`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Match {replay.matchId}
|
||||
|
||||
Reference in New Issue
Block a user