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