Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,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.Location;
import org.bukkit.Material;
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<UUID> fullLoginPlayerIds = ConcurrentHashMap.newKeySet();
/**
* The server's {@link CommandMap}, resolved lazily and cached on first tab-complete.
*
* <p>{@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
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <p>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<String> completions = mainThreadExecutor.callOnMainThread(() ->
{
final Player player = playerStore.getRequiredPlayer(uuid);
final CommandMap commandMap = resolveCommandMap();
final List<String> result = commandMap.tabComplete(player, commandLine);
return result == null ? List.<String>of() : List.copyOf(result);
});

return new TabCompletePlayer.Response(completions);
}

/**
* Resolves the server's {@link CommandMap}, caching it after the first lookup.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> 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> 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> 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> 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
Expand Down Expand Up @@ -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();
}
}
Loading