From 416b24b7c499066d064d5ac61908fca8450c5b51 Mon Sep 17 00:00:00 2001 From: Markle Date: Sat, 7 Nov 2026 21:57:45 +0100 Subject: [PATCH 1/2] old world --- README.md | 47 ++ gradle.properties | 2 +- src/java/hlvm/stats/OldWorldStats.java | 302 ++++++++++ src/java/hlvm/stats/StatsCommand.java | 790 ------------------------- src/java/hlvm/stats/StatsMode.java | 70 +++ src/java/hlvm/stats/StatsRows.java | 86 +++ 6 files changed, 506 insertions(+), 791 deletions(-) create mode 100644 src/java/hlvm/stats/OldWorldStats.java delete mode 100644 src/java/hlvm/stats/StatsCommand.java create mode 100644 src/java/hlvm/stats/StatsMode.java create mode 100644 src/java/hlvm/stats/StatsRows.java diff --git a/README.md b/README.md index 058fb07..b2259b5 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,53 @@ Item and block stats are tracked per item or block. Use the same category with a Minecraft's built-in stat keys are documented on the [Minecraft Wiki statistics page](https://minecraft.wiki/w/Statistics). +## Old world stats + +The mod can show the stats of a previous world next to the current ones. + +**Setup** + +1. Copy the old world's `players/stats/*.json` files into `config/stats-command/old-world/stats/` on the server (the folder is created on first start). +2. Restart the server. The log prints `Old world stats loaded: N players`. + +Nothing is written to the old world data and the regular index is not touched; old stats are read into memory at start. + +**Players** + +- Java players keep the same UUID in both worlds (offline mode), so nothing else is needed. +- Bedrock (Floodgate) players are matched by name: their old Floodgate UUID is translated to the offline UUID of their name (for example `.Steve`), which is what EasyAuth's `forced-offline-uuid` produces. Names are read from the server's `usercache.json`; you can also drop the old world's `usercache.json` next to the stats folder (`config/stats-command/old-world/usercache.json`). +- Anything that cannot be matched can be set by hand in `config/stats-command/old-world/uuid-map.json`, mapping an old UUID to a new UUID or to a player name: + ```json + { + "00000000-0000-0000-0009-0123456789ab": ".SomeBedrockPlayer" + } + ``` + Players whose name cannot be found are listed in the server log and ignored. + +**Modes** + +Add one of these words at the end of any `/stats` command (after the page and the optional `true`, or in place of `true`): + +| Mode | Shows | +|------|-------| +| `both` (default) | the current world, with the old world's value in parentheses | +| `current` | the current world only | +| `old` | the old world only | +| `total` | both worlds added together | + +``` +/stats top crafted minecraft:chest +--- Most Chest Crafted (Old world) (Page 1) --- +#1 marcel_mrm: 1,824 (3,243) +#2 Eddd64: 631 (0) + +/stats top crafted minecraft:chest 2 total +/stats top custom minecraft:play_time old +/stats Steve mined minecraft:stone total +``` + +`both` ranks by the current world. `old` and `total` also include players that only played in the old world. Without old world data, `both` behaves exactly like `current`. + ## Installation Requires Fabric Loader and Fabric API. Place the mod `.jar` file in your server's `mods` folder. diff --git a/gradle.properties b/gradle.properties index 8b223d5..2985d81 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loader_version=0.19.5 loom_version=1.17.21 # Mod Properties -mod_version=1.2.0 +mod_version=1.3.0 maven_group=hlvm.stats archives_base_name=stats-command diff --git a/src/java/hlvm/stats/OldWorldStats.java b/src/java/hlvm/stats/OldWorldStats.java new file mode 100644 index 0000000..2356774 --- /dev/null +++ b/src/java/hlvm/stats/OldWorldStats.java @@ -0,0 +1,302 @@ +package hlvm.stats; + +import com.google.gson.stream.JsonReader; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * Read-only, in-memory stats of a previous world. + * + * The stats are read from the vanilla {@code stats/.json} files of the old world. + * Nothing is written to disk and the regular {@link StatsIndex} is never touched. + * + * Old UUIDs are translated to the UUIDs the players have now (what EasyAuth's + * {@code forced-offline-uuid} produces: the offline UUID of the player's name): + * + */ +public class OldWorldStats { + private static final String FLOODGATE_PREFIX = "00000000-0000-0000-0009-"; + + // Stats that were renamed by Mojang between versions: old key -> current key + private static final Map STAT_ALIASES = Map.of( + "minecraft:custom/minecraft:play_one_minute", "minecraft:custom/minecraft:play_time"); + + private static final OldWorldStats EMPTY = new OldWorldStats(); + + private final Map> byStat = new HashMap<>(); + private final Map aliases = new HashMap<>(); // old uuid -> current uuid (only if they differ) + private final Map names = new HashMap<>(); // current uuid -> name + private int players; + + private OldWorldStats() { + } + + public static OldWorldStats empty() { + return EMPTY; + } + + /** True if at least one old player file was loaded. */ + public boolean hasData() { + return players > 0; + } + + public int playerCount() { + return players; + } + + /** Old value of a stat for a player (0 if none). Accepts current UUIDs and old Floodgate UUIDs. */ + public int get(UUID uuid, String statKey) { + Map byPlayer = byStat.get(statKey); + if (byPlayer == null) { + return 0; + } + UUID target = aliases.getOrDefault(uuid, uuid); + return byPlayer.getOrDefault(target, 0); + } + + /** All non-zero old values of a stat, keyed by current UUID. */ + public Map all(String statKey) { + Map byPlayer = byStat.get(statKey); + return byPlayer == null ? Collections.emptyMap() : Collections.unmodifiableMap(byPlayer); + } + + /** Name known for a player of the old world, or null. */ + public String nameOf(UUID uuid) { + return names.get(aliases.getOrDefault(uuid, uuid)); + } + + /** UUID a player has in the current world: the same one for Java, the offline one for Bedrock. */ + public static UUID offlineUuid(String name) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)); + } + + public static boolean isFloodgateUuid(UUID uuid) { + return uuid.toString().startsWith(FLOODGATE_PREFIX); + } + + /** + * The UUID a player has in the current world. Floodgate and premium (version 4) UUIDs become the offline + * UUID of {@code name}; offline UUIDs, and anything whose name is unknown, are returned unchanged. + */ + public static UUID canonicalFor(UUID uuid, String name) { + if (name == null || name.isEmpty()) { + return uuid; + } + if (isFloodgateUuid(uuid) || uuid.version() == 4) { + return offlineUuid(name); + } + return uuid; + } + + /** + * Loads the old world stats. + * + * @param oldWorldDir folder holding the old {@code stats/*.json} (directly or inside a {@code stats} + * subfolder), plus the optional {@code usercache.json} and {@code uuid-map.json} + * @param usercaches extra usercache.json files used to find the names of Bedrock players + * (normally the current server's one) + * @param fallbackNames last resort name lookup (UUID -> name), may return null + * @param log receives human readable messages (warnings, summary) + */ + public static OldWorldStats load(Path oldWorldDir, List usercaches, + Function fallbackNames, Consumer log) { + OldWorldStats result = new OldWorldStats(); + + Path statsDir = Files.isDirectory(oldWorldDir.resolve("stats")) ? oldWorldDir.resolve("stats") : oldWorldDir; + List files = new ArrayList<>(); + try (Stream stream = Files.list(statsDir)) { + stream.filter(p -> p.getFileName().toString().endsWith(".json")).forEach(files::add); + } catch (IOException e) { + log.accept("Could not read old world stats folder " + statsDir + ": " + e.getMessage()); + return result; + } + Collections.sort(files); + + // Names known for UUIDs: server usercache first, the old world's usercache overrides it + Map known = new HashMap<>(); + for (Path usercache : usercaches) { + readUsercache(usercache, known, log); + } + readUsercache(oldWorldDir.resolve("usercache.json"), known, log); + + Map overrides = readUuidMap(oldWorldDir.resolve("uuid-map.json"), log); + + int skipped = 0; + List unresolved = new ArrayList<>(); + // Offline-named files are merged first. Files from Floodgate UUIDs come last and only raise values + // (never add up): the migration script leaves a copy under the offline UUID, and the original and the + // copy hold the same data. + List pending = new ArrayList<>(); // {Path file, UUID target, Boolean fromFloodgate} + for (Path file : files) { + String fileName = file.getFileName().toString(); + UUID oldUuid; + try { + oldUuid = UUID.fromString(fileName.substring(0, fileName.length() - ".json".length())); + } catch (IllegalArgumentException e) { + continue; // not a player file (usercache.json, uuid-map.json, ...) + } + + UUID target = oldUuid; + String name = known.get(oldUuid); + String override = overrides.get(oldUuid.toString()); + if (override != null) { + try { + target = UUID.fromString(override); + } catch (IllegalArgumentException notAUuid) { + name = override; + target = offlineUuid(override); + } + } else { + if (name == null && fallbackNames != null) { + name = fallbackNames.apply(oldUuid); + } + if (isFloodgateUuid(oldUuid) && (name == null || name.isEmpty())) { + unresolved.add(oldUuid.toString()); + skipped++; + continue; + } + target = canonicalFor(oldUuid, name); + } + + if (!target.equals(oldUuid)) { + result.aliases.put(oldUuid, target); + } + if (name != null && !name.isEmpty()) { + result.names.putIfAbsent(target, name); + } + pending.add(new Object[] { file, target, isFloodgateUuid(oldUuid) }); + } + pending.sort(Comparator.comparing(entry -> (Boolean) entry[2])); // stable: false before true + + for (Object[] entry : pending) { + Path file = (Path) entry[0]; + UUID target = (UUID) entry[1]; + boolean fromFloodgate = (Boolean) entry[2]; + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8); + JsonReader json = new JsonReader(reader)) { + result.parse(json, target, fromFloodgate); + result.players++; + } catch (Exception e) { + log.accept("Skipping old world stats file " + file.getFileName() + ": " + e.getMessage()); + skipped++; + } + } + + if (!unresolved.isEmpty()) { + log.accept("Could not find a name for these Bedrock (Floodgate) UUIDs, their old stats are ignored. " + + "Add them to config/stats-command/old-world/uuid-map.json as \"uuid\": \".Name\": " + unresolved); + } + log.accept("Old world stats loaded: " + result.players + " players, " + skipped + " skipped."); + return result; + } + + private void parse(JsonReader reader, UUID target, boolean raiseOnly) throws IOException { + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (!name.equals("stats")) { + reader.skipValue(); + continue; + } + reader.beginObject(); + while (reader.hasNext()) { + String statType = reader.nextName(); + reader.beginObject(); + while (reader.hasNext()) { + String statKey = reader.nextName(); + long value = reader.nextLong(); + if (value <= 0) { + continue; + } + String fullKey = statType + "/" + statKey; + fullKey = STAT_ALIASES.getOrDefault(fullKey, fullKey); + byStat.computeIfAbsent(fullKey, k -> new HashMap<>()) + .merge(target, (int) Math.min(value, Integer.MAX_VALUE), + raiseOnly ? Math::max : OldWorldStats::addClamped); + } + reader.endObject(); + } + reader.endObject(); + } + reader.endObject(); + } + + static int addClamped(int a, int b) { + return (int) Math.min((long) a + (long) b, Integer.MAX_VALUE); + } + + /** Reads a vanilla usercache.json: [{"name": "...", "uuid": "...", "expiresOn": "..."}, ...] */ + private static void readUsercache(Path file, Map into, Consumer log) { + if (!Files.isRegularFile(file)) { + return; + } + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8); + JsonReader json = new JsonReader(reader)) { + json.beginArray(); + while (json.hasNext()) { + String name = null; + String uuid = null; + json.beginObject(); + while (json.hasNext()) { + String key = json.nextName(); + if (key.equals("name")) { + name = json.nextString(); + } else if (key.equals("uuid")) { + uuid = json.nextString(); + } else { + json.skipValue(); + } + } + json.endObject(); + if (name != null && !name.isEmpty() && uuid != null) { + try { + into.put(UUID.fromString(uuid), name); + } catch (IllegalArgumentException ignored) { + } + } + } + json.endArray(); + } catch (Exception e) { + log.accept("Could not read " + file + ": " + e.getMessage()); + } + } + + /** Reads uuid-map.json: {"old uuid": "new uuid or player name"} (keys are normalized UUID strings). */ + private static Map readUuidMap(Path file, Consumer log) { + Map map = new HashMap<>(); + if (!Files.isRegularFile(file)) { + return map; + } + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8); + JsonReader json = new JsonReader(reader)) { + json.beginObject(); + while (json.hasNext()) { + String key = json.nextName(); + String value = json.nextString(); + try { + map.put(UUID.fromString(key).toString(), value); + } catch (IllegalArgumentException e) { + log.accept("uuid-map.json: ignoring invalid UUID key " + key); + } + } + json.endObject(); + } catch (Exception e) { + log.accept("Could not read " + file + ": " + e.getMessage()); + } + return map; + } +} diff --git a/src/java/hlvm/stats/StatsCommand.java b/src/java/hlvm/stats/StatsCommand.java deleted file mode 100644 index 5d1a7dc..0000000 --- a/src/java/hlvm/stats/StatsCommand.java +++ /dev/null @@ -1,790 +0,0 @@ -package hlvm.stats; - -import com.mojang.brigadier.arguments.IntegerArgumentType; -import com.mojang.brigadier.arguments.BoolArgumentType; -import com.mojang.brigadier.builder.LiteralArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.suggestion.SuggestionProvider; -import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; -import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; -import net.minecraft.world.level.block.Block; -import net.minecraft.commands.arguments.GameProfileArgument; -import net.minecraft.commands.arguments.IdentifierArgument; -import net.minecraft.commands.arguments.ResourceArgument; -import net.minecraft.world.entity.EntityType; -import net.minecraft.world.item.Item; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.core.registries.Registries; -import net.minecraft.server.MinecraftServer; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.stats.Stat; -import net.minecraft.stats.StatsCounter; -import net.minecraft.stats.StatType; -import net.minecraft.stats.Stats; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.ClickEvent; -import net.minecraft.network.chat.HoverEvent; -import net.minecraft.resources.Identifier; -import net.minecraft.ChatFormatting; - -import java.util.*; -import java.util.Set; - -import static net.minecraft.commands.Commands.*; - -/** - * Stats Command Mod - Provides player statistics lookup and leaderboards. - * - * Commands: - * /stats - Look up individual player stats - * /stats top [page] - View leaderboards - * - * Categories: mined, used, crafted, broken, picked_up, dropped, killed, - * killed_by, custom - */ -public class StatsCommand implements ModInitializer { - - private static final int PAGE_SIZE = 10; - private static StatsIndex statsIndex; - - // Stats that are measured in ticks (20 ticks = 1 second) - private static final Set TIME_STATS = Set.of( - "play_time", "time_since_death", "time_since_rest", "sneak_time", - "total_world_time"); - - // Stats that are measured in centimeters - private static final Set DISTANCE_STATS = Set.of( - "walk_one_cm", "sprint_one_cm", "swim_one_cm", "fall_one_cm", - "climb_one_cm", "fly_one_cm", "walk_under_water_one_cm", - "minecart_one_cm", "boat_one_cm", "pig_one_cm", "horse_one_cm", - "aviate_one_cm", "walk_on_water_one_cm", "strider_one_cm", - "crouch_one_cm"); - - // ==================== INITIALIZATION ==================== - - @Override - public void onInitialize() { - registerLifecycleEvents(); - registerCommands(); - } - - private void registerLifecycleEvents() { - // Initialize index on server start - ServerLifecycleEvents.SERVER_STARTED.register(server -> { - statsIndex = new StatsIndex(server); - statsIndex.syncAll(server); - }); - - // Clear index on server stop - ServerLifecycleEvents.SERVER_STOPPED.register(server -> { - if (statsIndex != null) { - statsIndex.close(); - statsIndex = null; - } - }); - - // Capture stats when player disconnects - ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { - if (statsIndex == null) - return; - - ServerPlayer player = handler.getPlayer(); - statsIndex.cachePlayerName(player.getUUID(), player.getName().getString()); - - int playerId = statsIndex.getOrCreatePlayerId(player.getUUID()); - StatsCounter statHandler = player.getStats(); - - for (StatType statType : BuiltInRegistries.STAT_TYPE) { - persistStats(statType, statHandler, playerId); - } - }); - } - - private void registerCommands() { - CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> { - LiteralArgumentBuilder statsCmd = literal("stats") - .executes(ctx -> showHelp(ctx)) - .then(literal("help").executes(ctx -> showHelp(ctx))); - - // Player stats lookup - var playerArg = argument("target", GameProfileArgument.gameProfile()); - - // Block stats - playerArg.then(literal("mined") - .then(argument("block", - ResourceArgument.resource(registryAccess, Registries.BLOCK)) - .executes(ctx -> executeStat(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeStat(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", BoolArgumentType.getBool(ctx, "share")))))); - - // Item stats - for (var entry : List.of( - Map.entry("used", Stats.ITEM_USED), - Map.entry("crafted", Stats.ITEM_CRAFTED), - Map.entry("broken", Stats.ITEM_BROKEN), - Map.entry("picked_up", Stats.ITEM_PICKED_UP), - Map.entry("dropped", Stats.ITEM_DROPPED))) { - playerArg.then(literal(entry.getKey()) - .then(argument("item", - ResourceArgument.resource(registryAccess, Registries.ITEM)) - .executes(ctx -> executeStat(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeStat(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), - BoolArgumentType.getBool(ctx, "share")))))); - } - - // Entity stats - playerArg.then(literal("killed") - .then(argument("entity", - ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)) - .executes(ctx -> executeStat(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeStat(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", BoolArgumentType.getBool(ctx, "share")))))); - - playerArg.then(literal("killed_by") - .then(argument("entity", - ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)) - .executes(ctx -> executeStat(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeStat(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", BoolArgumentType.getBool(ctx, "share")))))); - - // Custom stats - SuggestionProvider customStatSuggestions = (ctx, builder) -> { - for (Identifier id : Stats.CUSTOM.getRegistry()) { - builder.suggest(id.toString()); - } - return builder.buildFuture(); - }; - - playerArg.then(literal("custom") - .then(argument("stat", IdentifierArgument.id()) - .suggests(customStatSuggestions) - .executes( - ctx -> executeCustomStat(ctx, IdentifierArgument.getId(ctx, "stat"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeCustomStat(ctx, - IdentifierArgument.getId(ctx, "stat"), - BoolArgumentType.getBool(ctx, "share")))))); - - statsCmd.then(playerArg); - - // Leaderboards - var topCmd = literal("top"); - - // Top mined - topCmd.then(literal("mined") - .then(argument("block", - ResourceArgument.resource(registryAccess, Registries.BLOCK)) - .executes(ctx -> executeTop(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", 1, false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", 1, BoolArgumentType.getBool(ctx, "share")))) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(ctx -> executeTop(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", IntegerArgumentType.getInteger(ctx, "page"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.BLOCK_MINED, - ResourceArgument - .getResource(ctx, "block", Registries.BLOCK).value(), - "mined", IntegerArgumentType.getInteger(ctx, "page"), - BoolArgumentType.getBool(ctx, "share"))))))); - - // Top item stats - for (var entry : List.of( - Map.entry("used", Stats.ITEM_USED), - Map.entry("crafted", Stats.ITEM_CRAFTED), - Map.entry("broken", Stats.ITEM_BROKEN), - Map.entry("picked_up", Stats.ITEM_PICKED_UP), - Map.entry("dropped", Stats.ITEM_DROPPED))) { - topCmd.then(literal(entry.getKey()) - .then(argument("item", - ResourceArgument.resource(registryAccess, Registries.ITEM)) - .executes(ctx -> executeTop(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), 1, false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), 1, - BoolArgumentType.getBool(ctx, "share")))) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(ctx -> executeTop(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), - IntegerArgumentType.getInteger(ctx, "page"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, entry.getValue(), - ResourceArgument - .getResource(ctx, "item", Registries.ITEM).value(), - entry.getKey().replace("_", " "), - IntegerArgumentType.getInteger(ctx, "page"), - BoolArgumentType.getBool(ctx, "share"))))))); - } - - // Top entity stats - topCmd.then(literal("killed") - .then(argument("entity", - ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", 1, false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", 1, BoolArgumentType.getBool(ctx, "share")))) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", IntegerArgumentType.getInteger(ctx, "page"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed", IntegerArgumentType.getInteger(ctx, "page"), - BoolArgumentType.getBool(ctx, "share"))))))); - - topCmd.then(literal("killed_by") - .then(argument("entity", - ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", 1, false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", 1, BoolArgumentType.getBool(ctx, "share")))) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", IntegerArgumentType.getInteger(ctx, "page"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeTop(ctx, Stats.ENTITY_KILLED_BY, - ResourceArgument - .getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), - "killed by", IntegerArgumentType.getInteger(ctx, "page"), - BoolArgumentType.getBool(ctx, "share"))))))); - - // Top custom stats - topCmd.then(literal("custom") - .then(argument("stat", IdentifierArgument.id()) - .suggests(customStatSuggestions) - .executes( - ctx -> executeCustomTop(ctx, IdentifierArgument.getId(ctx, "stat"), 1, false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeCustomTop(ctx, - IdentifierArgument.getId(ctx, "stat"), - 1, - BoolArgumentType.getBool(ctx, "share")))) - .then(argument("page", IntegerArgumentType.integer(1)) - .executes(ctx -> executeCustomTop(ctx, - IdentifierArgument.getId(ctx, "stat"), - IntegerArgumentType.getInteger(ctx, "page"), false)) - .then(argument("share", BoolArgumentType.bool()) - .executes(ctx -> executeCustomTop(ctx, - IdentifierArgument.getId(ctx, "stat"), - IntegerArgumentType.getInteger(ctx, "page"), - BoolArgumentType.getBool(ctx, "share"))))))); - - statsCmd.then(topCmd); - dispatcher.register(statsCmd); - }); - } - - // ==================== COMMAND HANDLERS ==================== - - /** - * Displays a concise help message for the /stats command. - */ - private int showHelp(CommandContext ctx) { - ctx.getSource().sendSuccess(() -> Component.literal("§6--- Stats Command ---"), false); - ctx.getSource().sendSuccess( - () -> Component.literal("§e/stats §r - Look up a player's stat"), false); - ctx.getSource().sendSuccess(() -> Component.literal("§e/stats top [page]§r - View leaderboard"), - false); - ctx.getSource() - .sendSuccess(() -> Component.literal( - "§7Categories:§r mined, used, crafted, broken, picked_up, dropped, killed, killed_by, custom"), - false); - ctx.getSource().sendSuccess(() -> Component.literal("§7Examples:§r"), false); - ctx.getSource().sendSuccess(() -> Component.literal(" /stats Steve mined minecraft:diamond_ore"), false); - ctx.getSource().sendSuccess(() -> Component.literal(" /stats top custom minecraft:play_time"), false); - return 1; - } - - /** - * Executes a stat lookup for standard stat types (blocks, items, entities). - */ - private int executeStat(CommandContext ctx, StatType type, T key, String verb, - boolean share) { - try { - var profiles = GameProfileArgument.getGameProfiles(ctx, "target"); - if (profiles.isEmpty()) { - sendError(ctx, "Player not found."); - return 0; - } - var profile = profiles.iterator().next(); - - MinecraftServer server = ctx.getSource().getServer(); - ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); - - int value; - if (onlinePlayer != null) { - value = onlinePlayer.getStats().getValue(type.get(key)); - } else { - if (statsIndex == null) { - sendError(ctx, "Index not loaded yet."); - return 0; - } - String statKey = getStatTypeId(type) + "/" + getStatKeyString(key); - value = statsIndex.getPlayerStat(profile.id(), statKey); - } - - String message = formatStatMessage(profile.name(), verb, value, getReadableName(key), type); - if (share) { - broadcastAsPlayer(ctx, Component.literal(message)); - } else { - sendWithShareButton(ctx, message); - } - return 1; - } catch (Exception e) { - sendError(ctx, e.getMessage()); - return 0; - } - } - - /** - * Executes a stat lookup for custom stats. - * Custom stats require special handling to get the exact registered Identifier. - */ - private int executeCustomStat(CommandContext ctx, Identifier statId, boolean share) { - try { - Identifier registeredId = findRegisteredCustomStat(statId); - if (registeredId == null) { - sendError(ctx, "Unknown custom stat: " + statId); - return 0; - } - - var profiles = GameProfileArgument.getGameProfiles(ctx, "target"); - if (profiles.isEmpty()) { - sendError(ctx, "Player not found."); - return 0; - } - var profile = profiles.iterator().next(); - - MinecraftServer server = ctx.getSource().getServer(); - ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); - - int value; - if (onlinePlayer != null) { - value = onlinePlayer.getStats().getValue(Stats.CUSTOM.get(registeredId)); - } else { - if (statsIndex == null) { - sendError(ctx, "Index not loaded yet."); - return 0; - } - value = statsIndex.getPlayerStat(profile.id(), "minecraft:custom/" + registeredId); - } - - String readableName = getCustomStatReadableName(statId); - String formattedValue = formatCustomStatValue(statId.getPath(), value); - String message = String.format("%s: §a%s§r %s", profile.name(), formattedValue, readableName); - if (share) { - broadcastAsPlayer(ctx, Component.literal(message)); - } else { - sendWithShareButton(ctx, message); - } - return 1; - } catch (Exception e) { - sendError(ctx, e.getMessage()); - return 0; - } - } - - /** - * Executes a leaderboard lookup for standard stat types. - */ - private int executeTop(CommandContext ctx, StatType type, T key, String verb, - int page, boolean share) { - if (statsIndex == null) { - sendError(ctx, "Index not loaded yet."); - return 0; - } - - MinecraftServer server = ctx.getSource().getServer(); - String statKey = getStatTypeId(type) + "/" + getStatKeyString(key); - Stat stat = type.get(key); - - List results = buildLeaderboard(server, statKey, stat); - return displayLeaderboard(ctx, results, getReadableName(key), verb, type, page, share); - } - - /** - * Executes a leaderboard lookup for custom stats. - */ - private int executeCustomTop(CommandContext ctx, Identifier statId, int page, boolean share) { - if (statsIndex == null) { - sendError(ctx, "Index not loaded yet."); - return 0; - } - - Identifier registeredId = findRegisteredCustomStat(statId); - if (registeredId == null) { - sendError(ctx, "Unknown custom stat: " + statId); - return 0; - } - - MinecraftServer server = ctx.getSource().getServer(); - String statKey = "minecraft:custom/" + registeredId; - Stat stat = Stats.CUSTOM.get(registeredId); - - List results = buildLeaderboard(server, statKey, stat); - String readableName = getCustomStatReadableName(statId); - - return displayCustomLeaderboard(ctx, results, readableName, statId.getPath(), page, share); - } - - // ==================== HELPER METHODS ==================== - - /** - * Finds the exact Identifier object from the custom stat registry. - * This is required because StatType.get() uses object identity for - * registry lookups. - */ - private Identifier findRegisteredCustomStat(Identifier statId) { - for (Identifier id : Stats.CUSTOM.getRegistry()) { - if (id.equals(statId)) { - return id; - } - } - return null; - } - - /** - * Builds a merged leaderboard from indexed stats and live online player stats. - */ - private List buildLeaderboard(MinecraftServer server, String statKey, Stat stat) { - Map entryMap = new HashMap<>(); - - // Load indexed results - for (StatsIndex.Entry entry : statsIndex.getTop(statKey, 1, 10000)) { - entryMap.put(entry.uuid, entry); - } - - // Merge with live stats from online players - for (ServerPlayer player : server.getPlayerList().getPlayers()) { - UUID uuid = player.getUUID(); - int liveValue = player.getStats().getValue(stat); - - if (entryMap.containsKey(uuid)) { - entryMap.get(uuid).value = liveValue; - } else if (liveValue > 0) { - entryMap.put(uuid, new StatsIndex.Entry(uuid, liveValue)); - } - - statsIndex.cachePlayerName(uuid, player.getName().getString()); - } - - // Sort by value descending - List sorted = new ArrayList<>(entryMap.values()); - sorted.sort((a, b) -> Integer.compare(b.value, a.value)); - return sorted; - } - - /** - * Displays a paginated leaderboard. - */ - private int displayLeaderboard(CommandContext ctx, List results, - String itemName, String verb, StatType type, int page, boolean share) { - int start = (page - 1) * PAGE_SIZE; - if (start >= results.size() || results.isEmpty()) { - sendError(ctx, "No stats found (or page empty)."); - return 0; - } - - int end = Math.min(start + PAGE_SIZE, results.size()); - List pageResults = results.subList(start, end); - - // Send header - String header = formatTopHeader(verb, itemName, type, page); - if (share) { - broadcastAsPlayer(ctx, Component.literal(header)); - } else { - sendWithShareButton(ctx, header); - } - - // Send entries - MinecraftServer server = ctx.getSource().getServer(); - for (int i = 0; i < pageResults.size(); i++) { - StatsIndex.Entry entry = pageResults.get(i); - int rank = start + i + 1; - int value = entry.value; - String name = getPlayerName(server, entry.uuid); - - Component line = Component.literal(String.format("#%d %s: §a%,d", rank, name, value)); - if (share) { - ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); - } else { - ctx.getSource().sendSuccess(() -> line, false); - } - } - - return 1; - } - - /** - * Gets a player's display name (online name, cached name, or truncated UUID). - */ - private String getPlayerName(MinecraftServer server, UUID uuid) { - ServerPlayer player = server.getPlayerList().getPlayer(uuid); - if (player != null) { - return player.getName().getString(); - } - String cached = statsIndex.getCachedName(uuid); - return cached != null ? cached : uuid.toString().substring(0, 8) + "..."; - } - - /** - * Persists all stats for a player when they disconnect. - */ - private void persistStats(StatType statType, StatsCounter statHandler, int playerId) { - for (T key : statType.getRegistry()) { - Stat stat = statType.get(key); - int value = statHandler.getValue(stat); - if (value > 0) { - String fullKey = getStatKey(stat); - int statId = statsIndex.getOrCreateStatId(fullKey); - statsIndex.updateStat(playerId, statId, value); - } - } - } - - // ==================== FORMATTING ==================== - - /** - * Formats a custom stat value based on its type (time, distance, or plain - * number). - */ - private String formatCustomStatValue(String statPath, int value) { - if (TIME_STATS.contains(statPath)) { - return formatTime(value); - } else if (DISTANCE_STATS.contains(statPath)) { - return formatDistance(value); - } - return String.format("%,d", value); - } - - /** - * Formats ticks as human-readable time (e.g., "2h 30m" or "45 minutes"). - */ - private String formatTime(int ticks) { - int totalSeconds = ticks / 20; - int hours = totalSeconds / 3600; - int minutes = (totalSeconds % 3600) / 60; - - if (hours > 0) { - return String.format("%dh %dm", hours, minutes); - } else if (minutes > 0) { - return String.format("%d minutes", minutes); - } else { - return String.format("%d seconds", totalSeconds); - } - } - - /** - * Formats centimeters as human-readable distance (e.g., "1.5 km" or "200 m"). - */ - private String formatDistance(int cm) { - double meters = cm / 100.0; - if (meters >= 1000) { - return String.format("%.1f km", meters / 1000.0); - } else { - return String.format("%.0f m", meters); - } - } - - /** - * Gets a readable name for a custom stat, removing the "_one_cm" suffix for - * distance stats. - */ - private String getCustomStatReadableName(Identifier statId) { - String path = statId.getPath(); - // Clean up distance stat names (e.g., "walk_one_cm" -> "walked") - if (path.endsWith("_one_cm")) { - String base = path.substring(0, path.length() - 7); - return base.replace("_", " "); - } - return path.replace("_", " "); - } - - /** - * Displays a paginated leaderboard for custom stats with formatted values. - */ - private int displayCustomLeaderboard(CommandContext ctx, List results, - String itemName, String statPath, int page, boolean share) { - int start = (page - 1) * PAGE_SIZE; - if (start >= results.size() || results.isEmpty()) { - sendError(ctx, "No stats found (or page empty)."); - return 0; - } - - int end = Math.min(start + PAGE_SIZE, results.size()); - List pageResults = results.subList(start, end); - - // Send header - String header = String.format("--- Top %s (Page %d) ---", itemName, page); - if (share) { - broadcastAsPlayer(ctx, Component.literal(header)); - } else { - sendWithShareButton(ctx, header); - } - - // Send entries with formatted values - MinecraftServer server = ctx.getSource().getServer(); - for (int i = 0; i < pageResults.size(); i++) { - StatsIndex.Entry entry = pageResults.get(i); - int rank = start + i + 1; - String formattedValue = formatCustomStatValue(statPath, entry.value); - String name = getPlayerName(server, entry.uuid); - - Component line = Component.literal(String.format("#%d %s: §a%s", rank, name, formattedValue)); - if (share) { - ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); - } else { - ctx.getSource().sendSuccess(() -> line, false); - } - } - - return 1; - } - - private void sendWithShareButton(CommandContext ctx, String message) { - String command = ctx.getInput(); - if (!command.startsWith("/")) { - command = "/" + command; - } - - String shareCommand = command + " true"; - Component shareButton = Component.literal(" [Share]") - .withStyle(style -> style - .withColor(ChatFormatting.GREEN) - .withClickEvent(new ClickEvent.RunCommand(shareCommand)) - .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to share in chat")))); - - ctx.getSource().sendSuccess(() -> Component.literal(message).append(shareButton), false); - } - - private void broadcastAsPlayer(CommandContext ctx, Component message) { - String playerName = ctx.getSource().getTextName(); - Component fullMessage = Component.literal("<" + playerName + "> ").append(message); - ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(fullMessage, false); - } - - private void sendError(CommandContext ctx, String message) { - ctx.getSource().sendFailure(Component.literal("§c" + message)); - } - - private String formatStatMessage(String playerName, String verb, int value, String itemName, StatType type) { - if (type == Stats.CUSTOM) { - return String.format("%s: §a%,d§r %s", playerName, value, itemName); - } else if (type == Stats.ENTITY_KILLED_BY) { - return String.format("%s was killed by §a%,d§r %s", playerName, value, itemName); - } else if (type == Stats.ENTITY_KILLED) { - return String.format("%s has killed §a%,d§r %s", playerName, value, itemName); - } else { - return String.format("%s has %s §a%,d§r %s", playerName, verb, value, itemName); - } - } - - private String formatTopHeader(String verb, String itemName, StatType type, int page) { - if (type == Stats.CUSTOM) { - return String.format("--- Top %s (Page %d) ---", itemName, page); - } else if (type == Stats.ENTITY_KILLED_BY) { - return String.format("--- Most Killed By %s (Page %d) ---", itemName, page); - } else if (type == Stats.ENTITY_KILLED) { - return String.format("--- Most %s Killed (Page %d) ---", itemName, page); - } else { - String capitalizedVerb = verb.substring(0, 1).toUpperCase() + verb.substring(1); - return String.format("--- Most %s %s (Page %d) ---", itemName, capitalizedVerb, page); - } - } - - // ==================== STAT KEY UTILITIES ==================== - - private String getStatTypeId(StatType type) { - Identifier id = BuiltInRegistries.STAT_TYPE.getKey(type); - return id != null ? id.toString() : "minecraft:custom"; - } - - private String getStatKey(Stat stat) { - return getStatTypeId(stat.getType()) + "/" + getStatKeyString(stat.getValue()); - } - - private String getStatKeyString(T key) { - if (key instanceof Block block) { - return BuiltInRegistries.BLOCK.getKey(block).toString(); - } else if (key instanceof Item item) { - return BuiltInRegistries.ITEM.getKey(item).toString(); - } else if (key instanceof EntityType entityType) { - return BuiltInRegistries.ENTITY_TYPE.getKey(entityType).toString(); - } else if (key instanceof Identifier id) { - return id.toString(); - } - return key.toString(); - } - - private String getReadableName(T key) { - if (key instanceof Block block) { - return block.getName().getString(); - } else if (key instanceof Item item) { - return item.getName(item.getDefaultInstance()).getString(); - } else if (key instanceof EntityType entityType) { - return entityType.getDescription().getString(); - } else if (key instanceof Identifier id) { - return id.getPath().replace("_", " "); - } - return key.toString(); - } -} \ No newline at end of file diff --git a/src/java/hlvm/stats/StatsMode.java b/src/java/hlvm/stats/StatsMode.java new file mode 100644 index 0000000..9bb5437 --- /dev/null +++ b/src/java/hlvm/stats/StatsMode.java @@ -0,0 +1,70 @@ +package hlvm.stats; + +import java.util.Locale; + +/** + * Which world(s) a command should show. Written as an optional last word of the command: + * {@code /stats top crafted minecraft:chest old} + */ +public enum StatsMode { + /** Current world, with the old world's value in parentheses (default). */ + BOTH("both"), + /** Current world only (the behaviour of the original mod). */ + CURRENT("current"), + /** Old world only. */ + OLD("old"), + /** Current world + old world added together. */ + TOTAL("total"); + + public final String id; + + StatsMode(String id) { + this.id = id; + } + + /** Text placed between the leaderboard title and "(Page n)". */ + public String headerLabel() { + switch (this) { + case BOTH: + return " (Old world)"; + case OLD: + return " (Old world only)"; + case TOTAL: + return " (Total)"; + default: + return ""; + } + } + + public static StatsMode fromId(String id) { + for (StatsMode mode : values()) { + if (mode.id.equals(id.toLowerCase(Locale.ROOT))) { + return mode; + } + } + return BOTH; + } + + /** + * Turns the command a player typed into the command that shares the result in chat: + * appends " true", or switches an explicit "false" to "true". Works with the mode word before or after + * the share flag (".. 2 old" -> ".. 2 old true", ".. false old" -> ".. true old"). + */ + public static String shareCommand(String input) { + String command = input.trim(); + if (!command.startsWith("/")) { + command = "/" + command; + } + String[] tokens = command.split("\\s+"); + for (int i = tokens.length - 1; i >= Math.max(0, tokens.length - 2); i--) { + if (tokens[i].equals("true")) { + return String.join(" ", tokens); + } + if (tokens[i].equals("false")) { + tokens[i] = "true"; + return String.join(" ", tokens); + } + } + return command + " true"; + } +} diff --git a/src/java/hlvm/stats/StatsRows.java b/src/java/hlvm/stats/StatsRows.java new file mode 100644 index 0000000..c7181bc --- /dev/null +++ b/src/java/hlvm/stats/StatsRows.java @@ -0,0 +1,86 @@ +package hlvm.stats; + +import java.util.*; +import java.util.function.UnaryOperator; + +/** + * Builds the rows of a leaderboard for a {@link StatsMode}. Pure logic (no Minecraft classes) so it can be + * tested on its own. + * + * The same person can show up under several UUIDs (an old Floodgate UUID and the offline UUID it became, a + * premium UUID and the offline UUID of the same name). Every UUID is first reduced to the one the player has + * now, so one person is always one row. + */ +public final class StatsRows { + private StatsRows() { + } + + /** One line of a leaderboard: the player's value in the current world and in the old world. */ + public static final class Row { + public final UUID uuid; + public final int current; + public final int old; + + public Row(UUID uuid, int current, int old) { + this.uuid = uuid; + this.current = current; + this.old = old; + } + + public long sortValue(StatsMode mode) { + switch (mode) { + case OLD: + return old; + case TOTAL: + return (long) current + (long) old; + default: + return current; + } + } + } + + /** + * @param currentRaw current world values by the UUID they are stored under (may include several UUIDs + * of the same person, e.g. a Floodgate UUID and its migrated copy) + * @param canonical maps any UUID to the UUID the player has now + * @param old old world stats (already keyed by current UUIDs) + * @param statKey e.g. "minecraft:custom/minecraft:play_time" + */ + public static List build(Map currentRaw, UnaryOperator canonical, + OldWorldStats old, String statKey, StatsMode mode) { + // Several UUIDs of one person in the current world are copies of the same data (the migration copies + // the Floodgate stats to the offline UUID), so keep the highest value instead of adding them up. + Map currentValues = new HashMap<>(); + if (mode != StatsMode.OLD) { + for (Map.Entry entry : currentRaw.entrySet()) { + currentValues.merge(canonical.apply(entry.getKey()), entry.getValue(), Math::max); + } + } + + Set players = new HashSet<>(); + if (mode == StatsMode.BOTH || mode == StatsMode.CURRENT) { + players.addAll(currentValues.keySet()); + } else if (mode == StatsMode.OLD) { + players.addAll(old.all(statKey).keySet()); + } else { + players.addAll(currentValues.keySet()); + players.addAll(old.all(statKey).keySet()); + } + + List rows = new ArrayList<>(); + for (UUID uuid : players) { + Row row = new Row(uuid, currentValues.getOrDefault(uuid, 0), + mode == StatsMode.CURRENT ? 0 : old.get(uuid, statKey)); + if (row.sortValue(mode) > 0) { + rows.add(row); + } + } + + // Highest first; UUID as tie-breaker so pages stay stable between commands + rows.sort((a, b) -> { + int cmp = Long.compare(b.sortValue(mode), a.sortValue(mode)); + return cmp != 0 ? cmp : a.uuid.compareTo(b.uuid); + }); + return rows; + } +} From b14301cda12cfedba6de77bfc2cfca9d795ed8e3 Mon Sep 17 00:00:00 2001 From: Markle Date: Sat, 7 Nov 2026 22:41:42 +0100 Subject: [PATCH 2/2] old world --- src/java/hlvm/stats/StatsCommand.java | 925 ++++++++++++++++++++++++++ 1 file changed, 925 insertions(+) create mode 100644 src/java/hlvm/stats/StatsCommand.java diff --git a/src/java/hlvm/stats/StatsCommand.java b/src/java/hlvm/stats/StatsCommand.java new file mode 100644 index 0000000..3f7c9a0 --- /dev/null +++ b/src/java/hlvm/stats/StatsCommand.java @@ -0,0 +1,925 @@ +package hlvm.stats; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.BoolArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.world.level.block.Block; +import net.minecraft.commands.arguments.GameProfileArgument; +import net.minecraft.commands.arguments.IdentifierArgument; +import net.minecraft.commands.arguments.ResourceArgument; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.Item; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.stats.Stat; +import net.minecraft.stats.StatsCounter; +import net.minecraft.stats.StatType; +import net.minecraft.stats.Stats; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.resources.Identifier; +import net.minecraft.ChatFormatting; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.Set; +import java.util.function.IntFunction; + +import hlvm.stats.StatsRows.Row; + +import static net.minecraft.commands.Commands.*; + +/** + * Stats Command Mod - Provides player statistics lookup and leaderboards. + * + * Commands: + * /stats [share] [mode] - Look up individual player stats + * /stats top [page] [share] [mode] - View leaderboards + * + * mode: both (default, current world with the old world in parentheses), + * current, old, total. The old world's stats are read from + * config/stats-command/old-world/ (see README). + * + * Categories: mined, used, crafted, broken, picked_up, dropped, killed, + * killed_by, custom + */ +public class StatsCommand implements ModInitializer { + + private static final int PAGE_SIZE = 10; + private static StatsIndex statsIndex; + + // Stats of the previous world (read-only, loaded in the background at start) + private static volatile OldWorldStats oldStats = OldWorldStats.empty(); + private static volatile boolean oldStatsReady = false; + + // Stats that are measured in ticks (20 ticks = 1 second) + private static final Set TIME_STATS = Set.of( + "play_time", "time_since_death", "time_since_rest", "sneak_time", + "total_world_time"); + + // Stats that are measured in centimeters + private static final Set DISTANCE_STATS = Set.of( + "walk_one_cm", "sprint_one_cm", "swim_one_cm", "fall_one_cm", + "climb_one_cm", "fly_one_cm", "walk_under_water_one_cm", + "minecart_one_cm", "boat_one_cm", "pig_one_cm", "horse_one_cm", + "aviate_one_cm", "walk_on_water_one_cm", "strider_one_cm", + "crouch_one_cm"); + + // ==================== INITIALIZATION ==================== + + @Override + public void onInitialize() { + registerLifecycleEvents(); + registerCommands(); + } + + private void registerLifecycleEvents() { + // Initialize index on server start + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + statsIndex = new StatsIndex(server); + statsIndex.syncAll(server); + loadOldWorldStats(statsIndex); + }); + + // Clear index on server stop + ServerLifecycleEvents.SERVER_STOPPED.register(server -> { + if (statsIndex != null) { + statsIndex.close(); + statsIndex = null; + } + oldStats = OldWorldStats.empty(); + oldStatsReady = false; + }); + + // Capture stats when player disconnects + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + if (statsIndex == null) + return; + + ServerPlayer player = handler.getPlayer(); + statsIndex.cachePlayerName(player.getUUID(), player.getName().getString()); + + int playerId = statsIndex.getOrCreatePlayerId(player.getUUID()); + StatsCounter statHandler = player.getStats(); + + for (StatType statType : BuiltInRegistries.STAT_TYPE) { + persistStats(statType, statHandler, playerId); + } + }); + } + + /** + * Runs a command with its optional trailing arguments already parsed. + */ + @FunctionalInterface + private interface TrailingRunner { + int run(CommandContext ctx, boolean share, StatsMode mode) throws CommandSyntaxException; + } + + /** + * Makes a command node executable and attaches the optional trailing arguments: + * [share] and [mode] (both|current|old|total), in any order. Examples of what + * is accepted after the node: nothing, "true", "old", "true old", "old true". + */ + private static void addTrailingOptions(ArgumentBuilder node, TrailingRunner runner) { + node.executes(ctx -> runner.run(ctx, false, StatsMode.BOTH)); + + // [share] [mode] + var shareNode = argument("share", BoolArgumentType.bool()) + .executes(ctx -> runner.run(ctx, BoolArgumentType.getBool(ctx, "share"), StatsMode.BOTH)); + for (StatsMode mode : StatsMode.values()) { + shareNode.then(literal(mode.id) + .executes(ctx -> runner.run(ctx, BoolArgumentType.getBool(ctx, "share"), mode))); + } + node.then(shareNode); + + // [mode] [share] + for (StatsMode mode : StatsMode.values()) { + node.then(literal(mode.id) + .executes(ctx -> runner.run(ctx, false, mode)) + .then(argument("share", BoolArgumentType.bool()) + .executes(ctx -> runner.run(ctx, BoolArgumentType.getBool(ctx, "share"), mode)))); + } + } + + /** + * Loads the old world's stats from config/stats-command/old-world/ in the background. + */ + private void loadOldWorldStats(StatsIndex index) { + Thread thread = new Thread(() -> { + try { + Path oldWorldDir = FabricLoader.getInstance().getConfigDir() + .resolve("stats-command").resolve("old-world"); + Files.createDirectories(oldWorldDir.resolve("stats")); + Path serverUsercache = FabricLoader.getInstance().getGameDir().resolve("usercache.json"); + oldStats = OldWorldStats.load(oldWorldDir, List.of(serverUsercache), + index::getCachedName, msg -> System.out.println("[StatsCommand] " + msg)); + } catch (Exception e) { + e.printStackTrace(); + } finally { + oldStatsReady = true; + } + }, "StatsCommand-OldWorld"); + thread.setDaemon(true); + thread.start(); + } + + private void registerCommands() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, env) -> { + LiteralArgumentBuilder statsCmd = literal("stats") + .executes(ctx -> showHelp(ctx)) + .then(literal("help").executes(ctx -> showHelp(ctx))); + + // Player stats lookup: /stats [share] [mode] + var playerArg = argument("target", GameProfileArgument.gameProfile()); + + // Block stats + var minedBlock = argument("block", ResourceArgument.resource(registryAccess, Registries.BLOCK)); + addTrailingOptions(minedBlock, (ctx, share, mode) -> executeStat(ctx, Stats.BLOCK_MINED, + ResourceArgument.getResource(ctx, "block", Registries.BLOCK).value(), + "mined", share, mode)); + playerArg.then(literal("mined").then(minedBlock)); + + // Item stats + for (var entry : List.of( + Map.entry("used", Stats.ITEM_USED), + Map.entry("crafted", Stats.ITEM_CRAFTED), + Map.entry("broken", Stats.ITEM_BROKEN), + Map.entry("picked_up", Stats.ITEM_PICKED_UP), + Map.entry("dropped", Stats.ITEM_DROPPED))) { + var itemNode = argument("item", ResourceArgument.resource(registryAccess, Registries.ITEM)); + addTrailingOptions(itemNode, (ctx, share, mode) -> executeStat(ctx, entry.getValue(), + ResourceArgument.getResource(ctx, "item", Registries.ITEM).value(), + entry.getKey().replace("_", " "), share, mode)); + playerArg.then(literal(entry.getKey()).then(itemNode)); + } + + // Entity stats + var killedEntity = argument("entity", ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)); + addTrailingOptions(killedEntity, (ctx, share, mode) -> executeStat(ctx, Stats.ENTITY_KILLED, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed", share, mode)); + playerArg.then(literal("killed").then(killedEntity)); + + var killedByEntity = argument("entity", ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)); + addTrailingOptions(killedByEntity, (ctx, share, mode) -> executeStat(ctx, Stats.ENTITY_KILLED_BY, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed by", share, mode)); + playerArg.then(literal("killed_by").then(killedByEntity)); + + // Custom stats + SuggestionProvider customStatSuggestions = (ctx, builder) -> { + for (Identifier id : Stats.CUSTOM.getRegistry()) { + builder.suggest(id.toString()); + } + return builder.buildFuture(); + }; + + var customStat = argument("stat", IdentifierArgument.id()).suggests(customStatSuggestions); + addTrailingOptions(customStat, (ctx, share, mode) -> executeCustomStat(ctx, + IdentifierArgument.getId(ctx, "stat"), share, mode)); + playerArg.then(literal("custom").then(customStat)); + + statsCmd.then(playerArg); + + // Leaderboards: /stats top [page] [share] [mode] + var topCmd = literal("top"); + + // Top mined + var topBlock = argument("block", ResourceArgument.resource(registryAccess, Registries.BLOCK)); + addTrailingOptions(topBlock, (ctx, share, mode) -> executeTop(ctx, Stats.BLOCK_MINED, + ResourceArgument.getResource(ctx, "block", Registries.BLOCK).value(), + "mined", 1, share, mode)); + var topBlockPage = argument("page", IntegerArgumentType.integer(1)); + addTrailingOptions(topBlockPage, (ctx, share, mode) -> executeTop(ctx, Stats.BLOCK_MINED, + ResourceArgument.getResource(ctx, "block", Registries.BLOCK).value(), + "mined", IntegerArgumentType.getInteger(ctx, "page"), share, mode)); + topBlock.then(topBlockPage); + topCmd.then(literal("mined").then(topBlock)); + + // Top item stats + for (var entry : List.of( + Map.entry("used", Stats.ITEM_USED), + Map.entry("crafted", Stats.ITEM_CRAFTED), + Map.entry("broken", Stats.ITEM_BROKEN), + Map.entry("picked_up", Stats.ITEM_PICKED_UP), + Map.entry("dropped", Stats.ITEM_DROPPED))) { + var topItem = argument("item", ResourceArgument.resource(registryAccess, Registries.ITEM)); + addTrailingOptions(topItem, (ctx, share, mode) -> executeTop(ctx, entry.getValue(), + ResourceArgument.getResource(ctx, "item", Registries.ITEM).value(), + entry.getKey().replace("_", " "), 1, share, mode)); + var topItemPage = argument("page", IntegerArgumentType.integer(1)); + addTrailingOptions(topItemPage, (ctx, share, mode) -> executeTop(ctx, entry.getValue(), + ResourceArgument.getResource(ctx, "item", Registries.ITEM).value(), + entry.getKey().replace("_", " "), + IntegerArgumentType.getInteger(ctx, "page"), share, mode)); + topItem.then(topItemPage); + topCmd.then(literal(entry.getKey()).then(topItem)); + } + + // Top entity stats + var topKilled = argument("entity", ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)); + addTrailingOptions(topKilled, (ctx, share, mode) -> executeTop(ctx, Stats.ENTITY_KILLED, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed", 1, share, mode)); + var topKilledPage = argument("page", IntegerArgumentType.integer(1)); + addTrailingOptions(topKilledPage, (ctx, share, mode) -> executeTop(ctx, Stats.ENTITY_KILLED, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed", IntegerArgumentType.getInteger(ctx, "page"), share, mode)); + topKilled.then(topKilledPage); + topCmd.then(literal("killed").then(topKilled)); + + var topKilledBy = argument("entity", ResourceArgument.resource(registryAccess, Registries.ENTITY_TYPE)); + addTrailingOptions(topKilledBy, (ctx, share, mode) -> executeTop(ctx, Stats.ENTITY_KILLED_BY, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed by", 1, share, mode)); + var topKilledByPage = argument("page", IntegerArgumentType.integer(1)); + addTrailingOptions(topKilledByPage, (ctx, share, mode) -> executeTop(ctx, Stats.ENTITY_KILLED_BY, + ResourceArgument.getResource(ctx, "entity", Registries.ENTITY_TYPE).value(), + "killed by", IntegerArgumentType.getInteger(ctx, "page"), share, mode)); + topKilledBy.then(topKilledByPage); + topCmd.then(literal("killed_by").then(topKilledBy)); + + // Top custom stats + var topCustom = argument("stat", IdentifierArgument.id()).suggests(customStatSuggestions); + addTrailingOptions(topCustom, (ctx, share, mode) -> executeCustomTop(ctx, + IdentifierArgument.getId(ctx, "stat"), 1, share, mode)); + var topCustomPage = argument("page", IntegerArgumentType.integer(1)); + addTrailingOptions(topCustomPage, (ctx, share, mode) -> executeCustomTop(ctx, + IdentifierArgument.getId(ctx, "stat"), + IntegerArgumentType.getInteger(ctx, "page"), share, mode)); + topCustom.then(topCustomPage); + topCmd.then(literal("custom").then(topCustom)); + + statsCmd.then(topCmd); + dispatcher.register(statsCmd); + }); + } + + // ==================== COMMAND HANDLERS ==================== + + /** + * Displays a concise help message for the /stats command. + */ + private int showHelp(CommandContext ctx) { + ctx.getSource().sendSuccess(() -> Component.literal("§6--- Stats Command ---"), false); + ctx.getSource().sendSuccess( + () -> Component.literal("§e/stats [mode]§r - Look up a player's stat"), false); + ctx.getSource().sendSuccess( + () -> Component.literal("§e/stats top [page] [mode]§r - View leaderboard"), + false); + ctx.getSource() + .sendSuccess(() -> Component.literal( + "§7Categories:§r mined, used, crafted, broken, picked_up, dropped, killed, killed_by, custom"), + false); + ctx.getSource().sendSuccess(() -> Component.literal( + "§7Modes:§r both (default, old world in parentheses), current, old, total"), false); + ctx.getSource().sendSuccess(() -> Component.literal("§7Examples:§r"), false); + ctx.getSource().sendSuccess(() -> Component.literal(" /stats Steve mined minecraft:diamond_ore"), false); + ctx.getSource().sendSuccess(() -> Component.literal(" /stats top custom minecraft:play_time total"), false); + return 1; + } + + /** + * Highest indexed current-world value of a stat over every UUID the player's + * stats may be stored under (see equivalentUuids). 0 if the index is not ready. + */ + private int indexedValue(UUID id, String name, String statKey) { + if (statsIndex == null) { + return 0; + } + int best = 0; + for (UUID candidate : equivalentUuids(id, name)) { + best = Math.max(best, statsIndex.getPlayerStat(candidate, statKey)); + } + return best; + } + + /** + * Checks that the requested mode can be served. Returns an error message, or + * null if everything is fine. + */ + private String checkMode(StatsMode mode) { + if (mode == StatsMode.CURRENT || mode == StatsMode.BOTH) { + return null; // BOTH falls back to the current world when there is no old data + } + if (!oldStatsReady) { + return "Old world stats are still loading, try again in a moment."; + } + if (!oldStats.hasData()) { + return "No old world stats found. Put the old world's stats/*.json files in " + + "config/stats-command/old-world/stats/ and restart the server."; + } + return null; + } + + /** + * BOTH only makes sense when old world data exists; without it the output is + * exactly the one of the original mod. + */ + private StatsMode effectiveMode(StatsMode mode) { + if (mode == StatsMode.BOTH && !oldStats.hasData()) { + return StatsMode.CURRENT; + } + return mode; + } + + /** + * Formats the value(s) of a stat for the given mode: "§a1,824 §7(3,243)" for + * BOTH, a single green value for the others. + */ + private String formatValue(StatsMode mode, int current, int old, IntFunction formatter) { + switch (mode) { + case BOTH: + return "§a" + formatter.apply(current) + " §7(" + formatter.apply(old) + ")"; + case OLD: + return "§a" + formatter.apply(old); + case TOTAL: + return "§a" + formatter.apply(OldWorldStats.addClamped(current, old)); + default: + return "§a" + formatter.apply(current); + } + } + + /** + * Executes a stat lookup for standard stat types (blocks, items, entities). + */ + private int executeStat(CommandContext ctx, StatType type, T key, String verb, + boolean share, StatsMode requestedMode) { + try { + String modeError = checkMode(requestedMode); + if (modeError != null) { + sendError(ctx, modeError); + return 0; + } + StatsMode mode = effectiveMode(requestedMode); + + var profiles = GameProfileArgument.getGameProfiles(ctx, "target"); + if (profiles.isEmpty()) { + sendError(ctx, "Player not found."); + return 0; + } + var profile = profiles.iterator().next(); + + MinecraftServer server = ctx.getSource().getServer(); + ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); + String statKey = getStatTypeId(type) + "/" + getStatKeyString(key); + + if (onlinePlayer == null && statsIndex == null) { + sendError(ctx, "Index not loaded yet."); + return 0; + } + int value = onlinePlayer != null ? onlinePlayer.getStats().getValue(type.get(key)) : 0; + value = Math.max(value, indexedValue(profile.id(), profile.name(), statKey)); + int oldValue = mode == StatsMode.CURRENT ? 0 + : oldStats.get(OldWorldStats.canonicalFor(profile.id(), profile.name()), statKey); + + String valueText = formatValue(mode, value, oldValue, v -> String.format("%,d", v)); + String message = formatStatMessage(profile.name(), verb, valueText, getReadableName(key), type, mode); + if (share) { + broadcastAsPlayer(ctx, Component.literal(message)); + } else { + sendWithShareButton(ctx, message); + } + return 1; + } catch (Exception e) { + sendError(ctx, e.getMessage()); + return 0; + } + } + + /** + * Executes a stat lookup for custom stats. + * Custom stats require special handling to get the exact registered Identifier. + */ + private int executeCustomStat(CommandContext ctx, Identifier statId, boolean share, + StatsMode requestedMode) { + try { + String modeError = checkMode(requestedMode); + if (modeError != null) { + sendError(ctx, modeError); + return 0; + } + StatsMode mode = effectiveMode(requestedMode); + + Identifier registeredId = findRegisteredCustomStat(statId); + if (registeredId == null) { + sendError(ctx, "Unknown custom stat: " + statId); + return 0; + } + + var profiles = GameProfileArgument.getGameProfiles(ctx, "target"); + if (profiles.isEmpty()) { + sendError(ctx, "Player not found."); + return 0; + } + var profile = profiles.iterator().next(); + + MinecraftServer server = ctx.getSource().getServer(); + ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); + String statKey = "minecraft:custom/" + registeredId; + + if (onlinePlayer == null && statsIndex == null) { + sendError(ctx, "Index not loaded yet."); + return 0; + } + int value = onlinePlayer != null ? onlinePlayer.getStats().getValue(Stats.CUSTOM.get(registeredId)) : 0; + value = Math.max(value, indexedValue(profile.id(), profile.name(), statKey)); + int oldValue = mode == StatsMode.CURRENT ? 0 + : oldStats.get(OldWorldStats.canonicalFor(profile.id(), profile.name()), statKey); + + String readableName = getCustomStatReadableName(statId); + String valueText = formatValue(mode, value, oldValue, v -> formatCustomStatValue(statId.getPath(), v)); + String message = String.format("%s: %s§r %s%s", profile.name(), valueText, readableName, + modeSuffix(mode)); + if (share) { + broadcastAsPlayer(ctx, Component.literal(message)); + } else { + sendWithShareButton(ctx, message); + } + return 1; + } catch (Exception e) { + sendError(ctx, e.getMessage()); + return 0; + } + } + + /** + * Executes a leaderboard lookup for standard stat types. + */ + private int executeTop(CommandContext ctx, StatType type, T key, String verb, + int page, boolean share, StatsMode requestedMode) { + if (statsIndex == null) { + sendError(ctx, "Index not loaded yet."); + return 0; + } + String modeError = checkMode(requestedMode); + if (modeError != null) { + sendError(ctx, modeError); + return 0; + } + StatsMode mode = effectiveMode(requestedMode); + + MinecraftServer server = ctx.getSource().getServer(); + String statKey = getStatTypeId(type) + "/" + getStatKeyString(key); + Stat stat = type.get(key); + + List results = buildRows(server, statKey, stat, mode); + return displayLeaderboard(ctx, results, getReadableName(key), verb, type, page, share, mode); + } + + /** + * Executes a leaderboard lookup for custom stats. + */ + private int executeCustomTop(CommandContext ctx, Identifier statId, int page, boolean share, + StatsMode requestedMode) { + if (statsIndex == null) { + sendError(ctx, "Index not loaded yet."); + return 0; + } + String modeError = checkMode(requestedMode); + if (modeError != null) { + sendError(ctx, modeError); + return 0; + } + StatsMode mode = effectiveMode(requestedMode); + + Identifier registeredId = findRegisteredCustomStat(statId); + if (registeredId == null) { + sendError(ctx, "Unknown custom stat: " + statId); + return 0; + } + + MinecraftServer server = ctx.getSource().getServer(); + String statKey = "minecraft:custom/" + registeredId; + Stat stat = Stats.CUSTOM.get(registeredId); + + List results = buildRows(server, statKey, stat, mode); + String readableName = getCustomStatReadableName(statId); + + return displayCustomLeaderboard(ctx, results, readableName, statId.getPath(), page, share, mode); + } + + // ==================== HELPER METHODS ==================== + + /** + * Finds the exact Identifier object from the custom stat registry. + * This is required because StatType.get() uses object identity for + * registry lookups. + */ + private Identifier findRegisteredCustomStat(Identifier statId) { + for (Identifier id : Stats.CUSTOM.getRegistry()) { + if (id.equals(statId)) { + return id; + } + } + return null; + } + + /** + * Builds a merged leaderboard from indexed stats and live online player stats. + */ + private List buildLeaderboard(MinecraftServer server, String statKey, Stat stat) { + Map entryMap = new HashMap<>(); + + // Load indexed results + for (StatsIndex.Entry entry : statsIndex.getTop(statKey, 1, 10000)) { + entryMap.put(entry.uuid, entry); + } + + // Merge with live stats from online players + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + UUID uuid = player.getUUID(); + int liveValue = player.getStats().getValue(stat); + + if (entryMap.containsKey(uuid)) { + entryMap.get(uuid).value = liveValue; + } else if (liveValue > 0) { + entryMap.put(uuid, new StatsIndex.Entry(uuid, liveValue)); + } + + statsIndex.cachePlayerName(uuid, player.getName().getString()); + } + + // Sort by value descending + List sorted = new ArrayList<>(entryMap.values()); + sorted.sort((a, b) -> Integer.compare(b.value, a.value)); + return sorted; + } + + /** + * Name known for a UUID: online name, cached name, or the old world's name. + */ + private String knownName(UUID uuid) { + String name = statsIndex != null ? statsIndex.getCachedName(uuid) : null; + return name != null ? name : oldStats.nameOf(uuid); + } + + /** + * The UUID a player has now. Floodgate UUIDs (and premium ones) are turned into + * the offline UUID of their name, which is what EasyAuth's forced-offline-uuid + * gives them. This way one person is one row, even while their stats still + * exist under several UUIDs. + */ + private UUID canonicalUuid(UUID uuid) { + return OldWorldStats.canonicalFor(uuid, knownName(uuid)); + } + + /** + * All the UUIDs a player's current-world stats can be stored under: the one + * given, its canonical one, and any Floodgate UUID that has the same name. + */ + private Set equivalentUuids(UUID id, String name) { + Set ids = new LinkedHashSet<>(); + ids.add(id); + ids.add(OldWorldStats.canonicalFor(id, name)); + if (statsIndex != null && name != null) { + for (UUID other : statsIndex.getAllPlayerUUIDs()) { + if (OldWorldStats.isFloodgateUuid(other) && name.equals(statsIndex.getCachedName(other))) { + ids.add(other); + } + } + } + return ids; + } + + /** + * Builds the leaderboard rows for a mode, sorted by the value that mode ranks + * by (current world for BOTH/CURRENT, old world for OLD, the sum for TOTAL). + * Players that only exist in the old world appear in OLD and TOTAL only. + */ + private List buildRows(MinecraftServer server, String statKey, Stat stat, StatsMode mode) { + Map currentRaw = new HashMap<>(); + if (mode != StatsMode.OLD) { + for (StatsIndex.Entry entry : buildLeaderboard(server, statKey, stat)) { + currentRaw.put(entry.uuid, entry.value); + } + } + return StatsRows.build(currentRaw, this::canonicalUuid, oldStats, statKey, mode); + } + + /** + * Displays a paginated leaderboard. + */ + private int displayLeaderboard(CommandContext ctx, List results, + String itemName, String verb, StatType type, int page, boolean share, StatsMode mode) { + int start = (page - 1) * PAGE_SIZE; + if (start >= results.size() || results.isEmpty()) { + sendError(ctx, "No stats found (or page empty)."); + return 0; + } + + int end = Math.min(start + PAGE_SIZE, results.size()); + List pageResults = results.subList(start, end); + + // Send header + String header = formatTopHeader(verb, itemName, type, page, mode); + if (share) { + broadcastAsPlayer(ctx, Component.literal(header)); + } else { + sendWithShareButton(ctx, header); + } + + // Send entries + MinecraftServer server = ctx.getSource().getServer(); + for (int i = 0; i < pageResults.size(); i++) { + Row entry = pageResults.get(i); + int rank = start + i + 1; + String valueText = formatValue(mode, entry.current, entry.old, v -> String.format("%,d", v)); + String name = getPlayerName(server, entry.uuid); + + Component line = Component.literal(String.format("#%d %s: %s", rank, name, valueText)); + if (share) { + ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); + } else { + ctx.getSource().sendSuccess(() -> line, false); + } + } + + return 1; + } + + /** + * Gets a player's display name (online name, cached name, or truncated UUID). + */ + private String getPlayerName(MinecraftServer server, UUID uuid) { + ServerPlayer player = server.getPlayerList().getPlayer(uuid); + if (player != null) { + return player.getName().getString(); + } + String cached = statsIndex.getCachedName(uuid); + if (cached == null) { + cached = oldStats.nameOf(uuid); + } + return cached != null ? cached : uuid.toString().substring(0, 8) + "..."; + } + + /** + * Persists all stats for a player when they disconnect. + */ + private void persistStats(StatType statType, StatsCounter statHandler, int playerId) { + for (T key : statType.getRegistry()) { + Stat stat = statType.get(key); + int value = statHandler.getValue(stat); + if (value > 0) { + String fullKey = getStatKey(stat); + int statId = statsIndex.getOrCreateStatId(fullKey); + statsIndex.updateStat(playerId, statId, value); + } + } + } + + // ==================== FORMATTING ==================== + + /** + * Formats a custom stat value based on its type (time, distance, or plain + * number). + */ + private String formatCustomStatValue(String statPath, int value) { + if (TIME_STATS.contains(statPath)) { + return formatTime(value); + } else if (DISTANCE_STATS.contains(statPath)) { + return formatDistance(value); + } + return String.format("%,d", value); + } + + /** + * Formats ticks as human-readable time (e.g., "2h 30m" or "45 minutes"). + */ + private String formatTime(int ticks) { + int totalSeconds = ticks / 20; + int hours = totalSeconds / 3600; + int minutes = (totalSeconds % 3600) / 60; + + if (hours > 0) { + return String.format("%dh %dm", hours, minutes); + } else if (minutes > 0) { + return String.format("%d minutes", minutes); + } else { + return String.format("%d seconds", totalSeconds); + } + } + + /** + * Formats centimeters as human-readable distance (e.g., "1.5 km" or "200 m"). + */ + private String formatDistance(int cm) { + double meters = cm / 100.0; + if (meters >= 1000) { + return String.format("%.1f km", meters / 1000.0); + } else { + return String.format("%.0f m", meters); + } + } + + /** + * Gets a readable name for a custom stat, removing the "_one_cm" suffix for + * distance stats. + */ + private String getCustomStatReadableName(Identifier statId) { + String path = statId.getPath(); + // Clean up distance stat names (e.g., "walk_one_cm" -> "walked") + if (path.endsWith("_one_cm")) { + String base = path.substring(0, path.length() - 7); + return base.replace("_", " "); + } + return path.replace("_", " "); + } + + /** + * Displays a paginated leaderboard for custom stats with formatted values. + */ + private int displayCustomLeaderboard(CommandContext ctx, List results, + String itemName, String statPath, int page, boolean share, StatsMode mode) { + int start = (page - 1) * PAGE_SIZE; + if (start >= results.size() || results.isEmpty()) { + sendError(ctx, "No stats found (or page empty)."); + return 0; + } + + int end = Math.min(start + PAGE_SIZE, results.size()); + List pageResults = results.subList(start, end); + + // Send header + String header = String.format("--- Top %s%s (Page %d) ---", itemName, mode.headerLabel(), page); + if (share) { + broadcastAsPlayer(ctx, Component.literal(header)); + } else { + sendWithShareButton(ctx, header); + } + + // Send entries with formatted values + MinecraftServer server = ctx.getSource().getServer(); + for (int i = 0; i < pageResults.size(); i++) { + Row entry = pageResults.get(i); + int rank = start + i + 1; + String valueText = formatValue(mode, entry.current, entry.old, v -> formatCustomStatValue(statPath, v)); + String name = getPlayerName(server, entry.uuid); + + Component line = Component.literal(String.format("#%d %s: %s", rank, name, valueText)); + if (share) { + ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); + } else { + ctx.getSource().sendSuccess(() -> line, false); + } + } + + return 1; + } + + private void sendWithShareButton(CommandContext ctx, String message) { + String shareCommand = StatsMode.shareCommand(ctx.getInput()); + Component shareButton = Component.literal(" [Share]") + .withStyle(style -> style + .withColor(ChatFormatting.GREEN) + .withClickEvent(new ClickEvent.RunCommand(shareCommand)) + .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to share in chat")))); + + ctx.getSource().sendSuccess(() -> Component.literal(message).append(shareButton), false); + } + + private void broadcastAsPlayer(CommandContext ctx, Component message) { + String playerName = ctx.getSource().getTextName(); + Component fullMessage = Component.literal("<" + playerName + "> ").append(message); + ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(fullMessage, false); + } + + private void sendError(CommandContext ctx, String message) { + ctx.getSource().sendFailure(Component.literal("§c" + message)); + } + + /** + * Text appended to single-player lookups so a shared message says which world + * it is about. BOTH needs none: the value in parentheses is the old world. + */ + private String modeSuffix(StatsMode mode) { + switch (mode) { + case OLD: + return " §7(Old world)"; + case TOTAL: + return " §7(Total)"; + default: + return ""; + } + } + + private String formatStatMessage(String playerName, String verb, String valueText, String itemName, + StatType type, StatsMode mode) { + String suffix = modeSuffix(mode); + if (type == Stats.CUSTOM) { + return String.format("%s: %s§r %s%s", playerName, valueText, itemName, suffix); + } else if (type == Stats.ENTITY_KILLED_BY) { + return String.format("%s was killed by %s§r %s%s", playerName, valueText, itemName, suffix); + } else if (type == Stats.ENTITY_KILLED) { + return String.format("%s has killed %s§r %s%s", playerName, valueText, itemName, suffix); + } else { + return String.format("%s has %s %s§r %s%s", playerName, verb, valueText, itemName, suffix); + } + } + + private String formatTopHeader(String verb, String itemName, StatType type, int page, StatsMode mode) { + String label = mode.headerLabel(); + if (type == Stats.CUSTOM) { + return String.format("--- Top %s%s (Page %d) ---", itemName, label, page); + } else if (type == Stats.ENTITY_KILLED_BY) { + return String.format("--- Most Killed By %s%s (Page %d) ---", itemName, label, page); + } else if (type == Stats.ENTITY_KILLED) { + return String.format("--- Most %s Killed%s (Page %d) ---", itemName, label, page); + } else { + String capitalizedVerb = verb.substring(0, 1).toUpperCase() + verb.substring(1); + return String.format("--- Most %s %s%s (Page %d) ---", itemName, capitalizedVerb, label, page); + } + } + + // ==================== STAT KEY UTILITIES ==================== + + private String getStatTypeId(StatType type) { + Identifier id = BuiltInRegistries.STAT_TYPE.getKey(type); + return id != null ? id.toString() : "minecraft:custom"; + } + + private String getStatKey(Stat stat) { + return getStatTypeId(stat.getType()) + "/" + getStatKeyString(stat.getValue()); + } + + private String getStatKeyString(T key) { + if (key instanceof Block block) { + return BuiltInRegistries.BLOCK.getKey(block).toString(); + } else if (key instanceof Item item) { + return BuiltInRegistries.ITEM.getKey(item).toString(); + } else if (key instanceof EntityType entityType) { + return BuiltInRegistries.ENTITY_TYPE.getKey(entityType).toString(); + } else if (key instanceof Identifier id) { + return id.toString(); + } + return key.toString(); + } + + private String getReadableName(T key) { + if (key instanceof Block block) { + return block.getName().getString(); + } else if (key instanceof Item item) { + return item.getName(item.getDefaultInstance()).getString(); + } else if (key instanceof EntityType entityType) { + return entityType.getDescription().getString(); + } else if (key instanceof Identifier id) { + return id.getPath().replace("_", " "); + } + return key.toString(); + } +} \ No newline at end of file