Implement stats command for viewing Minecraft statistics

This commit is contained in:
Xujiayao 2026-02-22 19:07:15 +08:00
parent 42977b2eb6
commit 6978a4ab8b
10 changed files with 413 additions and 27 deletions

View file

@ -1,8 +1,10 @@
package com.xujiayao.discord_mc_chat.commands;
import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand;
import com.xujiayao.discord_mc_chat.utils.LogFileUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
@ -27,35 +29,78 @@ public class CommandAutoCompleter {
if (input == null || input.isBlank()) {
// Return all command names
suggestions.addAll(CommandManager.getCommandNames());
CommandManager.getCommands().stream()
.sorted(Comparator.comparing(Command::name))
.forEach(cmd -> {
StringBuilder builder = new StringBuilder();
builder.append(cmd.name());
for (Command.CommandArgument arg : cmd.args()) {
builder.append(" <").append(arg.name()).append(">");
}
suggestions.add(builder.toString());
});
return suggestions;
}
String trimmed = input.trim();
String[] parts = trimmed.split("\\s+", 2);
String[] parts = input.trim().split("\\s+");
String commandName = parts[0].toLowerCase();
if (parts.length == 1 && !input.endsWith(" ")) {
// User is still typing the command name suggest matching command names
for (String name : CommandManager.getCommandNames()) {
if (name.startsWith(commandName)) {
suggestions.add(name);
}
}
// User is still typing the command name
CommandManager.getCommands().stream()
.filter(cmd -> cmd.name().startsWith(commandName))
.sorted(Comparator.comparing(Command::name))
.forEach(cmd -> {
StringBuilder builder = new StringBuilder();
builder.append(cmd.name());
for (Command.CommandArgument arg : cmd.args()) {
builder.append(" <").append(arg.name()).append(">");
}
suggestions.add(builder.toString());
});
return suggestions;
}
// User has typed a command name and moved on to arguments
String argInput = parts.length > 1 ? parts[1] : "";
switch (commandName) {
case "log" -> {
// Suggest log file names
List<String> logFiles = LogFileUtils.listLogFiles();
String lowerArgInput = argInput.toLowerCase();
for (String file : logFiles) {
if (file.toLowerCase().startsWith(lowerArgInput) || file.toLowerCase().contains(lowerArgInput)) {
suggestions.add("log " + file);
String argInput = parts.length > 1 ? parts[1] : "";
if (parts.length == 1 || (parts.length == 2 && !input.endsWith(" "))) {
List<String> logFiles = LogFileUtils.listLogFiles();
String lowerArgInput = argInput.toLowerCase();
for (String file : logFiles) {
if (file.toLowerCase().startsWith(lowerArgInput) || file.toLowerCase().contains(lowerArgInput)) {
suggestions.add("log " + file);
}
}
}
}
case "stats" -> {
StatsCommand.StatsProvider provider = StatsCommand.getProvider();
if (provider != null) {
if (parts.length == 1 || (parts.length == 2 && !input.endsWith(" "))) {
String typeInput = parts.length > 1 ? parts[1] : "";
String lowerType = typeInput.toLowerCase();
for (String type : provider.getStatTypes()) {
if (type.toLowerCase().startsWith(lowerType) || type.toLowerCase().contains(lowerType)) {
suggestions.add("stats " + type);
}
}
} else if (parts.length == 2 || (parts.length == 3 && !input.endsWith(" "))) {
String type = parts[1];
String statInput = parts.length > 2 ? parts[2] : "";
String lowerStat = statInput.toLowerCase();
for (String stat : provider.getStatNames(type)) {
if (stat.toLowerCase().startsWith(lowerStat) || stat.toLowerCase().contains(lowerStat)) {
suggestions.add("stats " + type + " " + stat);
}
}
}
}
}

View file

@ -6,6 +6,7 @@ import com.xujiayao.discord_mc_chat.commands.impl.InfoCommand;
import com.xujiayao.discord_mc_chat.commands.impl.LogCommand;
import com.xujiayao.discord_mc_chat.commands.impl.ReloadCommand;
import com.xujiayao.discord_mc_chat.commands.impl.ShutdownCommand;
import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand;
import com.xujiayao.discord_mc_chat.utils.ExecutorServiceUtils;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
@ -46,6 +47,8 @@ public class CommandManager {
if ("standalone".equals(ModeManager.getMode())) {
register(new ExecuteCommand());
register(new ShutdownCommand());
} else {
register(new StatsCommand());
}
}
@ -77,15 +80,6 @@ public class CommandManager {
return new ArrayList<>(COMMANDS.values());
}
/**
* Get all registered command names.
*
* @return A collection of registered command names
*/
public static Collection<String> getCommandNames() {
return new ArrayList<>(COMMANDS.keySet());
}
/**
* Execute a command line.
*

View file

@ -0,0 +1,154 @@
package com.xujiayao.discord_mc_chat.commands.impl;
import com.xujiayao.discord_mc_chat.commands.Command;
import com.xujiayao.discord_mc_chat.commands.CommandSender;
import com.xujiayao.discord_mc_chat.utils.JsonUtils;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
/**
* Stats command implementation.
*
* @author Xujiayao
*/
public class StatsCommand implements Command {
private static StatsProvider provider;
public static StatsProvider getProvider() {
return provider;
}
public static void setProvider(StatsProvider provider) {
StatsCommand.provider = provider;
}
@Override
public String name() {
return "stats";
}
@Override
public CommandArgument[] args() {
return new CommandArgument[]{
new CommandArgument() {
@Override
public String name() {
return "type";
}
@Override
public String description() {
return I18nManager.getDmccTranslation("commands.stats.args_desc.type");
}
},
new CommandArgument() {
@Override
public String name() {
return "stat";
}
@Override
public String description() {
return I18nManager.getDmccTranslation("commands.stats.args_desc.stat");
}
}
};
}
@Override
public String description() {
return I18nManager.getDmccTranslation("commands.stats.description");
}
@Override
public void execute(CommandSender sender, String... args) {
if (provider == null) {
sender.reply(I18nManager.getDmccTranslation("commands.stats.server_not_ready"));
return;
}
provider.saveAll(); // Ensure data is written to disk
String type = args[0];
String stat = args[1];
Path statsDir = provider.getStatsDirectory();
if (statsDir == null || !Files.exists(statsDir) || !Files.isDirectory(statsDir)) {
sender.reply(I18nManager.getDmccTranslation("commands.stats.dir_not_found"));
return;
}
Map<String, Integer> leaderboard = new ConcurrentHashMap<>();
try (Stream<Path> stream = Files.list(statsDir)) {
stream.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".json"))
.forEach(p -> {
String fileName = p.getFileName().toString();
String uuidStr = fileName.substring(0, fileName.length() - 5);
try {
UUID uuid = UUID.fromString(uuidStr);
int value = JsonUtils.getStat(p, type, stat);
if (value > 0) {
String name = provider.getPlayerName(uuid);
if (name == null || name.isBlank()) {
name = uuidStr;
}
leaderboard.put(name, value);
}
} catch (Exception ignored) {
}
});
} catch (Exception e) {
sender.reply(I18nManager.getDmccTranslation("commands.stats.read_failed", e.getMessage()));
return;
}
if (leaderboard.isEmpty()) {
sender.reply(I18nManager.getDmccTranslation("commands.stats.no_stats", type, stat));
return;
}
List<Map.Entry<String, Integer>> sorted = new ArrayList<>(leaderboard.entrySet());
sorted.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue()));
StringBuilder sb = new StringBuilder();
String colon = I18nManager.getDmccTranslation("commands.stats.colon");
sb.append("========== ").append(I18nManager.getDmccTranslation("commands.stats.stats")).append(" ==========\n\n")
.append(I18nManager.getDmccTranslation("commands.stats.args_desc.type")).append(colon).append(type).append("\n")
.append(I18nManager.getDmccTranslation("commands.stats.args_desc.stat")).append(colon).append(stat).append("\n\n");
for (int i = 0; i < sorted.size(); i++) {
Map.Entry<String, Integer> entry = sorted.get(i);
sb.append(i + 1).append(". ").append(entry.getKey()).append(": ").append(entry.getValue());
if (i < sorted.size() - 1) {
sb.append("\n");
}
}
sender.reply(sb.toString());
}
public interface StatsProvider {
void saveAll();
Path getStatsDirectory();
String getPlayerName(UUID uuid);
List<String> getStatTypes();
List<String> getStatNames(String type);
}
}

