diff --git a/app/api/players/playtime/route.ts b/app/api/players/playtime/route.ts
index 8462fcd..ea7199a 100644
--- a/app/api/players/playtime/route.ts
+++ b/app/api/players/playtime/route.ts
@@ -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 {
- 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 };
- };
- 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" },
});
diff --git a/app/api/players/stats/route.ts b/app/api/players/stats/route.ts
new file mode 100644
index 0000000..d349b0d
--- /dev/null
+++ b/app/api/players/stats/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/components/Leaderboard.tsx b/components/Leaderboard.tsx
new file mode 100644
index 0000000..ff077f6
--- /dev/null
+++ b/components/Leaderboard.tsx
@@ -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("playtimeHours");
+ const [showAll, setShowAll] = useState(false);
+
+ const { data = [], isLoading, isError } = useQuery({
+ 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();
+ for (const m of METRICS) {
+ if (!g.has(m.group)) g.set(m.group, []);
+ g.get(m.group)!.push(m);
+ }
+ return g;
+ }, []);
+
+ return (
+
+
+
+
+ Leaderboard
+
+ Ranked by{" "}
+ {metric.label}
+
+
+
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+ -
+
+
+
+
+ ))}
+
+ ) : isError ? (
+
+ Failed to load stats.
+
+ ) : sorted.length === 0 ? (
+
+ No player stats yet.
+
+ ) : (
+ <>
+
+ {visible.map((p, i) => {
+ const raw = p[metricKey] as number;
+ const display = `${metric.format(raw)}${metric.suffix || ""}`;
+ return (
+ -
+
+ {i + 1}
+
+
+
+
{p.name}
+
+ last seen {timeAgo(p.lastPlayedMs)}
+
+
+
+ {display}
+
+
+ );
+ })}
+
+ {sorted.length > TOP_N && (
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/components/PlayerDrawer.tsx b/components/PlayerDrawer.tsx
index 18055fa..9555768 100644
--- a/components/PlayerDrawer.tsx
+++ b/components/PlayerDrawer.tsx
@@ -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({
- queryKey: ["players-playtime"],
- queryFn: () => fetch("/api/players/playtime").then((r) => r.json()),
+ const stats = useQuery({
+ 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(["analytics", 6]);
@@ -157,22 +155,30 @@ export function PlayerDrawer() {
) : (
<>
- {myPlaytime && (
-
-
-
Playtime
-
- {formatHours(myPlaytime.playtimeHours)}
-
+ {mine && (
+
+
+
+
-
-
Last seen
-
- {timeAgo(myPlaytime.lastPlayedMs)}
-
+
+
+
+
+
+
+
+
+
+
)}
@@ -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 ||
"—"}
@@ -275,3 +281,32 @@ export function PlayerDrawer() {
);
}
+
+function Tile({
+ label,
+ value,
+ title,
+ small = false,
+}: {
+ label: string;
+ value: string;
+ title?: string;
+ small?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+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();
+}
diff --git a/components/PlayerManager.tsx b/components/PlayerManager.tsx
index 3298758..e91eb87 100644
--- a/components/PlayerManager.tsx
+++ b/components/PlayerManager.tsx
@@ -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 (
-
+
Player Management
diff --git a/components/PlaytimeLeaderboard.tsx b/components/PlaytimeLeaderboard.tsx
deleted file mode 100644
index 1590df9..0000000
--- a/components/PlaytimeLeaderboard.tsx
+++ /dev/null
@@ -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({
- 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 (
-
-
-
-
- Playtime
-
- Total hours played by each whitelisted player
-
-
- {data.length > 0 && (
-
-
Combined
-
- {formatHours(totalHours)}
-
-
- )}
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
- -
-
-
-
-
- ))}
-
- ) : isError ? (
-
- Failed to load playtime.
-
- ) : data.length === 0 ? (
-
- No playtime recorded yet.
-
- ) : (
- <>
-
- {visible.map((p, i) => (
- -
-
- {i + 1}
-
-
-
-
{p.name}
-
- last seen {timeAgo(p.lastPlayedMs)}
-
-
-
- {formatHours(p.playtimeHours)}
-
-
- ))}
-
- {data.length > TOP_N && (
-
-
-
- )}
- >
- )}
-
-
- );
-}
diff --git a/lib/player-stats.ts b/lib/player-stats.ts
new file mode 100644
index 0000000..5b7f243
--- /dev/null
+++ b/lib/player-stats.ts
@@ -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;
+ "minecraft:mined"?: Record;
+ "minecraft:used"?: Record;
+ "minecraft:crafted"?: Record;
+ "minecraft:picked_up"?: Record;
+ };
+};
+
+type AdvancementFile = Record;
+
+function loadNameMap(): Map {
+ 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 | 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 | undefined): number {
+ if (!obj) return 0;
+ return Object.keys(obj).length;
+}
+
+function get(custom: Record | 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;
+}