diff --git a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java index 6ba1e16..e3ae66c 100644 --- a/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java +++ b/src/main/java/net/tfminecraft/tfmccore/tfmc/TfmcCommand.java @@ -28,6 +28,7 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.tree.CommandNode; import com.mojang.brigadier.tree.LiteralCommandNode; import io.papermc.paper.command.brigadier.CommandSourceStack; @@ -148,7 +149,39 @@ public LiteralCommandNode build() { return player(p -> poster(p, name)).run(context); }))); - return root.build(); + LiteralCommandNode tree = root.build(); + // ConditionalEvents receives PlayerCommandPreprocessEvent before Brigadier executes. + // These paths make its commands visible to the client without running them twice. + for (String path : config.lines("conditional-events.commands")) { + if (!path.matches("[a-z0-9_+-]+(?: [a-z0-9_+-]+)*(?: )?")) { + LOGGER.warning("Invalid conditional-events command path: " + path); + continue; + } + CommandNode parent = tree; + String[] parts = path.split(" "); + for (int i = 0; i < parts.length; i++) { + CommandNode child = parent.getChild(parts[i]); + if (child == null) { + if (parts[i].equals("")) { + parent.addChild(Commands.argument("args", StringArgumentType.greedyString()) + .executes(context -> Command.SINGLE_SUCCESS).build()); + break; + } + var node = Commands.literal(parts[i]); + if (i == parts.length - 1) { + node.executes(context -> Command.SINGLE_SUCCESS); + } + child = node.build(); + parent.addChild(child); + } else if (i == parts.length - 1 && child.getCommand() == null) { + // Merge only the endpoint; retain existing permission requirements. + parent.addChild(Commands.literal(parts[i]) + .executes(context -> Command.SINGLE_SUCCESS).build()); + } + parent = child; + } + } + return tree; } private void tips(Player player, boolean disable) { diff --git a/src/main/resources/tfmc.yml b/src/main/resources/tfmc.yml index ec6082c..b58bf21 100644 --- a/src/main/resources/tfmc.yml +++ b/src/main/resources/tfmc.yml @@ -8,6 +8,14 @@ messages: cooldown: "You need to wait before using this again." failed: "Something went wrong. Please try again or open a Ticket." +# Literal paths after /tfmc handled by ConditionalEvents on this server, e.g. +# ["calendar", "kit iron", "kit iron remove"]. Restart after changing +# this list. CE still owns execution, permissions, conditions and cooldowns. +# Existing TFMCCore command handlers and permission gates are preserved. +# A final accepts free-form arguments, e.g. "roll ". +conditional-events: + commands: [] + tips: # Players holding this permission (in the current server context) do not receive chat tips. permission: tips.off diff --git a/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java b/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java index 6f11899..a46d08d 100644 --- a/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java +++ b/src/test/java/net/tfminecraft/tfmccore/tfmc/TfmcCommandTest.java @@ -92,6 +92,48 @@ public int nextInt(int bound) { dispatcher.getRoot().addChild(command.build()); } + @Test + void conditionalEventsPathsParseAndCompleteWithoutRunningCoreActions() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/tfmc.yml")); + config.loadFromString(yaml.replace("commands: []", "commands: [kit iron remove, kit iron, archaeology clear, masks clear, ban, pack, promote, roll strength, 'roll ']")); + dispatcher = new CommandDispatcher<>(); + dispatcher.getRoot().addChild(command.build()); + + TestPlayer steve = player("Steve"); + assertTrue(visible(steve).contains("kit")); + assertFalse(visible(steve).contains("ban")); + assertFalse(visible(steve).contains("promote")); + assertEquals(List.of("iron"), visible(steve, "kit")); + assertEquals(List.of("remove"), visible(steve, "kit", "iron")); + run(steve, "tfmc kit iron"); + run(steve, "tfmc kit iron remove"); + run(steve, "tfmc archaeology clear"); + run(steve, "tfmc masks clear"); + run(steve, "tfmc roll 20 +3"); + run(steve, "tfmc roll strength"); + assertTrue(console.isEmpty()); + assertTrue(given.isEmpty()); + assertTrue(steve.messages.isEmpty()); + run(steve, "tfmc pack"); + assertEquals(1, packsSent); + assertThrows(CommandSyntaxException.class, () -> run(steve, "tfmc kit gold")); + } + + @Test + void invalidConditionalEventsPathsAreSkipped() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/tfmc.yml")); + config.loadFromString(yaml.replace("commands: []", "commands: ['Kit Iron', 'kit iron', '', calendar]")); + dispatcher = new CommandDispatcher<>(); + dispatcher.getRoot().addChild(command.build()); + + TestPlayer steve = player("Steve"); + assertTrue(visible(steve).contains("calendar")); + assertFalse(visible(steve).contains("kit")); + assertFalse(visible(steve).contains("args")); + run(steve, "tfmc calendar"); + assertTrue(steve.messages.isEmpty()); + } + @Test void playersSeeOnlyPublicSubcommands() { assertEquals(List.of("tips", "pack", "booster", "drinks", "patreon", "statues", "patterns", "masks", "map", "date", "poster"),