View file

@ -1,6 +1,7 @@
package com.xujiayao.discord_mc_chat.server.discord;
import com.xujiayao.discord_mc_chat.commands.CommandManager;
import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand;
import com.xujiayao.discord_mc_chat.network.NetworkManager;
import com.xujiayao.discord_mc_chat.utils.LogFileUtils;
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
@ -46,6 +47,11 @@ public class DiscordEventHandler extends ListenerAdapter {
String file = event.getOption("file", OptionMapping::getAsString);
CommandManager.execute(new JdaCommandSender(event), name, file);
}
case "stats" -> {
String type = event.getOption("type", OptionMapping::getAsString);
String stat = event.getOption("stat", OptionMapping::getAsString);
CommandManager.execute(new JdaCommandSender(event), name, type, stat);
}
default -> CommandManager.execute(new JdaCommandSender(event), name);
}
}
@ -71,6 +77,14 @@ public class DiscordEventHandler extends ListenerAdapter {
choices = getLogFileChoices(currentValue);
}
}
case "stats" -> {
if ("type".equals(focusedOption)) {
choices = getStatsTypeChoices(currentValue);
} else if ("stat".equals(focusedOption)) {
String type = event.getOption("type", OptionMapping::getAsString);
choices = getStatsStatChoices(type, currentValue);
}
}
}
event.replyChoices(choices).queue();
@ -145,6 +159,43 @@ public class DiscordEventHandler extends ListenerAdapter {
.collect(Collectors.toList());
}
/**
* Gets auto-complete choices for the 'type' parameter of the stats command.
*
* @param currentValue The current user input for filtering
* @return List of choices
*/
private List<Command.Choice> getStatsTypeChoices(String currentValue) {
StatsCommand.StatsProvider provider = StatsCommand.getProvider();
if (provider == null) return List.of();
String lowerValue = currentValue.toLowerCase();
return provider.getStatTypes().stream()
.filter(t -> t.toLowerCase().contains(lowerValue))
.limit(25)
.map(t -> new Command.Choice(t, t))
.collect(Collectors.toList());
}
/**
* Gets auto-complete choices for the 'stat' parameter of the stats command.
*
* @param type The selected stat type
* @param currentValue The current user input for filtering
* @return List of choices
*/
private List<Command.Choice> getStatsStatChoices(String type, String currentValue) {
StatsCommand.StatsProvider provider = StatsCommand.getProvider();
if (provider == null || type == null || type.isBlank()) return List.of();
String lowerValue = currentValue.toLowerCase();
return provider.getStatNames(type).stream()
.filter(s -> s.toLowerCase().contains(lowerValue))
.limit(25)
.map(s -> new Command.Choice(s, s))
.collect(Collectors.toList());
}
@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
if (event.getAuthor().isBot() || event.isWebhookMessage()) {

View file

@ -106,6 +106,10 @@ public class DiscordManager {
.addOption(OptionType.STRING, "at", I18nManager.getDmccTranslation("commands.execute.args_desc.at"), true, true)
.addOption(OptionType.STRING, "command", I18nManager.getDmccTranslation("commands.execute.args_desc.command"), true, true));
commands.add(Commands.slash("shutdown", I18nManager.getDmccTranslation("commands.shutdown.description")));
} else {
commands.add(Commands.slash("stats", I18nManager.getDmccTranslation("commands.stats.description"))
.addOption(OptionType.STRING, "type", I18nManager.getDmccTranslation("commands.stats.args_desc.type"), true, true)
.addOption(OptionType.STRING, "stat", I18nManager.getDmccTranslation("commands.stats.args_desc.stat"), true, true));
}
CompletableFuture<List<Command>> updateFuture = jda.updateCommands().addCommands(commands).submit();

