Rich per-player stats + pick-your-metric leaderboard
- lib/player-stats.ts: single computePlayerStats() reads every world/stats/<uuid>.json plus world/advancements/<uuid>.json and joins with usercache.json. Returns a flat PlayerStats record per player: playtime, longest life, mob/player kills, deaths, K/D, damage dealt and taken (HP-scaled), blocks mined, items used / crafted / picked up (+ unique), distance (summed across all _one_cm stats), chest opens, nether/end trips, villager trades, fish caught, animals bred, and advancements (non-recipe) / recipes unlocked. - New GET /api/players/stats (authed, 60s memo). Existing /api/players/playtime now returns a thin projection of the same computed data (shared cache key keeps both endpoints cheap). - New components/Leaderboard.tsx with a metric select grouped into Time / Combat / World / Exploration / Progression / Economy (22 metrics). Sorts descending, top 10 with "show all" toggle, smart number formatting (1.2k / 3.4M / HP / km). Replaces the old PlaytimeLeaderboard in the Players tab. - PlayerDrawer upgraded: uses the full stats payload, shows small tiles for Kills / Deaths / K/D / Advs / Mined / Crafted / Distance alongside Playtime + Last seen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ccd4ca772
commit
3a69dc9243
7 changed files with 521 additions and 214 deletions
|
|
@ -1,71 +1,11 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { readdirSync, readFileSync, statSync } from "fs";
|
|
||||||
import { join } from "path";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { memoAsync } from "@/lib/cache";
|
import { memoAsync } from "@/lib/cache";
|
||||||
|
import { computePlayerStats } from "@/lib/player-stats";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const STATS_DIR = "/home/minecraft/server/world/stats";
|
|
||||||
const USERCACHE = "/home/minecraft/server/usercache.json";
|
|
||||||
const TICKS_PER_HOUR = 20 * 60 * 60;
|
|
||||||
|
|
||||||
type Entry = {
|
|
||||||
uuid: string;
|
|
||||||
name: string;
|
|
||||||
playtimeTicks: number;
|
|
||||||
playtimeHours: number;
|
|
||||||
lastPlayedMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserCacheEntry = { name: string; uuid: string };
|
|
||||||
|
|
||||||
function loadNameMap(): Map<string, string> {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(readFileSync(USERCACHE, "utf8")) as UserCacheEntry[];
|
|
||||||
return new Map(arr.map((e) => [e.uuid, e.name]));
|
|
||||||
} catch {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePlaytime(): Entry[] {
|
|
||||||
const names = loadNameMap();
|
|
||||||
let files: string[];
|
|
||||||
try {
|
|
||||||
files = readdirSync(STATS_DIR).filter((f) => f.endsWith(".json"));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: Entry[] = [];
|
|
||||||
for (const f of files) {
|
|
||||||
const uuid = f.replace(/\.json$/, "");
|
|
||||||
const path = join(STATS_DIR, f);
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(readFileSync(path, "utf8")) as {
|
|
||||||
stats?: { "minecraft:custom"?: Record<string, number> };
|
|
||||||
};
|
|
||||||
const ticks = Number(
|
|
||||||
data.stats?.["minecraft:custom"]?.["minecraft:play_time"] || 0
|
|
||||||
);
|
|
||||||
const mtime = statSync(path).mtimeMs;
|
|
||||||
out.push({
|
|
||||||
uuid,
|
|
||||||
name: names.get(uuid) || uuid.slice(0, 8),
|
|
||||||
playtimeTicks: ticks,
|
|
||||||
playtimeHours: Math.round((ticks / TICKS_PER_HOUR) * 100) / 100,
|
|
||||||
lastPlayedMs: mtime,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Skip unreadable/corrupt stat files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort((a, b) => b.playtimeTicks - a.playtimeTicks);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
@ -73,9 +13,18 @@ export async function GET() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await memoAsync("players:playtime", 60_000, async () =>
|
const full = await memoAsync("players:stats", 60_000, async () =>
|
||||||
computePlaytime()
|
computePlayerStats()
|
||||||
);
|
);
|
||||||
|
const data = full
|
||||||
|
.map((p) => ({
|
||||||
|
uuid: p.uuid,
|
||||||
|
name: p.name,
|
||||||
|
playtimeTicks: Math.round(p.playtimeHours * 20 * 60 * 60),
|
||||||
|
playtimeHours: p.playtimeHours,
|
||||||
|
lastPlayedMs: p.lastPlayedMs,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.playtimeHours - a.playtimeHours);
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data, {
|
||||||
headers: { "Cache-Control": "private, max-age=60" },
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
28
app/api/players/stats/route.ts
Normal file
28
app/api/players/stats/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { memoAsync } from "@/lib/cache";
|
||||||
|
import { computePlayerStats } from "@/lib/player-stats";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await memoAsync("players:stats", 60_000, async () =>
|
||||||
|
computePlayerStats()
|
||||||
|
);
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (e as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
221
components/Leaderboard.tsx
Normal file
221
components/Leaderboard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
||||||
|
import { formatHours, timeAgo } from "@/lib/time";
|
||||||
|
import type { PlayerStats } from "@/lib/player-stats";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type MetricKey =
|
||||||
|
| "playtimeHours"
|
||||||
|
| "distanceKm"
|
||||||
|
| "mobKills"
|
||||||
|
| "playerKills"
|
||||||
|
| "deaths"
|
||||||
|
| "kdr"
|
||||||
|
| "damageDealt"
|
||||||
|
| "damageTaken"
|
||||||
|
| "blocksMined"
|
||||||
|
| "itemsUsed"
|
||||||
|
| "itemsCrafted"
|
||||||
|
| "itemsPickedUp"
|
||||||
|
| "uniqueItemsPickedUp"
|
||||||
|
| "advancements"
|
||||||
|
| "recipesUnlocked"
|
||||||
|
| "chestOpens"
|
||||||
|
| "netherPortalEnters"
|
||||||
|
| "endPortalEnters"
|
||||||
|
| "villagerTrades"
|
||||||
|
| "fishCaught"
|
||||||
|
| "animalsBred"
|
||||||
|
| "longestLifeHours";
|
||||||
|
|
||||||
|
type Metric = {
|
||||||
|
key: MetricKey;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
format: (v: number) => string;
|
||||||
|
suffix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const METRICS: Metric[] = [
|
||||||
|
{ key: "playtimeHours", label: "Playtime", group: "Time", format: (v) => formatHours(v) },
|
||||||
|
{ key: "longestLifeHours", label: "Longest life", group: "Time", format: (v) => formatHours(v) },
|
||||||
|
|
||||||
|
{ key: "mobKills", label: "Mob kills", group: "Combat", format: fmtInt },
|
||||||
|
{ key: "playerKills", label: "Player kills", group: "Combat", format: fmtInt },
|
||||||
|
{ key: "deaths", label: "Deaths", group: "Combat", format: fmtInt },
|
||||||
|
{ key: "kdr", label: "K/D ratio", group: "Combat", format: (v) => v.toFixed(2) },
|
||||||
|
{ key: "damageDealt", label: "Damage dealt", group: "Combat", format: fmtInt, suffix: " HP" },
|
||||||
|
{ key: "damageTaken", label: "Damage taken", group: "Combat", format: fmtInt, suffix: " HP" },
|
||||||
|
|
||||||
|
{ key: "blocksMined", label: "Blocks mined", group: "World", format: fmtInt },
|
||||||
|
{ key: "itemsUsed", label: "Items used / placed", group: "World", format: fmtInt },
|
||||||
|
{ key: "itemsCrafted", label: "Items crafted", group: "World", format: fmtInt },
|
||||||
|
{ key: "itemsPickedUp", label: "Items picked up", group: "World", format: fmtInt },
|
||||||
|
{ key: "uniqueItemsPickedUp", label: "Unique items seen", group: "World", format: fmtInt },
|
||||||
|
|
||||||
|
{ key: "distanceKm", label: "Distance traveled", group: "Exploration", format: (v) => v.toFixed(1), suffix: " km" },
|
||||||
|
{ key: "chestOpens", label: "Chests opened", group: "Exploration", format: fmtInt },
|
||||||
|
{ key: "netherPortalEnters", label: "Nether trips", group: "Exploration", format: fmtInt },
|
||||||
|
{ key: "endPortalEnters", label: "End trips", group: "Exploration", format: fmtInt },
|
||||||
|
|
||||||
|
{ key: "advancements", label: "Advancements", group: "Progression", format: fmtInt },
|
||||||
|
{ key: "recipesUnlocked", label: "Recipes unlocked", group: "Progression", format: fmtInt },
|
||||||
|
|
||||||
|
{ key: "villagerTrades", label: "Villager trades", group: "Economy", format: fmtInt },
|
||||||
|
{ key: "fishCaught", label: "Fish caught", group: "Economy", format: fmtInt },
|
||||||
|
{ key: "animalsBred", label: "Animals bred", group: "Economy", format: fmtInt },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtInt(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 10_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||||
|
return v.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_N = 10;
|
||||||
|
|
||||||
|
export function Leaderboard() {
|
||||||
|
const [metricKey, setMetricKey] = useState<MetricKey>("playtimeHours");
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
const { data = [], isLoading, isError } = useQuery<PlayerStats[]>({
|
||||||
|
queryKey: ["players-stats"],
|
||||||
|
queryFn: () => fetch("/api/players/stats").then((r) => r.json()),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchInterval: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metric = METRICS.find((m) => m.key === metricKey)!;
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...data].sort(
|
||||||
|
(a, b) => (b[metricKey] as number) - (a[metricKey] as number)
|
||||||
|
);
|
||||||
|
}, [data, metricKey]);
|
||||||
|
|
||||||
|
const visible = showAll ? sorted : sorted.slice(0, TOP_N);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g = new Map<string, Metric[]>();
|
||||||
|
for (const m of METRICS) {
|
||||||
|
if (!g.has(m.group)) g.set(m.group, []);
|
||||||
|
g.get(m.group)!.push(m);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Leaderboard</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ranked by{" "}
|
||||||
|
<span className="text-foreground font-medium">{metric.label}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={metricKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMetricKey(e.target.value as MetricKey);
|
||||||
|
setShowAll(false);
|
||||||
|
}}
|
||||||
|
aria-label="Ranking metric"
|
||||||
|
className="h-9 rounded-md border border-input bg-muted px-2 text-sm max-w-full"
|
||||||
|
>
|
||||||
|
{Array.from(groups.entries()).map(([group, metrics]) => (
|
||||||
|
<optgroup key={group} label={group}>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<option key={m.key} value={m.key}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-3 px-3 py-2">
|
||||||
|
<Skeleton className="h-6 w-6 rounded" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-14 ml-auto" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-300 text-center py-4">
|
||||||
|
Failed to load stats.
|
||||||
|
</p>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No player stats yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{visible.map((p, i) => {
|
||||||
|
const raw = p[metricKey] as number;
|
||||||
|
const display = `${metric.format(raw)}${metric.suffix || ""}`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={p.uuid}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 text-center shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<PlayerAvatar name={p.name} size={24} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{p.name}</p>
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
title={new Date(p.lastPlayedMs).toLocaleString()}
|
||||||
|
>
|
||||||
|
last seen {timeAgo(p.lastPlayedMs)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1.5 py-0 tabular-nums shrink-0"
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{sorted.length > TOP_N && (
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{showAll ? "Show top 10" : `Show all ${sorted.length}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
||||||
import { onAppEvent } from "@/lib/events";
|
import { onAppEvent } from "@/lib/events";
|
||||||
import { formatHours, timeAgo } from "@/lib/time";
|
import { formatHours, timeAgo } from "@/lib/time";
|
||||||
import type { PlaytimeEntry } from "@/components/PlaytimeLeaderboard";
|
import type { PlayerStats } from "@/lib/player-stats";
|
||||||
|
|
||||||
type PlayerData = {
|
type PlayerData = {
|
||||||
ops: { name: string; uuid: string; level: number }[];
|
ops: { name: string; uuid: string; level: number }[];
|
||||||
|
|
@ -47,16 +47,14 @@ export function PlayerDrawer() {
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const playtime = useQuery<PlaytimeEntry[]>({
|
const stats = useQuery<PlayerStats[]>({
|
||||||
queryKey: ["players-playtime"],
|
queryKey: ["players-stats"],
|
||||||
queryFn: () => fetch("/api/players/playtime").then((r) => r.json()),
|
queryFn: () => fetch("/api/players/stats").then((r) => r.json()),
|
||||||
enabled: authed && !!name,
|
enabled: authed && !!name,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const myPlaytime = name
|
const mine = name ? stats.data?.find((p) => p.name === name) : undefined;
|
||||||
? playtime.data?.find((p) => p.name === name)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Use existing analytics cache (last entry) to determine online now
|
// Use existing analytics cache (last entry) to determine online now
|
||||||
const analytics = queryClient.getQueryData<MetricEntry[]>(["analytics", 6]);
|
const analytics = queryClient.getQueryData<MetricEntry[]>(["analytics", 6]);
|
||||||
|
|
@ -157,22 +155,30 @@ export function PlayerDrawer() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{myPlaytime && (
|
{mine && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<p className="text-xs text-muted-foreground">Playtime</p>
|
<Tile label="Playtime" value={formatHours(mine.playtimeHours)} />
|
||||||
<p className="text-lg font-bold tabular-nums">
|
<Tile
|
||||||
{formatHours(myPlaytime.playtimeHours)}
|
label="Last seen"
|
||||||
</p>
|
value={timeAgo(mine.lastPlayedMs)}
|
||||||
|
title={new Date(mine.lastPlayedMs).toLocaleString()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<p className="text-xs text-muted-foreground">Last seen</p>
|
<Tile small label="Kills" value={mine.mobKills.toLocaleString()} />
|
||||||
<p
|
<Tile small label="Deaths" value={mine.deaths.toLocaleString()} />
|
||||||
className="text-sm font-medium"
|
<Tile small label="K/D" value={mine.kdr.toFixed(2)} />
|
||||||
title={new Date(myPlaytime.lastPlayedMs).toLocaleString()}
|
<Tile small label="Advs" value={mine.advancements.toLocaleString()} />
|
||||||
>
|
</div>
|
||||||
{timeAgo(myPlaytime.lastPlayedMs)}
|
<div className="grid grid-cols-3 gap-2">
|
||||||
</p>
|
<Tile small label="Mined" value={fmtInt(mine.blocksMined)} />
|
||||||
|
<Tile small label="Crafted" value={fmtInt(mine.itemsCrafted)} />
|
||||||
|
<Tile
|
||||||
|
small
|
||||||
|
label="Distance"
|
||||||
|
value={`${mine.distanceKm.toFixed(1)} km`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -259,7 +265,7 @@ export function PlayerDrawer() {
|
||||||
{players.data?.ops.find((p) => p.name === name)?.uuid ||
|
{players.data?.ops.find((p) => p.name === name)?.uuid ||
|
||||||
players.data?.whitelist.find((p) => p.name === name)?.uuid ||
|
players.data?.whitelist.find((p) => p.name === name)?.uuid ||
|
||||||
players.data?.banned.find((p) => p.name === name)?.uuid ||
|
players.data?.banned.find((p) => p.name === name)?.uuid ||
|
||||||
myPlaytime?.uuid ||
|
mine?.uuid ||
|
||||||
"—"}
|
"—"}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -275,3 +281,32 @@ export function PlayerDrawer() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
small = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
title?: string;
|
||||||
|
small?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted p-3" title={title}>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p
|
||||||
|
className={`${small ? "text-sm" : "text-lg"} font-bold tabular-nums truncate`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInt(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 10_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||||
|
return v.toLocaleString();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
||||||
import { PlaytimeLeaderboard } from "@/components/PlaytimeLeaderboard";
|
import { Leaderboard } from "@/components/Leaderboard";
|
||||||
|
|
||||||
type PlayerData = {
|
type PlayerData = {
|
||||||
ops: { name: string; uuid: string; level: number }[];
|
ops: { name: string; uuid: string; level: number }[];
|
||||||
|
|
@ -129,7 +129,7 @@ export function PlayerManager() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<PlaytimeLeaderboard />
|
<Leaderboard />
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Player Management</CardTitle>
|
<CardTitle>Player Management</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
|
||||||
import { formatHours, timeAgo } from "@/lib/time";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export type PlaytimeEntry = {
|
|
||||||
uuid: string;
|
|
||||||
name: string;
|
|
||||||
playtimeTicks: number;
|
|
||||||
playtimeHours: number;
|
|
||||||
lastPlayedMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOP_N = 10;
|
|
||||||
|
|
||||||
export function PlaytimeLeaderboard() {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const { data = [], isLoading, isError } = useQuery<PlaytimeEntry[]>({
|
|
||||||
queryKey: ["players-playtime"],
|
|
||||||
queryFn: () => fetch("/api/players/playtime").then((r) => r.json()),
|
|
||||||
staleTime: 60_000,
|
|
||||||
refetchInterval: 5 * 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const visible = showAll ? data : data.slice(0, TOP_N);
|
|
||||||
const totalHours = data.reduce((sum, e) => sum + e.playtimeHours, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">Playtime</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Total hours played by each whitelisted player
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{data.length > 0 && (
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-xs text-muted-foreground">Combined</p>
|
|
||||||
<p className="text-sm font-semibold tabular-nums">
|
|
||||||
{formatHours(totalHours)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<li key={i} className="flex items-center gap-3 px-3 py-2">
|
|
||||||
<Skeleton className="h-6 w-6 rounded" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-3 w-14 ml-auto" />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : isError ? (
|
|
||||||
<p className="text-sm text-red-300 text-center py-4">
|
|
||||||
Failed to load playtime.
|
|
||||||
</p>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
No playtime recorded yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ol className="space-y-1">
|
|
||||||
{visible.map((p, i) => (
|
|
||||||
<li
|
|
||||||
key={p.uuid}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 text-center shrink-0">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<PlayerAvatar name={p.name} size={24} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium truncate">{p.name}</p>
|
|
||||||
<p
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
title={new Date(p.lastPlayedMs).toLocaleString()}
|
|
||||||
>
|
|
||||||
last seen {timeAgo(p.lastPlayedMs)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs px-1.5 py-0 tabular-nums shrink-0"
|
|
||||||
>
|
|
||||||
{formatHours(p.playtimeHours)}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
{data.length > TOP_N && (
|
|
||||||
<div className="text-center mt-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowAll((v) => !v)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{showAll ? "Show top 10" : `Show all ${data.length}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
200
lib/player-stats.ts
Normal file
200
lib/player-stats.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const STATS_DIR = "/home/minecraft/server/world/stats";
|
||||||
|
const ADVANCEMENTS_DIR = "/home/minecraft/server/world/advancements";
|
||||||
|
const USERCACHE = "/home/minecraft/server/usercache.json";
|
||||||
|
|
||||||
|
const TICKS_PER_HOUR = 20 * 60 * 60;
|
||||||
|
const TICKS_PER_SECOND = 20;
|
||||||
|
|
||||||
|
export type PlayerStats = {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
lastPlayedMs: number;
|
||||||
|
|
||||||
|
// Time
|
||||||
|
playtimeHours: number;
|
||||||
|
longestLifeHours: number;
|
||||||
|
|
||||||
|
// Combat
|
||||||
|
mobKills: number;
|
||||||
|
playerKills: number;
|
||||||
|
deaths: number;
|
||||||
|
kdr: number;
|
||||||
|
damageDealt: number; // HP (scaled /10 from raw)
|
||||||
|
damageTaken: number;
|
||||||
|
|
||||||
|
// World interaction
|
||||||
|
blocksMined: number;
|
||||||
|
itemsUsed: number; // approximates blocks placed + items consumed
|
||||||
|
itemsCrafted: number;
|
||||||
|
itemsPickedUp: number;
|
||||||
|
uniqueItemsPickedUp: number;
|
||||||
|
|
||||||
|
// Exploration
|
||||||
|
distanceKm: number; // all *_one_cm divided by 100_000
|
||||||
|
chestOpens: number;
|
||||||
|
netherPortalEnters: number;
|
||||||
|
endPortalEnters: number;
|
||||||
|
|
||||||
|
// Social / economy
|
||||||
|
villagerTrades: number;
|
||||||
|
fishCaught: number;
|
||||||
|
animalsBred: number;
|
||||||
|
|
||||||
|
// Progression
|
||||||
|
advancements: number; // excluding recipe advancements
|
||||||
|
recipesUnlocked: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserCacheEntry = { name: string; uuid: string };
|
||||||
|
|
||||||
|
type StatsFile = {
|
||||||
|
stats?: {
|
||||||
|
"minecraft:custom"?: Record<string, number>;
|
||||||
|
"minecraft:mined"?: Record<string, number>;
|
||||||
|
"minecraft:used"?: Record<string, number>;
|
||||||
|
"minecraft:crafted"?: Record<string, number>;
|
||||||
|
"minecraft:picked_up"?: Record<string, number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdvancementFile = Record<string, { done?: boolean } | number>;
|
||||||
|
|
||||||
|
function loadNameMap(): Map<string, string> {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(readFileSync(USERCACHE, "utf8")) as UserCacheEntry[];
|
||||||
|
return new Map(arr.map((e) => [e.uuid, e.name]));
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumValues(obj: Record<string, number> | undefined): number {
|
||||||
|
if (!obj) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const k in obj) total += Number(obj[k]) || 0;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countKeys(obj: Record<string, number> | undefined): number {
|
||||||
|
if (!obj) return 0;
|
||||||
|
return Object.keys(obj).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(custom: Record<string, number> | undefined, key: string): number {
|
||||||
|
return Number(custom?.[key] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdvancements(uuid: string): { advancements: number; recipes: number } {
|
||||||
|
const path = join(ADVANCEMENTS_DIR, `${uuid}.json`);
|
||||||
|
if (!existsSync(path)) return { advancements: 0, recipes: 0 };
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(path, "utf8")) as AdvancementFile;
|
||||||
|
let advancements = 0;
|
||||||
|
let recipes = 0;
|
||||||
|
for (const id in data) {
|
||||||
|
const entry = data[id];
|
||||||
|
if (typeof entry !== "object" || entry === null) continue;
|
||||||
|
if (!entry.done) continue;
|
||||||
|
if (id.startsWith("minecraft:recipes/")) recipes++;
|
||||||
|
else advancements++;
|
||||||
|
}
|
||||||
|
return { advancements, recipes };
|
||||||
|
} catch {
|
||||||
|
return { advancements: 0, recipes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePlayerStats(): PlayerStats[] {
|
||||||
|
const names = loadNameMap();
|
||||||
|
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(STATS_DIR).filter((f) => f.endsWith(".json"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: PlayerStats[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const uuid = file.replace(/\.json$/, "");
|
||||||
|
const path = join(STATS_DIR, file);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(path, "utf8")) as StatsFile;
|
||||||
|
const custom = data.stats?.["minecraft:custom"];
|
||||||
|
const mtime = statSync(path).mtimeMs;
|
||||||
|
|
||||||
|
const playtimeTicks = get(custom, "minecraft:play_time");
|
||||||
|
const longestLifeTicks = get(custom, "minecraft:time_since_death");
|
||||||
|
|
||||||
|
const mobKills = get(custom, "minecraft:mob_kills");
|
||||||
|
const playerKills = get(custom, "minecraft:player_kills");
|
||||||
|
const deaths = get(custom, "minecraft:deaths");
|
||||||
|
const kdr = deaths === 0 ? mobKills : mobKills / deaths;
|
||||||
|
|
||||||
|
const distanceCm =
|
||||||
|
get(custom, "minecraft:walk_one_cm") +
|
||||||
|
get(custom, "minecraft:sprint_one_cm") +
|
||||||
|
get(custom, "minecraft:crouch_one_cm") +
|
||||||
|
get(custom, "minecraft:swim_one_cm") +
|
||||||
|
get(custom, "minecraft:fly_one_cm") +
|
||||||
|
get(custom, "minecraft:boat_one_cm") +
|
||||||
|
get(custom, "minecraft:horse_one_cm") +
|
||||||
|
get(custom, "minecraft:minecart_one_cm") +
|
||||||
|
get(custom, "minecraft:pig_one_cm") +
|
||||||
|
get(custom, "minecraft:strider_one_cm") +
|
||||||
|
get(custom, "minecraft:climb_one_cm") +
|
||||||
|
get(custom, "minecraft:fall_one_cm") +
|
||||||
|
get(custom, "minecraft:aviate_one_cm");
|
||||||
|
|
||||||
|
const adv = readAdvancements(uuid);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
uuid,
|
||||||
|
name: names.get(uuid) || uuid.slice(0, 8),
|
||||||
|
lastPlayedMs: mtime,
|
||||||
|
|
||||||
|
playtimeHours: round2(playtimeTicks / TICKS_PER_HOUR),
|
||||||
|
longestLifeHours: round2(longestLifeTicks / TICKS_PER_HOUR),
|
||||||
|
|
||||||
|
mobKills,
|
||||||
|
playerKills,
|
||||||
|
deaths,
|
||||||
|
kdr: round2(kdr),
|
||||||
|
damageDealt: Math.round(get(custom, "minecraft:damage_dealt") / 10),
|
||||||
|
damageTaken: Math.round(get(custom, "minecraft:damage_taken") / 10),
|
||||||
|
|
||||||
|
blocksMined: sumValues(data.stats?.["minecraft:mined"]),
|
||||||
|
itemsUsed: sumValues(data.stats?.["minecraft:used"]),
|
||||||
|
itemsCrafted: sumValues(data.stats?.["minecraft:crafted"]),
|
||||||
|
itemsPickedUp: sumValues(data.stats?.["minecraft:picked_up"]),
|
||||||
|
uniqueItemsPickedUp: countKeys(data.stats?.["minecraft:picked_up"]),
|
||||||
|
|
||||||
|
distanceKm: round2(distanceCm / 100_000),
|
||||||
|
chestOpens: get(custom, "minecraft:open_chest"),
|
||||||
|
netherPortalEnters: get(custom, "minecraft:enter_nether_portal"),
|
||||||
|
endPortalEnters: get(custom, "minecraft:enter_end_portal"),
|
||||||
|
|
||||||
|
villagerTrades: get(custom, "minecraft:traded_with_villager"),
|
||||||
|
fishCaught: get(custom, "minecraft:fish_caught"),
|
||||||
|
animalsBred: get(custom, "minecraft:animals_bred"),
|
||||||
|
|
||||||
|
advancements: adv.advancements,
|
||||||
|
recipesUnlocked: adv.recipes,
|
||||||
|
});
|
||||||
|
|
||||||
|
void TICKS_PER_SECOND;
|
||||||
|
} catch {
|
||||||
|
// skip corrupt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => b.playtimeHours - a.playtimeHours);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue