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 index 5d1a7dc..3f7c9a0 100644 --- a/src/java/hlvm/stats/StatsCommand.java +++ b/src/java/hlvm/stats/StatsCommand.java @@ -2,10 +2,13 @@ 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; @@ -30,8 +33,13 @@ 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.*; @@ -39,8 +47,12 @@ * Stats Command Mod - Provides player statistics lookup and leaderboards. * * Commands: - * /stats - Look up individual player stats - * /stats top [page] - View leaderboards + * /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 @@ -50,6 +62,10 @@ 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", @@ -76,6 +92,7 @@ private void registerLifecycleEvents() { ServerLifecycleEvents.SERVER_STARTED.register(server -> { statsIndex = new StatsIndex(server); statsIndex.syncAll(server); + loadOldWorldStats(statsIndex); }); // Clear index on server stop @@ -84,6 +101,8 @@ private void registerLifecycleEvents() { statsIndex.close(); statsIndex = null; } + oldStats = OldWorldStats.empty(); + oldStatsReady = false; }); // Capture stats when player disconnects @@ -103,28 +122,77 @@ private void registerLifecycleEvents() { }); } + /** + * 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 + // Player stats lookup: /stats [share] [mode] 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")))))); + 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( @@ -133,47 +201,25 @@ private void registerCommands() { 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")))))); + 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 - 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")))))); + 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) -> { @@ -183,45 +229,27 @@ private void registerCommands() { 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")))))); + 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 + // Leaderboards: /stats top [page] [share] [mode] 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"))))))); + 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( @@ -230,103 +258,52 @@ private void registerCommands() { 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"))))))); + 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 - 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"))))))); + 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 - 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"))))))); + 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); @@ -341,25 +318,96 @@ private void registerCommands() { 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"), + () -> 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"), 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) { + 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."); @@ -369,20 +417,19 @@ private int executeStat(CommandContext ctx, StatType MinecraftServer server = ctx.getSource().getServer(); ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); + String statKey = getStatTypeId(type) + "/" + getStatKeyString(key); - 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); + 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 message = formatStatMessage(profile.name(), verb, value, getReadableName(key), type); + 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 { @@ -399,8 +446,16 @@ private int executeStat(CommandContext ctx, StatType * 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) { + 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); @@ -416,21 +471,21 @@ private int executeCustomStat(CommandContext ctx, Identifier MinecraftServer server = ctx.getSource().getServer(); ServerPlayer onlinePlayer = server.getPlayerList().getPlayer(profile.id()); + String statKey = "minecraft:custom/" + registeredId; - 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); + 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 formattedValue = formatCustomStatValue(statId.getPath(), value); - String message = String.format("%s: §a%s§r %s", profile.name(), formattedValue, readableName); + 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 { @@ -447,28 +502,41 @@ private int executeCustomStat(CommandContext ctx, Identifier * Executes a leaderboard lookup for standard stat types. */ private int executeTop(CommandContext ctx, StatType type, T key, String verb, - int page, boolean share) { + 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 = buildLeaderboard(server, statKey, stat); - return displayLeaderboard(ctx, results, getReadableName(key), verb, type, page, share); + 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) { + 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) { @@ -480,10 +548,10 @@ private int executeCustomTop(CommandContext ctx, Identifier String statKey = "minecraft:custom/" + registeredId; Stat stat = Stats.CUSTOM.get(registeredId); - List results = buildLeaderboard(server, statKey, stat); + List results = buildRows(server, statKey, stat, mode); String readableName = getCustomStatReadableName(statId); - return displayCustomLeaderboard(ctx, results, readableName, statId.getPath(), page, share); + return displayCustomLeaderboard(ctx, results, readableName, statId.getPath(), page, share, mode); } // ==================== HELPER METHODS ==================== @@ -533,11 +601,62 @@ private List buildLeaderboard(MinecraftServer server, Stri 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) { + 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)."); @@ -545,10 +664,10 @@ private int displayLeaderboard(CommandContext ctx, List< } int end = Math.min(start + PAGE_SIZE, results.size()); - List pageResults = results.subList(start, end); + List pageResults = results.subList(start, end); // Send header - String header = formatTopHeader(verb, itemName, type, page); + String header = formatTopHeader(verb, itemName, type, page, mode); if (share) { broadcastAsPlayer(ctx, Component.literal(header)); } else { @@ -558,12 +677,12 @@ private int displayLeaderboard(CommandContext ctx, List< // Send entries MinecraftServer server = ctx.getSource().getServer(); for (int i = 0; i < pageResults.size(); i++) { - StatsIndex.Entry entry = pageResults.get(i); + Row entry = pageResults.get(i); int rank = start + i + 1; - int value = entry.value; + 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: §a%,d", rank, name, value)); + Component line = Component.literal(String.format("#%d %s: %s", rank, name, valueText)); if (share) { ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); } else { @@ -583,6 +702,9 @@ private String getPlayerName(MinecraftServer server, UUID uuid) { 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) + "..."; } @@ -662,8 +784,8 @@ private String getCustomStatReadableName(Identifier statId) { /** * 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) { + 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)."); @@ -671,10 +793,10 @@ private int displayCustomLeaderboard(CommandContext ctx, Lis } int end = Math.min(start + PAGE_SIZE, results.size()); - List pageResults = results.subList(start, end); + List pageResults = results.subList(start, end); // Send header - String header = String.format("--- Top %s (Page %d) ---", itemName, page); + String header = String.format("--- Top %s%s (Page %d) ---", itemName, mode.headerLabel(), page); if (share) { broadcastAsPlayer(ctx, Component.literal(header)); } else { @@ -684,12 +806,12 @@ private int displayCustomLeaderboard(CommandContext ctx, Lis // Send entries with formatted values MinecraftServer server = ctx.getSource().getServer(); for (int i = 0; i < pageResults.size(); i++) { - StatsIndex.Entry entry = pageResults.get(i); + Row entry = pageResults.get(i); int rank = start + i + 1; - String formattedValue = formatCustomStatValue(statPath, entry.value); + 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: §a%s", rank, name, formattedValue)); + Component line = Component.literal(String.format("#%d %s: %s", rank, name, valueText)); if (share) { ctx.getSource().getServer().getPlayerList().broadcastSystemMessage(line, false); } else { @@ -701,12 +823,7 @@ private int displayCustomLeaderboard(CommandContext ctx, Lis } private void sendWithShareButton(CommandContext ctx, String message) { - String command = ctx.getInput(); - if (!command.startsWith("/")) { - command = "/" + command; - } - - String shareCommand = command + " true"; + String shareCommand = StatsMode.shareCommand(ctx.getInput()); Component shareButton = Component.literal(" [Share]") .withStyle(style -> style .withColor(ChatFormatting.GREEN) @@ -726,28 +843,46 @@ 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) { + /** + * 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: §a%,d§r %s", playerName, value, itemName); + 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 §a%,d§r %s", playerName, value, itemName); + 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 §a%,d§r %s", playerName, value, itemName); + return String.format("%s has killed %s§r %s%s", playerName, valueText, itemName, suffix); } else { - return String.format("%s has %s §a%,d§r %s", playerName, verb, value, itemName); + 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) { + 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 (Page %d) ---", itemName, page); + 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 (Page %d) ---", itemName, page); + 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 (Page %d) ---", itemName, page); + 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 (Page %d) ---", itemName, capitalizedVerb, page); + return String.format("--- Most %s %s%s (Page %d) ---", itemName, capitalizedVerb, label, page); } } 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; + } +}