View file

@ -1,12 +1,17 @@
package com.xujiayao.discord_mc_chat.utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static com.xujiayao.discord_mc_chat.Constants.JSON_MAPPER;
import static com.xujiayao.discord_mc_chat.Constants.YAML_MAPPER;
/**
@ -53,4 +58,24 @@ public class JsonUtils {
return YAML_MAPPER.readValue(inputStream, new TypeReference<>() {
});
}
/**
* Reads a specific statistic from a player's stats JSON file.
*
* @param path The path to the JSON file
* @param type The stat type (e.g., "minecraft:custom")
* @param stat The stat name (e.g., "minecraft:deaths")
* @return The stat value, or 0 if not found
*/
public static int getStat(Path path, String type, String stat) {
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
JsonNode root = JSON_MAPPER.readTree(reader);
JsonNode typeNode = root.path("stats").path(type);
if (!typeNode.isMissingNode() && typeNode.has(stat)) {
return typeNode.path(stat).asInt();
}
} catch (Exception ignored) {
}
return 0;
}
}

View file

@ -94,6 +94,17 @@ commands:
saved_to_cache: "Log file saved to: {}"
save_failed: "Failed to save log file to cache: {}"
terminal_not_supported: "Retrieving log files in terminal is not supported. Please access the \"./logs\" directory directly."
stats:
description: "View Minecraft statistics"
args_desc:
type: "Stat type"
stat: "Stat name"
server_not_ready: "Minecraft Server is not ready yet."
dir_not_found: "Stats directory not found."
read_failed: "Failed to read stats: {}"
no_stats: "No stats found for {0} {1}."
stats: "Stats"
colon: ": "
main:
init:

View file

