Fix compilation errors, NPE, help visibility, auto-complete, click-to-copy, file path rename

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-10 13:42:26 +00:00 committed by Jason Xu
parent 04874111ed
commit 1945cf2e19
9 changed files with 93 additions and 28 deletions

View file

@ -61,8 +61,8 @@ DMCC 所有运行模式都基于一个统一的通信模型,该模型包含两
- **一个 Discord 账户可关联多个 Minecraft 账户**(方便玩家管理大号与小号)。
- **一个 Minecraft 账户只能关联一个 Discord 账户**(确保游戏内身份的绝对唯一性)。
- **数据持久化**: 绑定关系作为永久数据存储在 `Server` 端的 `account_linking/linked_accounts.json` 中,以 Discord ID 为主键。
- **存储约束**: `linked_accounts.json` 中仅存储绑定关系最小必要字段Discord ID、Minecraft UUID、添加时间**不得额外存储**
- **数据持久化**: 绑定关系作为永久数据存储在 `Server` 端的 `account_linking/links.json` 中,以 Discord ID 为主键。
- **存储约束**: `links.json` 中仅存储绑定关系最小必要字段Discord ID、Minecraft UUID、添加时间**不得额外存储**
Discord 用户名或 Minecraft 玩家名;显示名称在查询时实时解析。
### 4.2 安全绑定工作流 (严格的 MC 优先原则)
@ -75,7 +75,7 @@ DMCC 所有运行模式都基于一个统一的通信模型,该模型包含两
- 若该玩家存在未过期验证码,返回同一验证码并将过期时间重置为“当前时间 + 5 分钟”;
- 若验证码已过期或不存在,生成并返回新验证码(同样 5 分钟有效)。
4. **确认所有权**: 玩家前往 Discord使用斜杠命令 `/link A7X9P2`
5. **完成绑定**: Server 验证代码有效后,将该 Discord ID 与 MC UUID 写入 `linked_accounts.json`,并使该验证码失效。
5. **完成绑定**: Server 验证代码有效后,将该 Discord ID 与 MC UUID 写入 `links.json`,并使该验证码失效。
### 4.3 解绑与查询工作流
@ -138,7 +138,7 @@ Discord 用户的身份将通过以下规则在 Server 端结算为一个具体
1. **全量同步原则**: 每次同步均执行“全量重算 + 全量应用”,而非增量补丁。
2. **强制覆盖原则**: DMCC 将重置 Minecraft 服务器当前 OP 列表,并依据 DMCC 配置中的映射规则重新分配。
3. **绑定缺失回退**: 若某玩家解除绑定后在 `linked_accounts.json` 中不再出现,则在下一次全量同步中该玩家 OP 等级将被重置为 0。
3. **绑定缺失回退**: 若某玩家解除绑定后在 `links.json` 中不再出现,则在下一次全量同步中该玩家 OP 等级将被重置为 0。
4. **关闭时不干预**: 若 `sync_op_level_to_minecraft=false`,解绑不触发 OP 回收,服务器维持原样。
5. **与原生 `/op` 的关系**: 开启后,管理员在游戏内使用原生命令 `/op` 手动授予的结果会在下一次 DMCC 全量同步时被覆盖,这是预期行为。

View file

