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 ( +
  1. + + {i + 1} + + +
    +

    {p.name}

    +

    + last seen {timeAgo(p.lastPlayedMs)} +

    +
    + + {display} + +
  2. + ); + })} +
+ {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) => ( -
  1. - - {i + 1} - - -
    -

    {p.name}

    -

    - last seen {timeAgo(p.lastPlayedMs)} -

    -
    - - {formatHours(p.playtimeHours)} - -
  2. - ))} -
- {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; +}