From 198a2ce1c48661c64d3c97d6562a55286e3053fb Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:43:29 +0200 Subject: [PATCH 1/9] feat(protocol): add TAB_COMPLETE_PLAYER command and bump protocol to v11 First layer of the S7 tab-completion slice: the wire contract. Adds the TabCompletePlayer namespace (Command carrying the player UUID and raw command-line buffer; Response carrying the ordered completions with a defensive copy and null-to-empty normalization), registers it in the IAgentCommand @JsonSubTypes/permits and IAgentResponse permits, and bumps RuntimeProtocol.VERSION 10 -> 11 so framework and agent stay in lockstep. Unlike ExecutePlayerCommand, the command line is deliberately not stripped or rejected when blank: a trailing space selects argument completion, so the buffer is load-bearing and preserved verbatim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../lightkeeper/protocol/IAgentCommand.java | 4 +- .../lightkeeper/protocol/IAgentResponse.java | 1 + .../protocol/TabCompletePlayer.java | 77 ++++++++++ .../protocol/TabCompletePlayerTest.java | 137 ++++++++++++++++++ .../lightkeeper/runtime/RuntimeProtocol.java | 2 +- 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java create mode 100644 lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java index 2787fe8b..15eb0102 100644 --- a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java @@ -58,6 +58,7 @@ @JsonSubTypes.Type(value = CancelNextEvents.Command.class, name = "CANCEL_NEXT_EVENTS"), @JsonSubTypes.Type(value = PlayerChat.Command.class, name = "PLAYER_CHAT"), @JsonSubTypes.Type(value = QueryEntities.Command.class, name = "QUERY_ENTITIES"), + @JsonSubTypes.Type(value = TabCompletePlayer.Command.class, name = "TAB_COMPLETE_PLAYER"), }) public sealed interface IAgentCommand permits Handshake.Command, MainWorld.Command, NewWorld.Command, ExecuteCommand.Command, @@ -72,7 +73,8 @@ public sealed interface IAgentCommand GetPlayerChatComponents.Command, GetServerPlatform.Command, GetServerErrors.Command, ClearServerErrors.Command, MutatePlayerPermission.Command, HasPlayerPermission.Command, - CancelNextEvents.Command, PlayerChat.Command, QueryEntities.Command + CancelNextEvents.Command, PlayerChat.Command, QueryEntities.Command, + TabCompletePlayer.Command { /** * Correlation identifier matching the response's {@code requestId}. diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java index f6f1e113..bc556084 100644 --- a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java @@ -46,6 +46,7 @@ public sealed interface IAgentResponse RemovePlayer.Response, RightClickBlock.Response, SetBlock.Response, + TabCompletePlayer.Response, TeleportPlayer.Response, UnloadChunk.Response, UnregisterEventListener.Response, diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java new file mode 100644 index 00000000..497a2a96 --- /dev/null +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java @@ -0,0 +1,77 @@ +package nl.pim16aap2.lightkeeper.protocol; + +import java.util.List; +import java.util.UUID; + +/** + * Computes command tab-completions as the given synthetic player. + * + *

The completions are produced by the server's live {@code CommandMap#tabComplete} call, so they are + * permission-filtered for the requesting player exactly as a real client tab-complete would be. + */ +public final class TabCompletePlayer +{ + private TabCompletePlayer() + { + } + + /** + * Command record for {@code TAB_COMPLETE_PLAYER}. + * + * @param requestId + * Correlation identifier matching the response's {@code requestId}. + * @param uuid + * Unique identifier of the player whose permissions and context drive the completion. + * @param commandLine + * The raw command-line buffer to complete, optionally beginning with a leading slash. Surrounding + * whitespace is not stripped: a trailing space is semantically load-bearing here (for example + * {@code "gamemode "} completes the first argument, whereas {@code "gamemode"} completes the command + * name itself), so the buffer is preserved verbatim. + */ + public record Command( + String requestId, + UUID uuid, + String commandLine + ) implements IAgentCommand + { + /** + * Validates the command inputs. + * + *

Unlike {@link ExecutePlayerCommand.Command}, the {@code commandLine} is intentionally not rejected + * when blank and not stripped: a whitespace-only or trailing-whitespace buffer is a valid, + * meaningfully-different tab-complete input rather than a malformed command string. + */ + public Command + { + ProtocolPreconditions.requireNonBlank(requestId, "requestId"); + ProtocolPreconditions.requireNonNull(uuid, "uuid"); + ProtocolPreconditions.requireNonNull(commandLine, "commandLine"); + } + + @Override + public Class responseType() + { + return Response.class; + } + } + + /** + * Response record for {@code TAB_COMPLETE_PLAYER}. + * + * @param completions + * The tab-completion suggestions in server order. A {@code null} {@code CommandMap#tabComplete} result + * (unknown command, or a {@code TabCompleter} returning {@code null}) is normalized to an empty list. + */ + public record Response( + List completions + ) implements IAgentResponse + { + /** + * Defensively copies the completion list, defaulting a {@code null} to an empty list. + */ + public Response + { + completions = completions == null ? List.of() : List.copyOf(completions); + } + } +} diff --git a/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java b/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java new file mode 100644 index 00000000..de4c8bca --- /dev/null +++ b/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java @@ -0,0 +1,137 @@ +package nl.pim16aap2.lightkeeper.protocol; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TabCompletePlayerTest +{ + @Test + void command_shouldRejectBlankRequestId() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command(" ", uuid, "gamemode")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestId"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void command_shouldRejectNullUuid() + { + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command("request-1", null, "gamemode")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("uuid"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void command_shouldRejectNullCommandLine() + { + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command("request-1", UUID.randomUUID(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("commandLine"); + } + + @Test + void command_shouldPreserveTrailingWhitespaceWithoutStripping() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute — a trailing space selects argument completion and is semantically load-bearing + final TabCompletePlayer.Command command = new TabCompletePlayer.Command("request-1", uuid, "/gamemode "); + + // verify + assertThat(command.commandLine()).isEqualTo("/gamemode "); + } + + @Test + void command_shouldAllowBlankCommandLine() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute — unlike ExecutePlayerCommand, a whitespace-only buffer is a valid tab-complete input + final TabCompletePlayer.Command command = new TabCompletePlayer.Command("request-1", uuid, " "); + + // verify + assertThat(command.commandLine()).isEqualTo(" "); + } + + @Test + void response_shouldDefensivelyCopyCompletionsSoLaterMutationsDoNotLeak() + { + // setup + final List source = new ArrayList<>(List.of("help")); + final TabCompletePlayer.Response response = new TabCompletePlayer.Response(source); + + // execute + source.add("plugins"); + + // verify + assertThat(response.completions()).containsExactly("help"); + assertThatThrownBy(() -> response.completions().add("me")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify default-on-null. + void response_shouldDefaultNullCompletionsToEmptyList() + { + // setup + execute + final TabCompletePlayer.Response response = new TabCompletePlayer.Response(null); + + // verify + assertThat(response.completions()).isEmpty(); + } + + @Test + void command_shouldRoundTripThroughJson() + throws Exception + { + // setup + final ObjectMapper mapper = AgentProtocolMapper.create(); + final UUID uuid = UUID.fromString("00000000-0000-0000-0000-0000000000cc"); + final TabCompletePlayer.Command original = new TabCompletePlayer.Command("req-tc", uuid, "/lktestg"); + + // execute + final String json = mapper.writeValueAsString(original); + @SuppressWarnings("rawtypes") + final IAgentCommand deserialized = mapper.readValue(json, IAgentCommand.class); + + // verify + assertThat(deserialized).isInstanceOf(TabCompletePlayer.Command.class); + final TabCompletePlayer.Command result = (TabCompletePlayer.Command) deserialized; + assertThat(result.requestId()).isEqualTo("req-tc"); + assertThat(result.uuid()).isEqualTo(uuid); + assertThat(result.commandLine()).isEqualTo("/lktestg"); + } + + @Test + void response_shouldRoundTripThroughJson() + throws Exception + { + // setup + final ObjectMapper mapper = AgentProtocolMapper.create(); + final TabCompletePlayer.Response original = new TabCompletePlayer.Response(List.of("lktestgui", "lktestlocale")); + + // execute + final String json = mapper.writeValueAsString(original); + final TabCompletePlayer.Response result = mapper.readValue(json, TabCompletePlayer.Response.class); + + // verify + assertThat(result.completions()).containsExactly("lktestgui", "lktestlocale"); + } +} diff --git a/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java b/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java index 542e48eb..6830c7c8 100644 --- a/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java +++ b/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java @@ -20,7 +20,7 @@ public final class RuntimeProtocol * in a backward-incompatible way. Both the framework and the agent must agree on this value; * a mismatch causes an {@code HANDSHAKE} failure. */ - public static final int VERSION = 10; + public static final int VERSION = 11; /** * Minecraft server version supported by this LightKeeper build. From d04c9bf26d3f31b498d1ecf0f614dc7c26334cbc Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:43:45 +0200 Subject: [PATCH 2/9] feat(agent): compute player completions via cached reflective CommandMap Second layer: the in-server handler for TAB_COMPLETE_PLAYER. Resolves the synthetic player from the store like its siblings, strips at most one leading slash (mirroring handleExecutePlayerCommand, since the command map matches names without a slash and its args branch does a raw lookup), and calls CommandMap#tabComplete on the main thread, mapping a null result to an empty list. getCommandMap() is not on the org.bukkit.Server interface on either distro (Spigot returns a versioned SimpleCommandMap, Paper an unversioned CommandMap), so it is reached through one cached reflective lookup off the live server; the CommandMap it returns is plain spigot-api, so no per-version NMS module is needed. A missing/renamed accessor fails loudly. Wires the exhaustive dispatcher switch arm. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../agent/spigot/AgentPlayerActions.java | 90 +++++++++++++ .../agent/spigot/AgentRequestDispatcher.java | 2 + .../agent/spigot/AgentPlayerActionsTest.java | 118 ++++++++++++++++++ 3 files changed, 210 insertions(+) diff --git a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java index d041f998..c641376e 100644 --- a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java +++ b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java @@ -14,6 +14,7 @@ import nl.pim16aap2.lightkeeper.protocol.PlayerChat; import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -21,6 +22,7 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandMap; import org.bukkit.entity.Player; import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; @@ -35,6 +37,7 @@ import org.jspecify.annotations.Nullable; import java.time.Duration; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -73,6 +76,15 @@ final class AgentPlayerActions * connection; their removal must run the real disconnect flow instead of the legacy player-list removal. */ private final Set fullLoginPlayerIds = ConcurrentHashMap.newKeySet(); + /** + * The server's {@link CommandMap}, resolved lazily and cached on first tab-complete. + * + *

{@code CraftServer#getCommandMap()} is not declared on the {@code org.bukkit.Server} interface on either + * distribution (Spigot returns a versioned {@code SimpleCommandMap}, Paper an unversioned {@code CommandMap}), + * so it can only be reached reflectively off the live server instance; the {@code CommandMap} interface it + * returns is regular spigot-api, so no per-version NMS module is required. + */ + private @Nullable CommandMap cachedCommandMap; /** * @param plugin @@ -485,6 +497,84 @@ ExecutePlayerCommand.Response handleExecutePlayerCommand(ExecutePlayerCommand.Co return new ExecutePlayerCommand.Response(dispatched); } + /** + * Handles {@code TAB_COMPLETE_PLAYER} by computing command tab-completions in the player's context. + * + *

Any single leading slash is stripped before the buffer reaches {@code CommandMap#tabComplete}, mirroring + * {@link #handleExecutePlayerCommand}: the map matches command names without a slash, and its args-completion + * branch does a raw {@code knownCommands.get} lookup that a leading slash would defeat. The remaining + * whitespace of the buffer is preserved verbatim, since a trailing space selects argument completion rather + * than command-name completion. A {@code null} result (unknown command, or a plugin {@code TabCompleter} + * returning {@code null}) is normalized to an empty list. Completions are permission-filtered for the + * synthetic player exactly as they would be for a real client. + * + *

No {@code TabCompleteEvent}/{@code AsyncTabCompleteEvent} is fired: this path exercises registered + * commands and {@code TabCompleter}s, not plugins that customize completions purely through those events. + * + * @param command + * Typed command carrying the player UUID and the raw command-line buffer. + * @return Response containing the ordered tab-completion suggestions. + * + * @throws Exception + * Propagates main-thread execution failures, and {@link IllegalStateException} when the server + * {@code CommandMap} cannot be resolved. + */ + TabCompletePlayer.Response handleTabCompletePlayer(TabCompletePlayer.Command command) + throws Exception + { + final UUID uuid = command.uuid(); + final String rawCommandLine = command.commandLine(); + final String commandLine = rawCommandLine.startsWith("/") ? rawCommandLine.substring(1) : rawCommandLine; + + final List completions = mainThreadExecutor.callOnMainThread(() -> + { + final Player player = playerStore.getRequiredPlayer(uuid); + final CommandMap commandMap = resolveCommandMap(); + final List result = commandMap.tabComplete(player, commandLine); + return result == null ? List.of() : List.copyOf(result); + }); + + return new TabCompletePlayer.Response(completions); + } + + /** + * Resolves the server's {@link CommandMap}, caching it after the first lookup. + * + *

The accessor lives on the concrete {@code CraftServer} (versioned on Spigot, unversioned on Paper) and + * not on the {@code org.bukkit.Server} interface the agent compiles against, so it is reached reflectively. + * A missing or renamed accessor on a future fork fails loudly here on first use instead of silently + * returning empty completions forever. + * + * @return The live server command map. + * @throws IllegalStateException + * When the accessor is absent, inaccessible, or returns something other than a {@link CommandMap}. + */ + private CommandMap resolveCommandMap() + { + final CommandMap resolved = cachedCommandMap; + if (resolved != null) + return resolved; + + try + { + final Object server = Bukkit.getServer(); + final Object rawCommandMap = server.getClass().getMethod("getCommandMap").invoke(server); + if (!(rawCommandMap instanceof CommandMap commandMap)) + throw new IllegalStateException( + "Server#getCommandMap() returned %s, which is not a CommandMap." + .formatted(rawCommandMap == null ? "null" : rawCommandMap.getClass().getName())); + cachedCommandMap = commandMap; + return commandMap; + } + catch (ReflectiveOperationException exception) + { + throw new IllegalStateException( + "Failed to resolve the server CommandMap via reflection; tab-completion is unavailable on this " + + "server distribution.", + exception); + } + } + /** * Handles {@code PLACE_PLAYER_BLOCK} by setting the target block type in the player's current world. * diff --git a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java index 5f594e7b..4e702446 100644 --- a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java +++ b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java @@ -41,6 +41,7 @@ import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; import nl.pim16aap2.lightkeeper.protocol.SetBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import nl.pim16aap2.lightkeeper.protocol.UnloadChunk; import nl.pim16aap2.lightkeeper.protocol.UnregisterEventListener; @@ -281,6 +282,7 @@ private RequestDispatchResult dispatchCommand(IAgentCommand command, boolean han case CancelNextEvents.Command c -> handle(c, eventActions::handleCancelNextEvents); case PlayerChat.Command c -> handle(c, playerActions::handlePlayerChat); case QueryEntities.Command c -> handle(c, worldActions::handleQueryEntities); + case TabCompletePlayer.Command c -> handle(c, playerActions::handleTabCompletePlayer); case Handshake.Command ignored -> throw new IllegalStateException("Unreachable HANDSHAKE dispatch branch."); }; diff --git a/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java b/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java index 56e2f36e..100acfe0 100644 --- a/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java +++ b/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java @@ -22,12 +22,15 @@ import nl.pim16aap2.lightkeeper.protocol.PlayerChat; import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandMap; import org.bukkit.entity.Player; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; @@ -107,6 +110,111 @@ void handleExecutePlayerCommand_shouldFallBackToServerDispatcherWhenPerformComma assertThat(response.dispatched()).isTrue(); } + @Test + void handleTabCompletePlayer_shouldStripLeadingSlashAndReturnCompletions() + throws Exception + { + // setup + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "lktestg")).thenReturn(List.of("/lktestgui")); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc", uuid, "/lktestg"); + + // execute + final TabCompletePlayer.Response response; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + response = fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify — the agent strips the single leading slash before the CommandMap lookup + assertThat(response.completions()).containsExactly("/lktestgui"); + verify(commandMap).tabComplete(player, "lktestg"); + } + + @Test + void handleTabCompletePlayer_shouldReturnEmptyListWhenCommandMapReturnsNull() + throws Exception + { + // setup — an unknown command's args-completion branch yields a null result that must not propagate + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "totallyNotACommand arg")).thenReturn(null); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-null", uuid, "/totallyNotACommand arg"); + + // execute + final TabCompletePlayer.Response response; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + response = fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify + assertThat(response.completions()).isEmpty(); + } + + @Test + void handleTabCompletePlayer_shouldPreserveTrailingSpaceAfterStrippingSlash() + throws Exception + { + // setup — a trailing space selects argument completion and must survive the slash strip + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "gamemode ")).thenReturn(List.of()); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-space", uuid, "/gamemode "); + + // execute + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify + verify(commandMap).tabComplete(player, "gamemode "); + } + + @Test + void handleTabCompletePlayer_shouldPropagateWhenPlayerUnknown() + { + // setup + final AgentPlayerActions playerActions = createPlayerActions(); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-unknown", UUID.randomUUID(), "/help"); + + // execute + verify + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThatThrownBy(() -> playerActions.handleTabCompletePlayer(command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not registered"); + } + } + @Test void handleLeftClickBlock_shouldFirePlayerInteractEvent() throws Exception @@ -903,4 +1011,14 @@ private record PlayerActionsFixture( IBotPlayerNmsAdapter nmsAdapter) { } + + /** + * Mockable stand-in for the concrete {@code CraftServer} whose {@code getCommandMap()} accessor lives off the + * {@code org.bukkit.Server} interface. The agent reaches it reflectively; mocking this subtype lets + * {@code server.getClass().getMethod("getCommandMap")} resolve to a stubbed {@link CommandMap}. + */ + interface TabCompleteServer extends Server + { + CommandMap getCommandMap(); + } } From eba1f379cc9eeec8233ab918cd5b6af839b944ac Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:44:00 +0200 Subject: [PATCH 3/9] feat(framework): add PlayerHandle.tabComplete and chat-click helpers Third layer: the test-facing API. Wires tabComplete through the established gateway pattern (IFrameworkGatewayView -> UdsAgentClient -> DefaultLightkeeperFramework) and exposes PlayerHandle.tabComplete(String): the buffer is validated non-blank but never trimmed, so a trailing space still selects argument completion, and a leading slash is accepted. Adds the chat-click convenience with no new protocol command: on ChatComponentSnapshot, clickRunCommand()/clickSuggestedCommand() parse the component tree (Jackson, already a framework dependency) for a click_event of the requested action (findValues walks 'extra' children; per-action matching so run never returns a suggest payload; malformed JSON degrades to empty). On PlayerHandle, clickChatComponent(...) extracts the run_command and reuses executeCommand, throwing when there is no run_command click. Parsing keys on the modern snake_case click_event, never the stale clickEvent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../framework/ChatComponentSnapshot.java | 80 +++++++++++++ .../framework/IFrameworkGatewayView.java | 7 ++ .../lightkeeper/framework/PlayerHandle.java | 39 +++++++ .../internal/DefaultLightkeeperFramework.java | 13 +++ .../framework/internal/UdsAgentClient.java | 8 ++ .../framework/ChatComponentSnapshotTest.java | 110 ++++++++++++++++++ .../framework/PlayerHandleTest.java | 43 +++++++ ...efaultLightkeeperFrameworkGatewayTest.java | 77 ++++++++++++ .../internal/UdsAgentClientTest.java | 22 ++++ 9 files changed, 399 insertions(+) create mode 100644 lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java index 1d52582b..87f4dce7 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java @@ -1,8 +1,18 @@ package nl.pim16aap2.lightkeeper.framework; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.Optional; + /** * Snapshot of a chat component. * + *

The {@link #json()} is the server's native component-codec output, verbatim from the outbound packet. On + * Minecraft 1.21.5+ that codec spells the click field {@code click_event} (snake_case), so the click helpers on + * this record key on {@code click_event}, not the pre-1.21.5 {@code clickEvent}. + * * @param json * The raw JSON representation of the component. */ @@ -10,4 +20,74 @@ public record ChatComponentSnapshot( String json ) { + /** + * Shared JSON mapper for click-event inspection, matching the shared-mapper convention used elsewhere in the + * framework instead of allocating a fresh mapper per call. + */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * The {@code click_event} action name of a component that runs a command when clicked. + */ + private static final String RUN_COMMAND_ACTION = "run_command"; + /** + * The {@code click_event} action name of a component that fills the chat box with a command when clicked. + */ + private static final String SUGGEST_COMMAND_ACTION = "suggest_command"; + + /** + * Extracts the command of the first {@code run_command} click event in this component's tree. + * + * @return The command to run (typically leading-slash prefixed), or empty when this component carries no + * {@code run_command} click event or its JSON cannot be parsed. + */ + public Optional clickRunCommand() + { + return findClickCommand(RUN_COMMAND_ACTION); + } + + /** + * Extracts the command of the first {@code suggest_command} click event in this component's tree. + * + *

The payload is returned verbatim, including any trailing space (suggest-command payloads often end with + * a space to prime the next argument), so it is never trimmed. + * + * @return The command to suggest, or empty when this component carries no {@code suggest_command} click event + * or its JSON cannot be parsed. + */ + public Optional clickSuggestedCommand() + { + return findClickCommand(SUGGEST_COMMAND_ACTION); + } + + /** + * Finds the command payload of the first click event matching the requested action anywhere in the tree. + * + * @param action + * The {@code click_event} action name to match. + * @return The matching click event's command payload, or empty when none matches. + */ + private Optional findClickCommand(String action) + { + try + { + final JsonNode root = OBJECT_MAPPER.readTree(json); + // findValues walks the whole tree, so a click_event nested in an 'extra' child is found, not only a + // root-level one. Matching per action (rather than "the first click_event found") means + // clickRunCommand() never returns a suggest_command payload when a single message carries both + // (for example a paginated [<] [1/3] [>] control). + for (final JsonNode clickEvent : root.findValues("click_event")) + { + if (action.equals(clickEvent.path("action").asString("")) && clickEvent.hasNonNull("command")) + return Optional.of(clickEvent.get("command").asString()); + } + return Optional.empty(); + } + catch (JacksonException malformed) + { + // Malformed component JSON degrades to "no click command", matching the framework's existing + // error-tolerance for this data (see PlayerHandleAssert's parse-failure handling). + return Optional.empty(); + } + } } diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java index 991a975b..4c16ffa6 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java @@ -40,6 +40,13 @@ public interface IFrameworkGatewayView */ void executePlayerCommand(UUID playerId, String command); + /** + * Computes command tab-completions as a synthetic player. + * + * @return The tab-completion suggestions in server order, permission-filtered for the player. + */ + List tabComplete(UUID playerId, String commandLine); + /** * Grants a permission node to a synthetic player by setting it to {@code true} on the player's attachment. */ diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java index 966fa1f3..a51aee79 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java @@ -71,6 +71,24 @@ public PlayerHandle executeCommand(String command) return this; } + /** + * Computes command tab-completions for this player as if they had typed the given command-line buffer. + * + *

The suggestions come from the live server command map and are permission-filtered for this player, so a + * bot that lacks a command's permission never sees it completed. A leading slash is accepted and normalized + * away agent-side, so {@code "/gamemode"} and {@code "gamemode"} behave identically; the buffer is otherwise + * passed through unmodified, so a trailing space (which selects argument completion rather than command-name + * completion) is preserved. + * + * @param commandLine + * The command-line buffer to complete; must not be blank. + * @return The tab-completion suggestions in server order; empty for an unknown command. + */ + public List tabComplete(String commandLine) + { + return frameworkGateway.tabComplete(uniqueId, commandLine); + } + /** * Teleports this player to target coordinates in a world. * @@ -419,6 +437,27 @@ public List chatComponents() return frameworkGateway.playerChatComponents(uniqueId); } + /** + * Clicks the {@code run_command} action of a captured chat component by extracting its command and executing + * it as this player, exactly as a real player clicking the message would. + * + *

Reuses the same execution path as {@link #executeCommand(String)} (no new protocol command), so the + * extracted command is dispatched in this player's context with a leading slash normalized away. + * + * @param component + * The captured chat component to click; must carry a {@code run_command} click event. + * @return This handle for fluent chaining. + * @throws IllegalArgumentException + * When the component carries no {@code run_command} click event to execute. + */ + public PlayerHandle clickChatComponent(ChatComponentSnapshot component) + { + Objects.requireNonNull(component, "component may not be null."); + final String command = component.clickRunCommand().orElseThrow(() -> new IllegalArgumentException( + "Chat component carries no run_command click event to execute: " + component.json())); + return executeCommand(command); + } + /** * Gets all recorded messages flattened into a single multi-line string. * diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java index 208fbced..696cae68 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java @@ -266,6 +266,19 @@ public void executePlayerCommand(UUID playerId, String command) agentClient.executePlayerCommand(playerId, trimmedCommand); } + @Override + public List tabComplete(UUID playerId, String commandLine) + { + ensureOpen(); + Objects.requireNonNull(playerId, "playerId may not be null."); + Objects.requireNonNull(commandLine, "commandLine may not be null."); + // Deliberately NOT trimmed: a trailing space selects argument completion over command-name completion, + // and the agent strips at most one leading slash. Only a fully-blank buffer is rejected. + if (commandLine.isBlank()) + throw new IllegalArgumentException("commandLine may not be blank."); + return agentClient.tabComplete(playerId, commandLine); + } + @Override public void grantPermission(UUID playerId, String permission) { diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java index 7be0c16c..d4d7acb4 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java @@ -45,6 +45,7 @@ import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; import nl.pim16aap2.lightkeeper.protocol.SetBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import nl.pim16aap2.lightkeeper.protocol.UnloadChunk; import nl.pim16aap2.lightkeeper.protocol.UnregisterEventListener; @@ -239,6 +240,13 @@ void executePlayerCommand(UUID uuid, String command) send(cmd); } + List tabComplete(UUID uuid, String commandLine) + { + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command(nextRequestId(), uuid, commandLine); + return send(command).completions(); + } + void mutatePlayerPermission(UUID uuid, String permission, MutatePlayerPermission.Mode mode) { final MutatePlayerPermission.Command command = diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java new file mode 100644 index 00000000..ce3e8c10 --- /dev/null +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java @@ -0,0 +1,110 @@ +package nl.pim16aap2.lightkeeper.framework; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatComponentSnapshotTest +{ + @Test + void clickRunCommand_shouldExtractCommandFromRootClickEvent() + { + // setup — the 1.21.5+ wire spells the field click_event (snake_case) + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"click me\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa confirm\"}}"); + + // execute + final Optional command = component.clickRunCommand(); + + // verify + assertThat(command).contains("/aa confirm"); + } + + @Test + void clickRunCommand_shouldMatchClickEventNestedInExtraChild() + { + // setup — NMS serializes clickable text inside an 'extra' child rather than the root object + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"\",\"extra\":[{\"text\":\"click me\"," + + "\"click_event\":{\"action\":\"run_command\",\"command\":\"/lktestgui\"}}]}"); + + // execute + final Optional command = component.clickRunCommand(); + + // verify — a root-only lookup would miss this + assertThat(command).contains("/lktestgui"); + } + + @Test + void clickRunCommand_shouldReturnEmptyWhenComponentHasNoClickEvent() + { + // setup + final ChatComponentSnapshot component = new ChatComponentSnapshot("{\"text\":\"plain\"}"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickRunCommand_shouldReturnEmptyForSuggestCommandOnlyComponent() + { + // setup — per-action matching: a suggest_command click must not satisfy clickRunCommand() + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickRunCommand_shouldReturnEmptyForLegacyCamelCaseClickEventKey() + { + // setup — extraction keys on the modern click_event only; the pre-1.21.5 clickEvent is not the wire + // format on supported versions, so it is not mined for a command + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"click me\",\"clickEvent\":{\"action\":\"run_command\",\"value\":\"/aa confirm\"}}"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickRunCommand_shouldReturnEmptyForMalformedJson() + { + // setup — malformed component JSON degrades to empty rather than throwing + final ChatComponentSnapshot component = new ChatComponentSnapshot("{\"text\":\"broken\" BROKEN"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickSuggestedCommand_shouldExtractCommandAndPreserveTrailingSpace() + { + // setup — suggest-command payloads often end with a space to prime the next argument + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + final Optional command = component.clickSuggestedCommand(); + + // verify — the trailing space must survive intact + assertThat(command).contains("/aa create "); + } + + @Test + void clickRunCommand_shouldReturnItsOwnActionWhenRunAndSuggestCoexist() + { + // setup — a paginated control can carry both a run_command and a suggest_command run + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"\",\"extra\":[" + + "{\"text\":\"[<]\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/page \"}}," + + "{\"text\":\"[>]\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/page 2\"}}]}"); + + // execute + verify — each accessor returns its own action's payload, never the other's + assertThat(component.clickRunCommand()).contains("/page 2"); + assertThat(component.clickSuggestedCommand()).contains("/page "); + } +} diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java index 5f762839..507464fb 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java @@ -64,6 +64,49 @@ void executeCommand_shouldDelegateToGatewayAndReturnSelf() verify(frameworkGateway).executePlayerCommand(PLAYER_UUID, "time set day"); } + @Test + void tabComplete_shouldDelegateToGatewayAndReturnCompletions() + { + // setup + when(frameworkGateway.tabComplete(PLAYER_UUID, "/lktestg")) + .thenReturn(List.of("/lktestgui", "/lktestlocale")); + + // execute + final List completions = playerHandle.tabComplete("/lktestg"); + + // verify + assertThat(completions).containsExactly("/lktestgui", "/lktestlocale"); + verify(frameworkGateway).tabComplete(PLAYER_UUID, "/lktestg"); + } + + @Test + void clickChatComponent_shouldExtractRunCommandAndExecuteItAsThePlayer() + { + // setup — the modern 1.21.5+ wire spells the click field click_event (snake_case) + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"Confirm\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa confirm\"}}"); + + // execute + final PlayerHandle result = playerHandle.clickChatComponent(component); + + // verify + assertThat(result).isSameAs(playerHandle); + verify(frameworkGateway).executePlayerCommand(PLAYER_UUID, "/aa confirm"); + } + + @Test + void clickChatComponent_shouldThrowWhenComponentHasNoRunCommandClick() + { + // setup — a suggest_command click is not a run_command click and must be rejected + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"Type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + verify + assertThatThrownBy(() -> playerHandle.clickChatComponent(component)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("run_command"); + } + @Test void teleport_shouldDelegateToGatewayAndReturnSelf() { diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java index e6f6f5e0..3c164d62 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java @@ -89,6 +89,83 @@ void unsetPermission_shouldDelegateToAgentClientWithUnsetMode() verify(agentClient).mutatePlayerPermission(playerId, "lightkeeper.fly", MutatePlayerPermission.Mode.UNSET); } + @Test + void tabComplete_shouldDelegateToAgentClientAndReturnCompletions() + { + // setup + final UdsAgentClient agentClient = mock(UdsAgentClient.class); + final UUID playerId = UUID.randomUUID(); + when(agentClient.tabComplete(playerId, "/lktestg")).thenReturn(List.of("/lktestgui")); + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + agentClient, + new PlayerScopeRegistry() + ); + + // execute + final List completions = framework.tabComplete(playerId, "/lktestg"); + + // verify + assertThat(completions).containsExactly("/lktestgui"); + verify(agentClient).tabComplete(playerId, "/lktestg"); + } + + @Test + void tabComplete_shouldPassBufferThroughUnmodifiedPreservingTrailingSpace() + { + // setup + final UdsAgentClient agentClient = mock(UdsAgentClient.class); + final UUID playerId = UUID.randomUUID(); + when(agentClient.tabComplete(playerId, "/gamemode ")).thenReturn(List.of()); + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + agentClient, + new PlayerScopeRegistry() + ); + + // execute — a trailing space (argument completion) is never trimmed + framework.tabComplete(playerId, "/gamemode "); + + // verify + verify(agentClient).tabComplete(playerId, "/gamemode "); + } + + @Test + void tabComplete_shouldThrowExceptionWhenCommandLineIsBlank() + { + // setup + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + mock(UdsAgentClient.class), + new PlayerScopeRegistry() + ); + + // execute + verify + assertThatThrownBy(() -> framework.tabComplete(UUID.randomUUID(), " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blank"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void tabComplete_shouldThrowNullPointerExceptionWhenCommandLineIsNull() + { + // setup + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + mock(UdsAgentClient.class), + new PlayerScopeRegistry() + ); + + // execute + verify + assertThatThrownBy(() -> framework.tabComplete(UUID.randomUUID(), null)) + .isInstanceOf(NullPointerException.class); + } + @Test void grantPermission_shouldThrowExceptionWhenPermissionIsBlank() { diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java index 277f3f2b..2d8e2e57 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java @@ -727,6 +727,28 @@ void playerChatComponents_shouldParseComponentSnapshots(@TempDir Path tempDirect } } + @Test + void tabComplete_shouldReturnCompletionsFromResponseAndPreserveBuffer(@TempDir Path tempDirectory) + throws Exception + { + // setup + final Path socketPath = tempDirectory.resolve("tab-complete.sock"); + final String responseJson = + "{\"requestId\":\"1\",\"success\":true,\"completions\":[\"/lktestgui\",\"/lktestlocale\"]}"; + try (AgentSocketServer server = AgentSocketServer.start(socketPath, responseJson); + UdsAgentClient client = new UdsAgentClient(socketPath, Duration.ofSeconds(3))) + { + // execute — the trailing slash and space of the buffer must reach the wire unmodified + final List completions = client.tabComplete(PLAYER_ID, "/lktestg "); + + // verify + assertThat(completions).containsExactly("/lktestgui", "/lktestlocale"); + assertThat(server.capturedRequest()) + .contains("\"action\":\"TAB_COMPLETE_PLAYER\"") + .contains("\"commandLine\":\"/lktestg \""); + } + } + @Test void mutatePlayerPermission_shouldSendGrantModeInRequest(@TempDir Path tempDirectory) throws Exception From 291c82a6baf7b0175a701e1401e1dea13f699b05 Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:44:14 +0200 Subject: [PATCH 4/9] fix(nms): capture chat components through obfuscated Spigot accessors Chat-component capture was silently dead on Spigot: the outbound ClientboundSystemChatPacket exposes its component through the remapped record accessor `IChatBaseComponent b()`, whose single-letter name matched none of isSafeComponentAccessor's name heuristics (get/is/component/message/ content/title), so the walker never followed it. Paper exposes the same field as content() and worked. The bot therefore captured the message text but not the component JSON, leaving the entire chat-click feature (and hasClickableChatText) non-functional on Spigot. Key the accessor decision on the return type first: any zero-arg accessor returning a net.minecraft.network.chat.* / *Component type is followed regardless of its name, so components are captured identically on Spigot and Paper. The name heuristic is retained only for container (Optional/Collection/array) return types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../v121r7/BotPlayerNmsAdapterV1_21_R7.java | 23 +++++----- .../BotPlayerNmsAdapterV1_21_R7Test.java | 43 ++++++++++++++++++- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java index de6faa45..781c4216 100644 --- a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java +++ b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java @@ -645,6 +645,16 @@ private static boolean isSafeComponentAccessor(Method method) if (returnType == Void.TYPE || returnType.isPrimitive()) return false; + // A zero-arg accessor that directly returns a chat-component type is always followed, regardless of its + // name. Spigot's remapped record accessors are single obfuscated letters (e.g. IChatBaseComponent b() on + // ClientboundSystemChatPacket), which no name heuristic matches, whereas Paper exposes the same field as + // content(); keying on the return type rather than the method name captures the component on both distros. + final String returnTypeName = returnType.getName(); + if (returnTypeName.startsWith("net.minecraft.network.chat.") || returnTypeName.endsWith("Component")) + return true; + + // For container return types the component sits one level deeper, so require a component-like accessor + // name before following the getter to avoid walking unrelated object graphs. final String lowerMethodName = methodName.toLowerCase(Locale.ROOT); final boolean safeName = methodName.startsWith("get") || methodName.startsWith("is") || @@ -655,16 +665,9 @@ private static boolean isSafeComponentAccessor(Method method) if (!safeName) return false; - if (Optional.class.isAssignableFrom(returnType)) - return true; - if (Collection.class.isAssignableFrom(returnType)) - return true; - if (returnType.isArray()) - return true; - - final String returnTypeName = returnType.getName(); - return returnTypeName.startsWith("net.minecraft.network.chat.") || - returnTypeName.endsWith("Component"); + return Optional.class.isAssignableFrom(returnType) || + Collection.class.isAssignableFrom(returnType) || + returnType.isArray(); } private static @Nullable String extractText( diff --git a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java index ea2d6438..ad32015a 100644 --- a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java +++ b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java @@ -406,6 +406,43 @@ void extractComponentJson_shouldInspectOnlySafeAccessors() assertThat(unsafeComponentAccessor).isFalse(); } + @Test + void extractComponentJson_shouldFollowObfuscatedRecordAccessorReturningComponentType() + throws Exception + { + // setup — Spigot's remapped ClientboundSystemChatPacket exposes its component through an obfuscated + // single-letter record accessor (e.g. IChatBaseComponent b()) that no method-name heuristic matches; + // keying on the return type follows it, so components are captured on Spigot as well as on Paper. + final BotPlayerNmsAdapterV1_21_R7 adapter = allocateAdapter(); + setField(adapter, "componentJsonCodec", new FakeCodec()); + setField(adapter, "componentJsonOps", new Object()); + setField(adapter, "componentCodecEncodeStartMethod", FakeCodec.class.getMethod( + "encodeStart", + Object.class, + Object.class + )); + setField(adapter, "dataResultResultMethod", FakeDataResult.class.getMethod("result")); + final boolean obfuscatedAccessorSafe = (boolean) invokeStatic( + "isSafeComponentAccessor", + new Class[]{Method.class}, + ObfuscatedComponentPacket.class.getMethod("b") + ); + + // execute + final String extracted = (String) invokeInstance( + adapter, + "extractComponentJson", + new Class[]{Object.class, int.class, IdentityHashMap.class}, + new ObfuscatedComponentPacket(new FakeComponent("hi")), + 4, + new IdentityHashMap<>() + ); + + // verify + assertThat(obfuscatedAccessorSafe).isTrue(); + assertThat(extracted).isEqualTo("{\"text\":\"hi\"}"); + } + @Test void extractComponentJson_shouldReturnNullWhenComponentSerializationFails() throws Exception @@ -735,7 +772,11 @@ public FakeComponent getComponent() } } - public record UnsafeComponentPacket(FakeComponent value) + public record UnsafeComponentPacket(String value) + { + } + + public record ObfuscatedComponentPacket(FakeComponent b) { } From 34a01288d23d21e18815f8ebbb242d6b27068013 Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:44:23 +0200 Subject: [PATCH 5/9] fix(framework): match modern click_event key in hasClickableChatText hasClickableChatText keyed on the pre-1.21.5 camelCase "clickEvent" field, but the native component codec renamed it to snake_case "click_event" around 1.21.5. On 1.21.11 the real wire JSON never contains "clickEvent", so the assertion silently never matched a real click-event component. Accept both spellings (click_event first, clickEvent for pre-1.21.5 payload compat) so the assertion works across component-codec versions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../framework/assertions/PlayerHandleAssert.java | 10 +++++++--- .../framework/assertions/HandleAssertionsTest.java | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java index dd95a942..1da55391 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java @@ -113,9 +113,13 @@ public PlayerHandleAssert hasClickableChatText(String expectedText) continue; try { - // findValue matches a clickEvent nested in an 'extra' child, not only the root object; NMS - // serializes components that carry their clickable text in extra children. - if (OBJECT_MAPPER.readTree(component.json()).findValue("clickEvent") != null) + // findValue matches a click event nested in an 'extra' child, not only the root object; NMS + // serializes components that carry their clickable text in extra children. The native component + // codec renamed this field click_event (snake_case) in 1.21.5+, leaving the old clickEvent + // (camelCase) only on pre-1.21.5 payloads; accept both so the assertion matches across codec + // versions (keying on the old name alone silently never matched on 1.21.11). + final var tree = OBJECT_MAPPER.readTree(component.json()); + if (tree.findValue("click_event") != null || tree.findValue("clickEvent") != null) { found = true; break; diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java index 4a02f748..446b7223 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java @@ -78,6 +78,20 @@ void playerHandleAssert_shouldValidateInventoryAndClickableChatText() .hasClickableChatText("Open"); } + @Test + void playerHandleAssert_shouldMatchModernSnakeCaseClickEvent() + { + // setup — the 1.21.5+ component codec spells the field click_event (snake_case); keying only on the + // pre-1.21.5 clickEvent (as the assertion used to) never matched real 1.21.11 wire JSON + final PlayerHandle handle = mock(PlayerHandle.class); + when(handle.name()).thenReturn("bot"); + when(handle.chatComponents()).thenReturn(List.of(new ChatComponentSnapshot( + "{\"text\":\"Open\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa\"}}"))); + + // execute + verify + LightkeeperAssertions.assertThat(handle).hasClickableChatText("Open"); + } + @Test void playerHandleAssert_shouldFailWhenUnexpectedItemExists() { From fbf09b84216dc8a7c5ffdf411b4dcb8e51752b40 Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:44:38 +0200 Subject: [PATCH 6/9] test: add tab-complete and chat-click integration tests and test-plugin support End-to-end coverage on both distros. The test plugin gains /lktestclick, which emits a clickable run_command component via vanilla /tellraw (this Minecraft version's spigot-api ships no component builder, and /tellraw is parsed by the same native codec that serializes click_event), plus a permission-gated no-op /lktestperm command that the tab-complete filtering test needs (lktestgui has no command-level permission, so it could not exercise it). LightkeeperTabCompleteIT asserts a permissionless plugin command is suggested, an unknown command returns empty, and command names are permission-filtered before/after a runtime grant. LightkeeperChatClickIT runs the full capture -> extract -> click -> observe-side-effect flow and locks in that captured JSON uses click_event, not clickEvent. Both are green on Paper and Spigot. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../maven/test/LightkeeperChatClickIT.java | 52 ++++++++++++ .../maven/test/LightkeeperTabCompleteIT.java | 64 +++++++++++++++ .../LightkeeperSpigotTestPlugin.java | 10 +++ .../LkTestClickCommandExecutor.java | 80 ++++++++++++++++++ .../src/main/resources/plugin.yml | 10 +++ .../LkTestClickCommandExecutorTest.java | 81 +++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java create mode 100644 lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java create mode 100644 lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java create mode 100644 lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java diff --git a/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java new file mode 100644 index 00000000..43e517f1 --- /dev/null +++ b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java @@ -0,0 +1,52 @@ +package nl.pim16aap2.lightkeeper.maven.test; + +import nl.pim16aap2.lightkeeper.framework.ChatComponentSnapshot; +import nl.pim16aap2.lightkeeper.framework.ILightkeeperFramework; +import nl.pim16aap2.lightkeeper.framework.LightkeeperExtension; +import nl.pim16aap2.lightkeeper.framework.PlayerHandle; +import nl.pim16aap2.lightkeeper.framework.WorldHandle; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Duration; +import java.util.Optional; + +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.assertThat; +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.eventually; + +@ExtendWith(LightkeeperExtension.class) +class LightkeeperChatClickIT +{ + @Test + void chatClick_shouldCaptureExtractAndDispatchRunCommandComponent(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lkclick01", world); + + // execute — the test plugin sends the bot a clickable run_command chat component + player.executeCommand("lktestclick"); + framework.waitUntil( + () -> player.chatComponents().stream().anyMatch(component -> component.clickRunCommand().isPresent()), + Duration.ofSeconds(15)); + + final ChatComponentSnapshot clickable = player.chatComponents().stream() + .filter(component -> component.clickRunCommand().isPresent()) + .reduce((first, second) -> second) + .orElseThrow(); + + // verify — the native codec spells the field click_event (snake_case), never the stale clickEvent + assertThat(clickable.json()) + .contains("\"click_event\"") + .doesNotContain("\"clickEvent\""); + final Optional extracted = clickable.clickRunCommand(); + assertThat(extracted).contains("/lktestclick confirm"); + + // execute — clicking the component dispatches its run_command as the player + player.clickChatComponent(clickable); + + // verify — the dispatched command produced its observable confirmation message + eventually(Duration.ofSeconds(15), () -> + assertThat(player).receivedMessagesText().contains("LK_TEST_CLICK confirmed")); + } +} diff --git a/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java new file mode 100644 index 00000000..1c611b72 --- /dev/null +++ b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java @@ -0,0 +1,64 @@ +package nl.pim16aap2.lightkeeper.maven.test; + +import nl.pim16aap2.lightkeeper.framework.ILightkeeperFramework; +import nl.pim16aap2.lightkeeper.framework.LightkeeperExtension; +import nl.pim16aap2.lightkeeper.framework.PlayerHandle; +import nl.pim16aap2.lightkeeper.framework.WorldHandle; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.assertThat; + +@ExtendWith(LightkeeperExtension.class) +class LightkeeperTabCompleteIT +{ + private static final String TAB_PERMISSION = "lightkeeper.testplugin.lktestperm"; + + @Test + void tabComplete_shouldSuggestPermissionlessPluginCommandName(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab001", world); + + // execute — lktestlocale declares no permission, so it is visible to any player + final List completions = player.tabComplete("/lktestlocal"); + + // verify — a player sender's completions carry a leading slash, so match on the substring + assertThat(completions).anyMatch(completion -> completion.contains("lktestlocale")); + } + + @Test + void tabComplete_shouldReturnEmptyListForUnknownCommand(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab002", world); + + // execute — the trailing token triggers the args-completion branch, whose unknown-command lookup is null + final List completions = player.tabComplete("/zzznocommand arg"); + + // verify — a null command-map result is normalized to an empty list rather than propagating + assertThat(completions).isEmpty(); + } + + @Test + void tabComplete_shouldFilterCommandNamesByPlayerPermission(ILightkeeperFramework framework) + { + // setup — lktestperm carries a command-level permission (lightkeeper.testplugin.lktestperm) that + // defaults to false, so the command map filters it out of completions until the player is granted it + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab003", world); + + // execute + verify — without the permission the command is filtered out of the completions + final List beforeGrant = player.tabComplete("/lktestp"); + assertThat(beforeGrant).noneMatch(completion -> completion.contains("lktestperm")); + + // execute + verify — granting the permission makes the same buffer offer the command + player.permissions().grant(TAB_PERMISSION); + final List afterGrant = player.tabComplete("/lktestp"); + assertThat(afterGrant).anyMatch(completion -> completion.contains("lktestperm")); + } +} diff --git a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java index bc4e43a3..075fac2d 100644 --- a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java +++ b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java @@ -36,6 +36,10 @@ public final class LightkeeperSpigotTestPlugin extends JavaPlugin implements Lis * Command name used to log the invoking player's client locale for full-login locale integration tests. */ private static final String LOCALE_COMMAND_NAME = "lktestlocale"; + /** + * Command name used to emit a clickable {@code run_command} chat component for chat-click integration tests. + */ + private static final String CLICK_COMMAND_NAME = "lktestclick"; /** * Prefix used for deterministic block interaction messages. */ @@ -66,9 +70,15 @@ public void onEnable() throw new IllegalStateException( "Required command '/%s' is not declared in plugin metadata.".formatted(LOCALE_COMMAND_NAME)); + final PluginCommand clickCommand = getCommand(CLICK_COMMAND_NAME); + if (clickCommand == null) + throw new IllegalStateException( + "Required command '/%s' is not declared in plugin metadata.".formatted(CLICK_COMMAND_NAME)); + pluginCommand.setExecutor(new LkTestGuiCommandExecutor(guiMenuService)); errorCommand.setExecutor(new LkTestErrorCommandExecutor(getLogger())); localeCommand.setExecutor(new LkTestLocaleCommandExecutor(getLogger())); + clickCommand.setExecutor(new LkTestClickCommandExecutor()); Bukkit.getPluginManager().registerEvents(this, this); } diff --git a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java new file mode 100644 index 00000000..4f8d0f04 --- /dev/null +++ b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java @@ -0,0 +1,80 @@ +package nl.pim16aap2.lightkeeper.spigot.testplugin; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * Handles {@code /lktestclick} command execution for the standalone test plugin. + * + *

Invoked with no arguments, it sends the invoking player a chat component carrying a {@code run_command} + * click event, so integration tests can capture the component, extract the command, and "click" it. Invoked with + * {@code confirm} (the command the click event runs), it replies with a deterministic marker the test can assert + * on to prove the click actually dispatched the command. + * + *

The clickable component is produced through the vanilla {@code /tellraw} command rather than an API builder: + * this Minecraft version's spigot-api ships no chat-component builder (the BungeeCord chat API was removed and + * Adventure is Paper-only), and {@code /tellraw} is parsed by the same native component codec that serializes the + * {@code click_event} field on the wire, so the round trip is faithful and identical on Spigot and Paper. + */ +final class LkTestClickCommandExecutor implements CommandExecutor +{ + /** + * Visible text of the clickable component; integration tests locate the component by this marker. + */ + static final String CLICK_TEXT = "LK_CLICK_ME"; + /** + * Argument that triggers the confirmation reply instead of emitting a new clickable component. + */ + static final String CONFIRM_ARG = "confirm"; + /** + * Command the clickable component runs when clicked (a self-invocation with {@link #CONFIRM_ARG}). + */ + static final String RUN_COMMAND = "/lktestclick " + CONFIRM_ARG; + /** + * Deterministic reply sent once the {@link #RUN_COMMAND} is executed, asserted on by integration tests. + */ + static final String CONFIRM_MESSAGE = "LK_TEST_CLICK confirmed"; + /** + * Reply sent when the command is invoked by a non-player sender (only players receive chat components). + */ + static final String PLAYERS_ONLY_MESSAGE = "Only players can use /lktestclick."; + + /** + * Emits a clickable component to the player, or replies with the confirmation marker on {@code confirm}. + * + * @param sender + * Sender that invoked the command. + * @param command + * Command metadata. + * @param label + * Used command label. + * @param args + * Command arguments; {@code confirm} selects the confirmation reply. + * @return + * Always {@code true} because this executor handles all command outcomes directly. + */ + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) + { + if (!(sender instanceof Player player)) + { + sender.sendMessage(PLAYERS_ONLY_MESSAGE); + return true; + } + + if (args.length > 0 && args[0].equalsIgnoreCase(CONFIRM_ARG)) + { + player.sendMessage(CONFIRM_MESSAGE); + return true; + } + + final String tellraw = ("minecraft:tellraw %s " + + "{\"text\":\"%s\",\"click_event\":{\"action\":\"run_command\",\"command\":\"%s\"}}") + .formatted(player.getName(), CLICK_TEXT, RUN_COMMAND); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), tellraw); + return true; + } +} diff --git a/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml b/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml index d79171b1..60672c49 100644 --- a/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml +++ b/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml @@ -15,6 +15,13 @@ commands: lktestlocale: description: Logs the invoking player's client locale for LightKeeper full-login locale integration tests usage: /lktestlocale + lktestclick: + description: Sends a clickable run_command chat component for LightKeeper chat-click integration tests + usage: /lktestclick + lktestperm: + description: A permission-gated no-op command for LightKeeper tab-completion permission integration tests + usage: /lktestperm + permission: lightkeeper.testplugin.lktestperm permissions: lightkeeper.testplugin.lktestgui: description: Allows opening the LightKeeper test GUI. @@ -22,3 +29,6 @@ permissions: lightkeeper.testplugin.lktesterror: description: Allows emitting LightKeeper error-capture test log events. default: false + lightkeeper.testplugin.lktestperm: + description: Gates visibility of /lktestperm in tab-completion. + default: false diff --git a/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java b/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java new file mode 100644 index 00000000..1fafd0f6 --- /dev/null +++ b/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java @@ -0,0 +1,81 @@ +package nl.pim16aap2.lightkeeper.spigot.testplugin; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LkTestClickCommandExecutorTest +{ + @Test + void onCommand_shouldSendConfirmMessageWhenArgIsConfirm() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final Player player = mock(); + final Command command = mock(); + + // execute + final boolean handled = executor.onCommand(player, command, "lktestclick", new String[]{"confirm"}); + + // verify + assertThat(handled).isTrue(); + verify(player).sendMessage(LkTestClickCommandExecutor.CONFIRM_MESSAGE); + } + + @Test + void onCommand_shouldReplyPlayersOnlyForNonPlayerSender() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final CommandSender sender = mock(); + final Command command = mock(); + + // execute + final boolean handled = executor.onCommand(sender, command, "lktestclick", new String[0]); + + // verify + assertThat(handled).isTrue(); + verify(sender).sendMessage(LkTestClickCommandExecutor.PLAYERS_ONLY_MESSAGE); + } + + @Test + void onCommand_shouldDispatchTellrawWithRunCommandClickEventWhenNoArgs() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final Player player = mock(); + when(player.getName()).thenReturn("lkclick01"); + final Command command = mock(); + final ConsoleCommandSender console = mock(); + final ArgumentCaptor dispatched = ArgumentCaptor.forClass(String.class); + + // execute + final boolean handled; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + handled = executor.onCommand(player, command, "lktestclick", new String[0]); + bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), dispatched.capture())); + } + + // verify — a /tellraw that targets the player and carries a run_command click_event on the wire key + assertThat(handled).isTrue(); + assertThat(dispatched.getValue()) + .startsWith("minecraft:tellraw lkclick01 ") + .contains(LkTestClickCommandExecutor.CLICK_TEXT) + .contains("\"click_event\":{\"action\":\"run_command\"") + .contains("\"command\":\"/lktestclick confirm\""); + } +} From 4be92c83c4da0e8d17fd0a49d4766268ecab3aa7 Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 08:44:46 +0200 Subject: [PATCH 7/9] docs: document tab-completion and chat-click features in README Add the command tab-completion and clickable-chat-component capabilities to the Key Features and Core Features sections, describing the permission filtering, leading-slash/trailing-space handling, and the capture -> extract -> click helper flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d13067f..684c3e7e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ planned and reviewed by a human. - **World & Chunk Control**: Load/unload chunks, check chunk status, and teleport players between worlds. - **Inventory & Item Drops**: Inspect player inventories and simulate item drops. - **Dynamic Event Capture**: Capture and assert on any Bukkit event dynamically without writing custom listeners. -- **Clickable Chat Assertions**: Verify clickable chat message text and actions. +- **Clickable Chat Assertions**: Verify clickable chat message text and actions, extract a click action, and click it. +- **Command Tab-Completion**: Query a player's permission-filtered command completions exactly as a real client would. - **Platform Awareness**: Write tests that adapt to Paper or Spigot specifics. ## Requirements @@ -248,6 +249,13 @@ class MyPluginIT - Bot chat and deterministic cancellation: `player.chat("...")` fires the real chat event; `capture.cancelNext(n)` cancels exactly the next N events of a captured class (LOWEST priority, so the capture still records the final cancelled state); captured events carry the server tick they fired on +- Command tab-completion: `player.tabComplete("/cmd")` returns the live server command map's completions for that + player, permission-filtered exactly as a real client's tab-complete would be — a bot that lacks a command's + permission never sees it. A leading slash is accepted and normalized; a trailing space selects argument + completion (and is never trimmed), while an unknown command yields an empty list +- Clickable chat components: capture a player's rendered chat components with `player.chatComponents()`, extract a + click action from a snapshot with `component.clickRunCommand()`/`component.clickSuggestedCommand()`, and dispatch + a `run_command` click as the player with `player.clickChatComponent(component)` — the same path a real click takes - Block state as a first-class value: place blocks with full state via `world.setBlockAt(pos, BlockSpec.parse("minecraft:lever[face=floor,powered=true]"))` and assert partially via `world.blockAt(pos).is(BlockSpec.parse("minecraft:lever[powered=true]"))` — only named properties From b828fe20480bf5720c74b4609de806f5966c1418 Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 09:36:25 +0200 Subject: [PATCH 8/9] fix: address pre-PR deep-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal: fold the Opus review round into the S7 slice. Drops the redundant whole-chat-package disjunct from the component-accessor predicate — the type-suffix match alone covers both distros' real accessors, while the package match would nondeterministically follow MessageSignature/ ChatType.Bound/FilterMask accessors on multi-field chat packets and capture a decoration instead of the message (plus WARNING spam). The widened type-driven capture semantics are documented in place, and the TabCompletePlayer wire javadoc now states that the framework's public API rejects blank input before it reaches the command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java | 6 +++++- .../pim16aap2/lightkeeper/protocol/TabCompletePlayer.java | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java index 781c4216..f0c56eee 100644 --- a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java +++ b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java @@ -649,8 +649,12 @@ private static boolean isSafeComponentAccessor(Method method) // name. Spigot's remapped record accessors are single obfuscated letters (e.g. IChatBaseComponent b() on // ClientboundSystemChatPacket), which no name heuristic matches, whereas Paper exposes the same field as // content(); keying on the return type rather than the method name captures the component on both distros. + // Deliberately ONLY the type-name suffix: a whole-chat-package match would also follow MessageSignature/ + // ChatType.Bound/FilterMask accessors on multi-field chat packets and could nondeterministically capture a + // decoration instead of the message. Note this widens capture to any Component-returning accessor + // (boss bars, titles, tab-list headers) — component capture is type-driven, not name-driven. final String returnTypeName = returnType.getName(); - if (returnTypeName.startsWith("net.minecraft.network.chat.") || returnTypeName.endsWith("Component")) + if (returnTypeName.endsWith("Component")) return true; // For container return types the component sits one level deeper, so require a component-like accessor diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java index 497a2a96..403060c9 100644 --- a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java @@ -39,7 +39,8 @@ public record Command( * *

Unlike {@link ExecutePlayerCommand.Command}, the {@code commandLine} is intentionally not rejected * when blank and not stripped: a whitespace-only or trailing-whitespace buffer is a valid, - * meaningfully-different tab-complete input rather than a malformed command string. + * meaningfully-different tab-complete input at the wire level; the framework's public API rejects blank + * input before it reaches this command. */ public Command { From 6e04d33c9e9f3f8a610fc9e1ebf3d5340a1a09bf Mon Sep 17 00:00:00 2001 From: Pim van der Loos Date: Thu, 16 Jul 2026 10:17:06 +0200 Subject: [PATCH 9/9] fix: extract legacy clickEvent payloads in click helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal: address CodeRabbit's bot-round finding on PR #102 — extraction rejected the legacy clickEvent/value spelling while hasClickableChatText accepted it, so clickChatComponent would throw on the very payloads the assertion matches. findClickCommand now mines both key spellings (command first, then value) in preference order, with the legacy test flipped to assert successful extraction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P15FjGQzyNmX9T6Qmyq8Dn --- .../framework/ChatComponentSnapshot.java | 23 ++++++++++++++++--- .../framework/ChatComponentSnapshotTest.java | 17 ++++++++------ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java index 87f4dce7..0916d169 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java @@ -26,6 +26,12 @@ public record ChatComponentSnapshot( */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * Modern and legacy click-event key spellings, in preference order. + */ + private static final java.util.List CLICK_EVENT_KEYS = java.util.List.of("click_event", "clickEvent"); + + /** * The {@code click_event} action name of a component that runs a command when clicked. */ @@ -76,10 +82,21 @@ private Optional findClickCommand(String action) // root-level one. Matching per action (rather than "the first click_event found") means // clickRunCommand() never returns a suggest_command payload when a single message carries both // (for example a paginated [<] [1/3] [>] control). - for (final JsonNode clickEvent : root.findValues("click_event")) + // Modern (1.21.5+) payloads use click_event with a 'command' field; legacy payloads used + // clickEvent with a 'value' field. Extraction accepts both so it matches exactly what + // PlayerHandleAssert#hasClickableChatText accepts — an asymmetry there would make + // clickChatComponent throw on the very payloads the assertion matches. + for (final String key : CLICK_EVENT_KEYS) { - if (action.equals(clickEvent.path("action").asString("")) && clickEvent.hasNonNull("command")) - return Optional.of(clickEvent.get("command").asString()); + for (final JsonNode clickEvent : root.findValues(key)) + { + if (!action.equals(clickEvent.path("action").asString(""))) + continue; + if (clickEvent.hasNonNull("command")) + return Optional.of(clickEvent.get("command").asString()); + if (clickEvent.hasNonNull("value")) + return Optional.of(clickEvent.get("value").asString()); + } } return Optional.empty(); } diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java index ce3e8c10..f34c77a8 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java @@ -59,15 +59,18 @@ void clickRunCommand_shouldReturnEmptyForSuggestCommandOnlyComponent() } @Test - void clickRunCommand_shouldReturnEmptyForLegacyCamelCaseClickEventKey() + void clickRunCommand_shouldExtractLegacyCamelCaseClickEventValue() { - // setup — extraction keys on the modern click_event only; the pre-1.21.5 clickEvent is not the wire - // format on supported versions, so it is not mined for a command - final ChatComponentSnapshot component = new ChatComponentSnapshot( - "{\"text\":\"click me\",\"clickEvent\":{\"action\":\"run_command\",\"value\":\"/aa confirm\"}}"); + // setup — pre-1.21.5 payloads used clickEvent with a 'value' field; extraction accepts them so it + // stays consistent with hasClickableChatText's lenient matching. + final ChatComponentSnapshot snapshot = new ChatComponentSnapshot( + "{\"text\":\"[Confirm]\",\"clickEvent\":{\"action\":\"run_command\",\"value\":\"/lk confirm\"}}"); - // execute + verify - assertThat(component.clickRunCommand()).isEmpty(); + // execute + final java.util.Optional result = snapshot.clickRunCommand(); + + // verify + assertThat(result).contains("/lk confirm"); } @Test