@ -94,6 +94,17 @@ commands:
saved_to_cache: "日志文件已保存到:{}"
save_failed: "保存日志文件到缓存失败:{}"
terminal_not_supported: "在终端中获取日志文件不受支持。请直接访问 \"./logs\" 目录。"
stats:
description: "查看 Minecraft 统计数据"
args_desc:
type: "统计数据类型"
stat: "统计数据名称"
server_not_ready: "Minecraft 服务器尚未准备就绪。"
dir_not_found: "未找到统计数据目录。"
read_failed: "读取统计数据失败:{}"
no_stats: "未找到关于 {0} {1} 的统计数据。"
stats: "统计数据"
colon: ""
main:
init:

View file

@ -5,8 +5,17 @@ import com.xujiayao.discord_mc_chat.commands.CommandManager;
import com.xujiayao.discord_mc_chat.commands.LocalCommandSender;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.commands.arguments.ResourceLocationArgument;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.stats.StatType;
import java.util.Optional;
import static net.minecraft.commands.Commands.argument;
import static net.minecraft.commands.Commands.literal;
/**
@ -46,11 +55,36 @@ public class MinecraftCommands {
CommandManager.execute(new MinecraftCommandSender(ctx.getSource()), "reload");
return 1;
});
var stats = literal("stats")
.requires(source -> source.hasPermission(ConfigManager.getInt("command_permission_levels.stats", 0)))
.then(argument("type", ResourceLocationArgument.id())
.suggests((ctx, builder) -> SharedSuggestionProvider.suggestResource(
BuiltInRegistries.STAT_TYPE.keySet(), builder))
.then(argument("stat", ResourceLocationArgument.id())
.suggests((ctx, builder) -> {
try {
ResourceLocation typeLoc = ctx.getArgument("type", ResourceLocation.class);
Optional<Holder.Reference<StatType<?>>> optional = BuiltInRegistries.STAT_TYPE.get(typeLoc);
if (optional.isPresent()) {
return SharedSuggestionProvider.suggestResource(optional.get().value().getRegistry().keySet(), builder);
}
} catch (Exception ignored) {
}
return builder.buildFuture();
})
.executes(ctx -> {
ResourceLocation typeLoc = ctx.getArgument("type", ResourceLocation.class);
ResourceLocation statLoc = ctx.getArgument("stat", ResourceLocation.class);
CommandManager.execute(new MinecraftCommandSender(ctx.getSource()), "stats", typeLoc.toString(), statLoc.toString());
return 1;
})));
dispatcher.register(root
.then(help)
.then(info)
.then(reload));
.then(reload)
.then(stats));
}
/**

View file

@ -1,6 +1,7 @@
package com.xujiayao.discord_mc_chat.minecraft.events;
import com.xujiayao.discord_mc_chat.DMCC;
import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand;
import com.xujiayao.discord_mc_chat.minecraft.commands.MinecraftCommands;
import com.xujiayao.discord_mc_chat.minecraft.translations.TranslationManager;
import com.xujiayao.discord_mc_chat.network.NetworkManager;
@ -11,15 +12,26 @@ import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.events.EventManager;
import net.minecraft.advancements.DisplayInfo;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.ServerTickRateManager;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.players.NameAndId;
import net.minecraft.stats.StatType;
import net.minecraft.util.TimeUtil;
import net.minecraft.world.level.GameRules;
import net.minecraft.world.level.storage.LevelResource;
import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
@ -34,6 +46,51 @@ public class MinecraftEventHandler {
*/
public static void init() {
EventManager.register(MinecraftEvents.ServerStarted.class, event -> {
StatsCommand.setProvider(new StatsCommand.StatsProvider() {
@Override
public void saveAll() {
event.minecraftServer().getPlayerList().saveAll();
}
@Override
public Path getStatsDirectory() {
return event.minecraftServer().getWorldPath(LevelResource.PLAYER_STATS_DIR);
}
@Override
public String getPlayerName(UUID uuid) {
return event.minecraftServer().services().nameToIdCache()
.get(uuid)
.map(NameAndId::name)
.orElse(null);
}
@Override
public List<String> getStatTypes() {
List<String> types = new ArrayList<>();
for (ResourceLocation loc : BuiltInRegistries.STAT_TYPE.keySet()) {
types.add(loc.toString());
}
return types;
}
@Override
public List<String> getStatNames(String typeStr) {
List<String> stats = new ArrayList<>();
try {
ResourceLocation typeLoc = ResourceLocation.parse(typeStr);
Optional<Holder.Reference<StatType<?>>> optional = BuiltInRegistries.STAT_TYPE.get(typeLoc);
if (optional.isPresent()) {
for (ResourceLocation loc : optional.get().value().getRegistry().keySet()) {
stats.add(loc.toString());
}
}
} catch (Exception ignored) {
}
return stats;
}
});
Map<String, String> placeholders = Map.of();
NetworkManager.sendPacketToServer(new MinecraftEventPacket(MinecraftEventPacket.MessageType.SERVER_STARTED, placeholders));