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:
hurkicorgi 2026-04-13 06:37:50 -06:00
parent 9ccd4ca772
commit 3a69dc9243
7 changed files with 521 additions and 214 deletions

View file

@ -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" },
});

View 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
View 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>
);
}

View file

@ -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();
}

View file

@ -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>

View file

@ -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
View 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;
}