- 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>
221 lines
7.7 KiB
TypeScript
221 lines
7.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|