@ -53,6 +53,19 @@ public interface Command {
return false;
}
/**
* Whether this command should appear in auto-complete suggestions for the execute command.
* <p>
* Commands that are only meaningful for specific sender types (e.g., link/unlink
* which require player or Discord context) should return false.
* This does NOT affect whether the command can be executed only its auto-complete visibility.
*
* @return true if this command should appear in auto-complete, false otherwise.
*/
default boolean isAutoCompletable() {
return true;
}
/**
* Whether this command should be visible in help listings for the given sender.
* <p>

View file

@ -68,6 +68,7 @@ public class CommandAutoCompleter {
CommandManager.getCommands().stream()
.filter(cmd -> cmd.name().startsWith(commandName))
.filter(Command::isAutoCompletable)
.sorted(Comparator.comparing(Command::name))
.forEach(cmd -> addCommandIfAuthorized(cmd, opLevel, suggestions));
@ -308,6 +309,9 @@ public class CommandAutoCompleter {
* @param suggestions The list to append to.
*/
private static void addCommandIfAuthorized(Command cmd, int opLevel, List<String> suggestions) {
if (!cmd.isAutoCompletable()) {
return;
}
int requiredOp = ConfigManager.getInt("command_permission_levels." + cmd.name(), 4);
if (opLevel >= requiredOp) {
StringBuilder builder = new StringBuilder();

View file

@ -7,6 +7,8 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkRequestPac
import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager;
import com.xujiayao.discord_mc_chat.server.linking.VerificationCodeManager;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.events.CoreEvents;
import com.xujiayao.discord_mc_chat.utils.events.EventManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
/**
@ -106,10 +108,15 @@ public class LinkCommand implements Command {
return I18nManager.getDmccTranslation("commands.link.description");
}
@Override
public boolean isAutoCompletable() {
return false;
}
@Override
public boolean isVisibleInHelp(CommandSender sender) {
// Always visible - link is available to players and Discord users
return true;
// Only show link in help for Minecraft players or Discord users
return (sender instanceof PlayerContextProvider) || (sender instanceof DiscordUserContextProvider);
}
@Override
@ -148,11 +155,13 @@ public class LinkCommand implements Command {
if (LinkedAccountManager.isMinecraftUuidLinked(uuid)) {
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(uuid);
String discordName = LinkedAccountManager.resolveDiscordName(discordId);
sender.reply(I18nManager.getDmccTranslation("commands.link.already_linked", discordName));
// Post event so Minecraft module can display with rich formatting
EventManager.post(new CoreEvents.LinkCodeResponseEvent(uuid, null, true, discordName));
return;
}
String code = VerificationCodeManager.generateOrRefreshCode(uuid, name);
sender.reply(I18nManager.getDmccTranslation("commands.link.code_generated", code));
// Post event so Minecraft module can display with click-to-copy
EventManager.post(new CoreEvents.LinkCodeResponseEvent(uuid, code, false, ""));
}
case "multi_server_client" -> {
// Send request to standalone server via network

View file

@ -33,10 +33,15 @@ public class UnlinkCommand implements Command {
return I18nManager.getDmccTranslation("commands.unlink.description");
}
@Override
public boolean isAutoCompletable() {
return false;
}
@Override
public boolean isVisibleInHelp(CommandSender sender) {
// Always visible - unlink is available to players and Discord users
return true;
// Only show unlink in help for Minecraft players or Discord users
return (sender instanceof LinkCommand.PlayerContextProvider) || (sender instanceof LinkCommand.DiscordUserContextProvider);
}
@Override
@ -57,6 +62,11 @@ public class UnlinkCommand implements Command {
String uuid = player.getPlayerUuid();
String name = player.getPlayerName();
if (uuid == null) {
sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_available"));
return;
}
switch (ModeManager.getMode()) {
case "single_server" -> {
// Look up the linked Discord ID before unlinking so we can show it

View file

@ -22,13 +22,13 @@ import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
* <p>
* One Discord account can link multiple Minecraft accounts (1:N).
* One Minecraft account can only link to one Discord account (N:1 uniqueness).
* Data is stored in {@code account_linking/linked_accounts.json} in the DMCC config directory.
* Data is stored in {@code account_linking/links.json} in the DMCC config directory.
*
* @author Xujiayao
*/
public class LinkedAccountManager {
private static final Path LINKS_FILE = Paths.get("./config/discord_mc_chat/account_linking/linked_accounts.json");
private static final Path LINKS_FILE = Paths.get("./config/discord_mc_chat/account_linking/links.json");
private static final ConcurrentHashMap<String, List<LinkEntry>> LINKED_ACCOUNTS = new ConcurrentHashMap<>();
@ -127,7 +127,7 @@ public class LinkedAccountManager {
* Clears all linked accounts from memory without writing to disk.
* <p>
* This intentionally does NOT save to disk, so users can manually edit
* {@code linked_accounts.json} while DMCC is running and reload to apply their changes.
* {@code links.json} while DMCC is running and reload to apply their changes.
* Any in-memory changes that were not yet persisted via {@link #save()} will be lost.
* In practice, all mutations (link/unlink) call {@link #save()} immediately,
* so no data is lost under normal operation.
@ -195,6 +195,10 @@ public class LinkedAccountManager {
* @return The Discord user ID that was unlinked from, or null if the UUID was not linked.
*/
public static synchronized String unlinkByMinecraftUuid(String minecraftUuid, String minecraftName) {
if (minecraftUuid == null) {
return null;
}
String discordId = UUID_TO_DISCORD.remove(minecraftUuid);
if (discordId == null) {
return null;

View file

@ -273,6 +273,7 @@ linking:
player_join:
not_linked: "Your Minecraft account is not linked to Discord. Use /dmcc link to get a verification code."
code_hint: "Use /link {} on Discord within 5 minutes to link your account."
click_to_copy: "Click to copy verification code"
minecraft:
translations:

View file

@ -273,6 +273,7 @@ linking:
player_join:
not_linked: "你的 Minecraft 账户尚未绑定 Discord。使用 /dmcc link 获取验证码。"
code_hint: "在 5 分钟内在 Discord 上使用 /link {} 来绑定你的账户。"
click_to_copy: "点击复制验证码"
minecraft:
translations:

View file

@ -23,16 +23,22 @@ import com.xujiayao.discord_mc_chat.utils.events.CoreEvents;
import com.xujiayao.discord_mc_chat.utils.events.EventManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import net.minecraft.advancements.DisplayInfo;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSource;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
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.server.players.PlayerList;
import net.minecraft.server.players.ServerOpList;
import net.minecraft.server.players.ServerOpListEntry;
import net.minecraft.stats.StatType;
import net.minecraft.util.TimeUtil;
import net.minecraft.world.level.GameRules;
@ -162,6 +168,7 @@ public class MinecraftEventHandler {
I18nManager.getDmccTranslation("linking.player_join.not_linked")));
sp.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("linking.player_join.code_hint", code)));
sp.sendSystemMessage(buildClickableCode(code));
}
}
case "multi_server_client" -> {
@ -369,7 +376,8 @@ public class MinecraftEventHandler {
I18nManager.getDmccTranslation("commands.link.already_linked", event.discordName())));
} else if (event.code() != null) {
player.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("linking.player_join.code_hint", event.code())));
I18nManager.getDmccTranslation("commands.link.code_generated", event.code())));
player.sendSystemMessage(buildClickableCode(event.code()));
}
}
} catch (Exception ignored) {
@ -405,17 +413,16 @@ public class MinecraftEventHandler {
serverInstance.execute(() -> {
try {
net.minecraft.server.players.PlayerList playerList = serverInstance.getPlayerList();
net.minecraft.server.players.ServerOpList opList = playerList.getOps();
PlayerList playerList = serverInstance.getPlayerList();
ServerOpList opList = playerList.getOps();
// Step 1: De-op all currently opped players
// Copy the list to avoid ConcurrentModificationException
List<net.minecraft.server.players.ServerOpListEntry> currentOps =
new ArrayList<>(opList.getEntries());
for (net.minecraft.server.players.ServerOpListEntry op : currentOps) {
com.mojang.authlib.GameProfile profile = op.getUser();
if (profile != null) {
playerList.deop(profile);
List<ServerOpListEntry> currentOps = new ArrayList<>(opList.getEntries());
for (ServerOpListEntry op : currentOps) {
NameAndId nameAndId = op.getUser();
if (nameAndId != null) {
playerList.deop(nameAndId);
}
}
@ -427,15 +434,15 @@ public class MinecraftEventHandler {
try {
UUID uuid = UUID.fromString(uuidStr);
com.mojang.authlib.GameProfile profile = serverInstance.getProfileCache()
.get(uuid).orElse(null);
if (profile == null) {
// Profile not in cache; skip this entry as we can't op without a valid profile
Optional<NameAndId> nameAndIdOpt = serverInstance.services().nameToIdCache().get(uuid);
if (nameAndIdOpt.isEmpty()) {
// Profile not in cache; skip this entry
continue;
}
NameAndId nameAndId = nameAndIdOpt.get();
// Add the OP entry with the exact desired level
opList.add(new net.minecraft.server.players.ServerOpListEntry(
profile, opLevel, opList.canBypassPlayerLimit(profile)));
opList.add(new ServerOpListEntry(
nameAndId, opLevel, opList.canBypassPlayerLimit(nameAndId)));
} catch (Exception ignored) {
}
}
@ -534,6 +541,22 @@ public class MinecraftEventHandler {
}
}
/**
* Builds a clickable Component for a verification code.
* When clicked, the code is copied to the player's clipboard.
*
* @param code The verification code.
* @return A clickable Component.
*/
private static Component buildClickableCode(String code) {
return Component.literal("[" + code + "]").withStyle(style -> style
.withClickEvent(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, code))
.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT,
Component.literal(I18nManager.getDmccTranslation("linking.player_join.click_to_copy"))))
.withColor(ChatFormatting.GREEN)
.withBold(true));
}
/**
* Builds an InfoResponsePacket containing real-time metrics of the Minecraft server.
*