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 { readdirSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
|
|
@ -73,9 +13,18 @@ export async function GET() {
|
|||
}
|
||||
|
||||
try {
|
||||
const data = await memoAsync("players:playtime", 60_000, async () =>
|
||||
computePlaytime()
|
||||
const full = await memoAsync("players:stats", 60_000, async () =>
|
||||
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, {
|
||||
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 { onAppEvent } from "@/lib/events";
|
||||
import { formatHours, timeAgo } from "@/lib/time";
|
||||
import type { PlaytimeEntry } from "@/components/PlaytimeLeaderboard";
|
||||
import type { PlayerStats } from "@/lib/player-stats";
|
||||
|
||||
type PlayerData = {
|
||||
ops: { name: string; uuid: string; level: number }[];
|
||||
|
|
@ -47,16 +47,14 @@ export function PlayerDrawer() {
|
|||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const playtime = useQuery<PlaytimeEntry[]>({
|
||||
queryKey: ["players-playtime"],
|
||||
queryFn: () => fetch("/api/players/playtime").then((r) => r.json()),
|
||||
const stats = useQuery<PlayerStats[]>({
|
||||
queryKey: ["players-stats"],
|
||||
queryFn: () => fetch("/api/players/stats").then((r) => r.json()),
|
||||
enabled: authed && !!name,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const myPlaytime = name
|
||||
? playtime.data?.find((p) => p.name === name)
|
||||
: undefined;
|
||||
const mine = name ? stats.data?.find((p) => p.name === name) : undefined;
|
||||
|
||||
// Use existing analytics cache (last entry) to determine online now
|
||||
const analytics = queryClient.getQueryData<MetricEntry[]>(["analytics", 6]);
|
||||
|
|
@ -157,22 +155,30 @@ export function PlayerDrawer() {
|
|||
</p>
|
||||
) : (
|
||||
<>
|
||||
{myPlaytime && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-xs text-muted-foreground">Playtime</p>
|
||||
<p className="text-lg font-bold tabular-nums">
|
||||
{formatHours(myPlaytime.playtimeHours)}
|
||||
</p>
|
||||
{mine && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Tile label="Playtime" value={formatHours(mine.playtimeHours)} />
|
||||
<Tile
|
||||
label="Last seen"
|
||||
value={timeAgo(mine.lastPlayedMs)}
|
||||
title={new Date(mine.lastPlayedMs).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<p className="text-xs text-muted-foreground">Last seen</p>
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
title={new Date(myPlaytime.lastPlayedMs).toLocaleString()}
|
||||
>
|
||||
{timeAgo(myPlaytime.lastPlayedMs)}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Tile small label="Kills" value={mine.mobKills.toLocaleString()} />
|
||||
<Tile small label="Deaths" value={mine.deaths.toLocaleString()} />
|
||||
<Tile small label="K/D" value={mine.kdr.toFixed(2)} />
|
||||
<Tile small label="Advs" value={mine.advancements.toLocaleString()} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -259,7 +265,7 @@ export function PlayerDrawer() {
|
|||
{players.data?.ops.find((p) => p.name === name)?.uuid ||
|
||||
players.data?.whitelist.find((p) => p.name === name)?.uuid ||
|
||||
players.data?.banned.find((p) => p.name === name)?.uuid ||
|
||||
myPlaytime?.uuid ||
|
||||
mine?.uuid ||
|
||||
"—"}
|
||||
</span>
|
||||
</p>
|
||||
|
|
@ -275,3 +281,32 @@ export function PlayerDrawer() {
|
|||
</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,
|
||||
} from "@/components/ui/card";
|
||||
import { PlayerAvatar } from "@/components/PlayerAvatar";
|
||||
import { PlaytimeLeaderboard } from "@/components/PlaytimeLeaderboard";
|
||||
import { Leaderboard } from "@/components/Leaderboard";
|
||||
|
||||
type PlayerData = {
|
||||
ops: { name: string; uuid: string; level: number }[];
|
||||
|
|
@ -129,7 +129,7 @@ export function PlayerManager() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<PlaytimeLeaderboard />
|
||||
<Leaderboard />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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