From c97d490b9ec48fffa729da9a8caa7ed8a09eb1ab Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 2 Jul 2026 16:41:39 -0500 Subject: [PATCH 01/17] Fix list_players and get_player_position hanging forever Both handlers called Universe.get().getPlayers()/PlayerRef.getTransform() directly on the Jetty request thread instead of hopping onto the world thread via world.execute(), unlike every other stateful feature (get_world_info, give_item, set_block, etc). They also used stale types (List instead of Collection, and fully-qualified Vector3d/Vector3f which no longer exist) that no longer match the live HytaleServer API (getPlayers() returns Collection; Transform.getPosition() returns org.joml.Vector3d; Transform.getRotation() returns Rotation3f). Because the outer catch only caught Exception, the resulting NoSuchMethodError/NoClassDefFoundError was swallowed by whatever thread ran the sync tool handler without ever completing the MCP SDK's response future, so HttpServletStreamableServerTransportProvider.doPost's Mono.block() hung forever with no server-side log output at all. Verified against a live server: both tools now return correctly instead of timing out, with a connected player and with zero players. Co-Authored-By: Claude Sonnet 5 --- .../features/GetPlayerPositionFeature.java | 71 +++++++++++++------ .../mcp/features/ListPlayersFeature.java | 49 +++++++++---- 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetPlayerPositionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetPlayerPositionFeature.java index 0f543e4..4c2cb4f 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetPlayerPositionFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetPlayerPositionFeature.java @@ -5,15 +5,17 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; import com.top_serveurs.hytale.plugins.mcp.models.McpTool; import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; -import java.util.List; +import java.util.Collection; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; public class GetPlayerPositionFeature implements McpFeature { private static final Gson GSON = new Gson(); @@ -56,30 +58,53 @@ public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLe } String playerIdentifier = args.get("player").toString(); - PlayerRef player = findPlayer(playerIdentifier); - if (player == null) { - return McpToolResponse.error("Player not found: " + playerIdentifier); + Map worlds = Universe.get().getWorlds(); + if (worlds.isEmpty()) { + return McpToolResponse.error("No world available to get player position"); } + World world = worlds.values().iterator().next(); + + // Universe/PlayerRef state (including live transform) must be read on the + // owning world's thread, otherwise the MCP SDK's blocking sync tool call + // hangs forever with no exception (the Jetty request thread is not the + // world thread). + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + PlayerRef player = findPlayer(playerIdentifier); + + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + com.hypixel.hytale.math.vector.Transform transform = player.getTransform(); + org.joml.Vector3d pos = transform.getPosition(); + com.hypixel.hytale.math.vector.Rotation3f rotation = transform.getRotation(); + + JsonObject position = new JsonObject(); + position.addProperty("x", pos.x()); + position.addProperty("y", pos.y()); + position.addProperty("z", pos.z()); + position.addProperty("worldUuid", player.getWorldUuid().toString()); + position.addProperty("yaw", rotation.yaw()); + position.addProperty("pitch", rotation.pitch()); + + JsonObject response = new JsonObject(); + response.addProperty("name", player.getUsername()); + response.addProperty("uuid", player.getUuid().toString()); + response.add("position", position); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("Error getting player position"); + future.complete(McpToolResponse.error("Failed to get player position: " + t.getMessage())); + } + }); - com.hypixel.hytale.math.vector.Transform transform = player.getTransform(); - com.hypixel.hytale.math.vector.Vector3d pos = transform.getPosition(); - com.hypixel.hytale.math.vector.Vector3f rotation = transform.getRotation(); - - JsonObject position = new JsonObject(); - position.addProperty("x", pos.getX()); - position.addProperty("y", pos.getY()); - position.addProperty("z", pos.getZ()); - position.addProperty("worldUuid", player.getWorldUuid().toString()); - position.addProperty("yaw", rotation.getY()); - position.addProperty("pitch", rotation.getX()); - - JsonObject response = new JsonObject(); - response.addProperty("name", player.getUsername()); - response.addProperty("uuid", player.getUuid().toString()); - response.add("position", position); - - return McpToolResponse.success(GSON.toJson(response)); + return future.join(); } catch (Exception e) { logger.atSevere().withCause(e).log("Error getting player position"); return McpToolResponse.error("Failed to get player position: " + e.getMessage()); @@ -87,7 +112,7 @@ public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLe } private PlayerRef findPlayer(String identifier) { - List players = Universe.get().getPlayers(); + Collection players = Universe.get().getPlayers(); try { UUID uuid = UUID.fromString(identifier); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListPlayersFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListPlayersFeature.java index 94bdd27..9133e6b 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListPlayersFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListPlayersFeature.java @@ -6,13 +6,16 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; import com.top_serveurs.hytale.plugins.mcp.models.McpTool; import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; -import java.util.List; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.CompletableFuture; public class ListPlayersFeature implements McpFeature { private static final Gson GSON = new Gson(); @@ -44,21 +47,41 @@ public String getInputSchema() { @Override public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLevel) { try { - List players = Universe.get().getPlayers(); - JsonArray playerArray = new JsonArray(); - - for (PlayerRef player : players) { - JsonObject playerObj = new JsonObject(); - playerObj.addProperty("uuid", player.getUuid().toString()); - playerObj.addProperty("name", player.getUsername()); - playerArray.add(playerObj); + Map worlds = Universe.get().getWorlds(); + if (worlds.isEmpty()) { + return McpToolResponse.error("No world available to list players"); } + World world = worlds.values().iterator().next(); + + // Universe/PlayerRef state must be read on the owning world's thread, + // otherwise the MCP SDK's blocking sync tool call hangs forever with no + // exception (the Jetty request thread is not the world thread). + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Collection players = Universe.get().getPlayers(); + JsonArray playerArray = new JsonArray(); + + for (PlayerRef player : players) { + JsonObject playerObj = new JsonObject(); + playerObj.addProperty("uuid", player.getUuid().toString()); + playerObj.addProperty("name", player.getUsername()); + playerArray.add(playerObj); + } + + JsonObject response = new JsonObject(); + response.addProperty("count", players.size()); + response.add("players", playerArray); - JsonObject response = new JsonObject(); - response.addProperty("count", players.size()); - response.add("players", playerArray); + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("Error listing players"); + future.complete(McpToolResponse.error("Failed to list players: " + t.getMessage())); + } + }); - return McpToolResponse.success(GSON.toJson(response)); + return future.join(); } catch (Exception e) { logger.atSevere().withCause(e).log("Error listing players"); return McpToolResponse.error("Failed to list players: " + e.getMessage()); From a58fb3b46b64b4370acc448a98a690be7dfa14cb Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 17:04:20 -0500 Subject: [PATCH 02/17] Add get_block tool: the read counterpart to set_block Lets an MCP client query the actual block placed at a coordinate (distinguishing air, unloaded chunks, and real blocks) instead of only being able to write blocks blind. Adds a matching getBlock permission flag alongside the existing per-tool flags. Co-Authored-By: Claude Sonnet 5 --- config.example.json | 6 +- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../hytale/plugins/mcp/config/McpConfig.java | 9 ++ .../plugins/mcp/features/GetBlockFeature.java | 147 ++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java diff --git a/config.example.json b/config.example.json index c217ab5..d32b8e8 100644 --- a/config.example.json +++ b/config.example.json @@ -20,7 +20,8 @@ "getBlockTypes": false, "listBlocks": false, "getWorldInfo": false, - "getServerInfo": false + "getServerInfo": false, + "getBlock": false }, "admins": { "listPlayers": true, @@ -33,7 +34,8 @@ "getBlockTypes": true, "listBlocks": true, "getWorldInfo": true, - "getServerInfo": true + "getServerInfo": true, + "getBlock": true }, "maxBlocksBatch": 1000 } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 5eb5a7f..32e84be 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -81,6 +81,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new ListPlayersFeature(logger)); featureRegistry.registerFeature(new GetPlayerPositionFeature(logger)); featureRegistry.registerFeature(new ListBlocksFeature(logger)); + featureRegistry.registerFeature(new GetBlockFeature(logger)); featureRegistry.registerFeature(new ExecuteCommandFeature(logger, config)); featureRegistry.registerFeature(new GiveItemFeature(logger, config)); featureRegistry.registerFeature(new BroadcastMessageFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 1b72321..925137e 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -179,6 +179,7 @@ public static class FeaturePermissions { private boolean getWorldInfo = false; private boolean getServerInfo = false; private boolean listBlocks = false; + private boolean getBlock = false; public boolean canListPlayers() { return listPlayers; @@ -267,5 +268,13 @@ public boolean canListBlocks() { public void setListBlocks(boolean listBlocks) { this.listBlocks = listBlocks; } + + public boolean canGetBlock() { + return getBlock; + } + + public void setGetBlock(boolean getBlock) { + this.getBlock = getBlock; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java new file mode 100644 index 0000000..1e2163c --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java @@ -0,0 +1,147 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class GetBlockFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public GetBlockFeature(HytaleLogger logger) { + this.logger = logger; + } + + @Override + public String getName() { + return "get_block"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "get_block", + "Gets the block type actually placed at specific world coordinates - the read counterpart to set_block.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "x", McpToolSchema.integerProperty("X coordinate"), + "y", McpToolSchema.integerProperty("Y coordinate"), + "z", McpToolSchema.integerProperty("Z coordinate"), + "world", McpToolSchema.stringProperty("World UUID") + ), + java.util.List.of("x", "y", "z", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + + int x = getArgumentAsInt(call, "x"); + int y = getArgumentAsInt(call, "y"); + int z = getArgumentAsInt(call, "z"); + String worldUuidStr = getArgumentAsString(call, "world"); + + if (x == Integer.MIN_VALUE || y == Integer.MIN_VALUE || z == Integer.MIN_VALUE) { + return McpToolResponse.error("x, y and z are required integers"); + } + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + // getBlockType returns null when the target chunk isn't loaded (not an error + // condition, just unresolvable right now) - distinct from BlockType.EMPTY, which + // means the chunk is loaded and the position is genuinely air. + BlockType blockType = world.getBlockType(x, y, z); + + JsonObject json = new JsonObject(); + json.addProperty("x", x); + json.addProperty("y", y); + json.addProperty("z", z); + + if (blockType == null) { + json.addProperty("loaded", false); + json.add("blockType", null); + } else if (blockType == BlockType.EMPTY) { + json.addProperty("loaded", true); + json.addProperty("air", true); + json.add("blockType", null); + } else { + json.addProperty("loaded", true); + json.addProperty("air", false); + json.addProperty("blockType", blockType.getId()); + } + + future.complete(McpToolResponse.success(GSON.toJson(json))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[GET_BLOCK] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canGetBlock(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canGetBlock(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 77a12e3c0226fcb3c5722c6d0c8e79407d655099 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 17:26:57 -0500 Subject: [PATCH 03/17] Add break_block tool: clear a block back to air set_block can never place "air" - BlockType.getAssetMap().getAsset(...) has no lookupable string key for it, since BlockType.EMPTY is a plain Java static field, not a registered asset. Confirmed via decompiling BlockAccessor.breakBlock's bytecode that it internally resolves to setBlock(x, y, z, 0, BlockType.EMPTY, 0, 0, flags) - this tool exposes that path directly instead of requiring a player to break the block. Co-Authored-By: Claude Sonnet 5 --- config.example.json | 6 +- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../hytale/plugins/mcp/config/McpConfig.java | 9 ++ .../mcp/features/BreakBlockFeature.java | 138 ++++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlockFeature.java diff --git a/config.example.json b/config.example.json index d32b8e8..5ddf8ec 100644 --- a/config.example.json +++ b/config.example.json @@ -21,7 +21,8 @@ "listBlocks": false, "getWorldInfo": false, "getServerInfo": false, - "getBlock": false + "getBlock": false, + "breakBlock": false }, "admins": { "listPlayers": true, @@ -35,7 +36,8 @@ "listBlocks": true, "getWorldInfo": true, "getServerInfo": true, - "getBlock": true + "getBlock": true, + "breakBlock": true }, "maxBlocksBatch": 1000 } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 32e84be..e3a9eac 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -86,6 +86,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new GiveItemFeature(logger, config)); featureRegistry.registerFeature(new BroadcastMessageFeature(logger, config)); featureRegistry.registerFeature(new SetBlockFeature(logger)); + featureRegistry.registerFeature(new BreakBlockFeature(logger)); featureRegistry.registerFeature(new SetBlocksBatchFeature(logger, config)); featureRegistry.registerFeature(new FlattenTerrainFeature(logger, config)); featureRegistry.registerFeature(new GetBuildingGuideFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 925137e..52ccfcc 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -180,6 +180,7 @@ public static class FeaturePermissions { private boolean getServerInfo = false; private boolean listBlocks = false; private boolean getBlock = false; + private boolean breakBlock = false; public boolean canListPlayers() { return listPlayers; @@ -276,5 +277,13 @@ public boolean canGetBlock() { public void setGetBlock(boolean getBlock) { this.getBlock = getBlock; } + + public boolean canBreakBlock() { + return breakBlock; + } + + public void setBreakBlock(boolean breakBlock) { + this.breakBlock = breakBlock; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlockFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlockFeature.java new file mode 100644 index 0000000..c3a5b7e --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlockFeature.java @@ -0,0 +1,138 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Clears a block back to air. There's no registered string id for "air" - it's the static + * {@code BlockType.EMPTY} sentinel, which {@code set_block}'s string-based lookup can never + * resolve (confirmed live: "Air"/"Empty"/"None" all fail with "Unknown block type"). BlockAccessor's + * own {@code breakBlock} default method is what actually clears a position, internally calling + * {@code setBlock(x, y, z, 0, BlockType.EMPTY, 0, 0, flags)} directly - this tool just exposes that. + */ +public class BreakBlockFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public BreakBlockFeature(HytaleLogger logger) { + this.logger = logger; + } + + @Override + public String getName() { + return "break_block"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "break_block", + "Clears the block at specified world coordinates back to air, the same as a player breaking it (drops items, plays break effects).", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "x", McpToolSchema.integerProperty("X coordinate"), + "y", McpToolSchema.integerProperty("Y coordinate"), + "z", McpToolSchema.integerProperty("Z coordinate"), + "world", McpToolSchema.stringProperty("World UUID") + ), + java.util.List.of("x", "y", "z", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + + int x = getArgumentAsInt(call, "x"); + int y = getArgumentAsInt(call, "y"); + int z = getArgumentAsInt(call, "z"); + String worldUuidStr = getArgumentAsString(call, "world"); + + if (x == Integer.MIN_VALUE || y == Integer.MIN_VALUE || z == Integer.MIN_VALUE) { + return McpToolResponse.error("x, y and z are required integers"); + } + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + boolean changed = world.breakBlock(x, y, z, 0); + + JsonObject json = new JsonObject(); + json.addProperty("x", x); + json.addProperty("y", y); + json.addProperty("z", z); + json.addProperty("changed", changed); + + future.complete(McpToolResponse.success(GSON.toJson(json))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[BREAK_BLOCK] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canBreakBlock(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canBreakBlock(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 289516e206bd71cd90ff8af9daa8fb4a5452fe12 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 17:36:41 -0500 Subject: [PATCH 04/17] Add scan_region tool: bulk-read a bounding box in one call Complements get_block for checking a whole structure at once instead of probing one coordinate at a time. Accepts any two opposite corners, returns every non-air block plus air/unloaded counts, and caps volume via a new maxScanVolume config option (mirrors maxBlocksBatch). Co-Authored-By: Claude Sonnet 5 --- config.example.json | 9 +- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../hytale/plugins/mcp/config/McpConfig.java | 18 ++ .../mcp/features/ScanRegionFeature.java | 195 ++++++++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java diff --git a/config.example.json b/config.example.json index 5ddf8ec..d5055d5 100644 --- a/config.example.json +++ b/config.example.json @@ -22,7 +22,8 @@ "getWorldInfo": false, "getServerInfo": false, "getBlock": false, - "breakBlock": false + "breakBlock": false, + "scanRegion": false }, "admins": { "listPlayers": true, @@ -37,8 +38,10 @@ "getWorldInfo": true, "getServerInfo": true, "getBlock": true, - "breakBlock": true + "breakBlock": true, + "scanRegion": true }, - "maxBlocksBatch": 1000 + "maxBlocksBatch": 1000, + "maxScanVolume": 32768 } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index e3a9eac..7b851b9 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -82,6 +82,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new GetPlayerPositionFeature(logger)); featureRegistry.registerFeature(new ListBlocksFeature(logger)); featureRegistry.registerFeature(new GetBlockFeature(logger)); + featureRegistry.registerFeature(new ScanRegionFeature(logger, config)); featureRegistry.registerFeature(new ExecuteCommandFeature(logger, config)); featureRegistry.registerFeature(new GiveItemFeature(logger, config)); featureRegistry.registerFeature(new BroadcastMessageFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 52ccfcc..039c208 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -141,6 +141,7 @@ public static class FeaturesConfig { private FeaturePermissions players = new FeaturePermissions(); private FeaturePermissions admins = new FeaturePermissions(); private int maxBlocksBatch = 1000; + private int maxScanVolume = 32768; public FeaturePermissions getPlayers() { return players; @@ -165,6 +166,14 @@ public int getMaxBlocksBatch() { public void setMaxBlocksBatch(int maxBlocksBatch) { this.maxBlocksBatch = maxBlocksBatch; } + + public int getMaxScanVolume() { + return maxScanVolume; + } + + public void setMaxScanVolume(int maxScanVolume) { + this.maxScanVolume = maxScanVolume; + } } public static class FeaturePermissions { @@ -181,6 +190,7 @@ public static class FeaturePermissions { private boolean listBlocks = false; private boolean getBlock = false; private boolean breakBlock = false; + private boolean scanRegion = false; public boolean canListPlayers() { return listPlayers; @@ -285,5 +295,13 @@ public boolean canBreakBlock() { public void setBreakBlock(boolean breakBlock) { this.breakBlock = breakBlock; } + + public boolean canScanRegion() { + return scanRegion; + } + + public void setScanRegion(boolean scanRegion) { + this.scanRegion = scanRegion; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java new file mode 100644 index 0000000..9a0ee3a --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java @@ -0,0 +1,195 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Bulk read counterpart to set_blocks_batch: scans a bounding box (any corner order accepted, + * normalized internally) and reports every non-air block found, plus counts of air/unloaded + * positions. Only non-air blocks are included in the returned list - returning every air block in + * a large box would blow up payload size for little value, since the caller almost always wants + * "what's actually built here", not a full lattice. + * + *

Volume is capped by {@code maxScanVolume} (mirrors set_blocks_batch's maxBlocksBatch cap) to + * bound both response size and worst-case blocking time: each position not yet resolved triggers + * getBlockType's normal chunk-load-on-demand behavior, so a very large box spanning many unloaded + * chunks could stall the world thread for a while. Callers scanning a big area should keep it to + * chunks they know are already loaded (e.g. right around a player). + */ +public class ScanRegionFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + private final McpConfig config; + + public ScanRegionFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "scan_region"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "scan_region", + "Scans a bounding box (any two opposite corners) and reports every non-air block found, plus counts of air/unloaded positions. Max volume " + + config.getFeatures().getMaxScanVolume() + " blocks. Use this instead of repeated get_block calls to check whether a structure is actually built as expected.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "x1", McpToolSchema.integerProperty("First corner X coordinate"), + "y1", McpToolSchema.integerProperty("First corner Y coordinate"), + "z1", McpToolSchema.integerProperty("First corner Z coordinate"), + "x2", McpToolSchema.integerProperty("Opposite corner X coordinate"), + "y2", McpToolSchema.integerProperty("Opposite corner Y coordinate"), + "z2", McpToolSchema.integerProperty("Opposite corner Z coordinate"), + "world", McpToolSchema.stringProperty("World UUID") + ), + java.util.List.of("x1", "y1", "z1", "x2", "y2", "z2", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + + int x1 = getArgumentAsInt(call, "x1"); + int y1 = getArgumentAsInt(call, "y1"); + int z1 = getArgumentAsInt(call, "z1"); + int x2 = getArgumentAsInt(call, "x2"); + int y2 = getArgumentAsInt(call, "y2"); + int z2 = getArgumentAsInt(call, "z2"); + String worldUuidStr = getArgumentAsString(call, "world"); + + if (x1 == Integer.MIN_VALUE || y1 == Integer.MIN_VALUE || z1 == Integer.MIN_VALUE + || x2 == Integer.MIN_VALUE || y2 == Integer.MIN_VALUE || z2 == Integer.MIN_VALUE) { + return McpToolResponse.error("x1, y1, z1, x2, y2 and z2 are required integers"); + } + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + int minX = Math.min(x1, x2); + int maxX = Math.max(x1, x2); + int minY = Math.min(y1, y2); + int maxY = Math.max(y1, y2); + int minZ = Math.min(z1, z2); + int maxZ = Math.max(z1, z2); + + long volume = (long) (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1); + int maxVolume = config.getFeatures().getMaxScanVolume(); + if (volume > maxVolume) { + return McpToolResponse.error("Region volume " + volume + " exceeds maximum " + maxVolume + " blocks - shrink the box"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + JsonArray blocks = new JsonArray(); + int airCount = 0; + int unloadedCount = 0; + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockType blockType = world.getBlockType(x, y, z); + if (blockType == null) { + unloadedCount++; + } else if (blockType == BlockType.EMPTY) { + airCount++; + } else { + JsonObject block = new JsonObject(); + block.addProperty("x", x); + block.addProperty("y", y); + block.addProperty("z", z); + block.addProperty("blockType", blockType.getId()); + blocks.add(block); + } + } + } + } + + JsonObject response = new JsonObject(); + response.addProperty("volume", volume); + response.addProperty("nonAirCount", blocks.size()); + response.addProperty("airCount", airCount); + response.addProperty("unloadedCount", unloadedCount); + response.add("blocks", blocks); + + logger.atInfo().log("[SCAN_REGION] Scanned " + volume + " positions (" + + blocks.size() + " non-air, " + airCount + " air, " + unloadedCount + " unloaded)"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[SCAN_REGION] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 49035557dd761bd213fbdbd48b2def786113c157 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 18:01:53 -0500 Subject: [PATCH 05/17] Add break_blocks_batch tool: clear many blocks in one call One-at-a-time break_block calls became impractical for cleaning up large builds (a 450-block sphere shell). Mirrors set_blocks_batch's shape but calls breakBlock per coordinate, reusing the existing breakBlock permission flag rather than adding a new one. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../mcp/features/BreakBlocksBatchFeature.java | 197 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlocksBatchFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 7b851b9..03faaac 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -88,6 +88,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new BroadcastMessageFeature(logger, config)); featureRegistry.registerFeature(new SetBlockFeature(logger)); featureRegistry.registerFeature(new BreakBlockFeature(logger)); + featureRegistry.registerFeature(new BreakBlocksBatchFeature(logger, config)); featureRegistry.registerFeature(new SetBlocksBatchFeature(logger, config)); featureRegistry.registerFeature(new FlattenTerrainFeature(logger, config)); featureRegistry.registerFeature(new GetBuildingGuideFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlocksBatchFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlocksBatchFeature.java new file mode 100644 index 0000000..5f19187 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/BreakBlocksBatchFeature.java @@ -0,0 +1,197 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Bulk counterpart to break_block, mirroring set_blocks_batch's shape - clears up to + * {@code maxBlocksBatch} coordinates back to air in one call instead of one round-trip per block. + */ +public class BreakBlocksBatchFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + private final McpConfig config; + + public BreakBlocksBatchFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "break_blocks_batch"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "break_blocks_batch", + "Clears up to " + config.getFeatures().getMaxBlocksBatch() + " blocks back to air in one call - the batch counterpart to break_block.", + "function" + ); + } + + @Override + public String getInputSchema() { + var coordSchema = McpToolSchema.objectProperty( + java.util.Map.of( + "x", McpToolSchema.integerProperty("X coordinate"), + "y", McpToolSchema.integerProperty("Y coordinate"), + "z", McpToolSchema.integerProperty("Z coordinate") + ), + java.util.List.of("x", "y", "z"), + "Coordinate to clear" + ); + + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "coords", McpToolSchema.arrayProperty(coordSchema, "List of coordinates to clear (max " + config.getFeatures().getMaxBlocksBatch() + ")") + ), + java.util.List.of("world", "coords") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object coordsObj = call.getArguments().get("coords"); + String worldUuidStr = getArgumentAsString(call, "world"); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + if (coordsObj == null) { + return McpToolResponse.error("coords array is required"); + } + + JsonArray coords; + try { + if (coordsObj instanceof JsonArray) { + coords = (JsonArray) coordsObj; + } else if (coordsObj instanceof List) { + coords = GSON.toJsonTree(coordsObj).getAsJsonArray(); + } else { + JsonElement element = GSON.toJsonTree(coordsObj); + if (element.isJsonArray()) { + coords = element.getAsJsonArray(); + } else { + return McpToolResponse.error("coords must be an array"); + } + } + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error parsing coords array"); + return McpToolResponse.error("Invalid coords format: " + e.getMessage()); + } + + if (coords.size() == 0) { + return McpToolResponse.error("coords array cannot be empty"); + } + + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + if (coords.size() > maxBlocks) { + return McpToolResponse.error("Maximum " + maxBlocks + " coordinates per request"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + JsonArray results = new JsonArray(); + int successCount = 0; + int failureCount = 0; + + for (int i = 0; i < coords.size(); i++) { + JsonObject coord = coords.get(i).getAsJsonObject(); + + int x = coord.get("x").getAsInt(); + int y = coord.get("y").getAsInt(); + int z = coord.get("z").getAsInt(); + + try { + boolean changed = world.breakBlock(x, y, z, 0); + successCount++; + + JsonObject result = new JsonObject(); + result.addProperty("x", x); + result.addProperty("y", y); + result.addProperty("z", z); + result.addProperty("changed", changed); + result.addProperty("status", "success"); + results.add(result); + } catch (Exception e) { + failureCount++; + JsonObject result = new JsonObject(); + result.addProperty("x", x); + result.addProperty("y", y); + result.addProperty("z", z); + result.addProperty("status", "error"); + result.addProperty("message", e.getMessage()); + results.add(result); + } + } + + JsonObject response = new JsonObject(); + response.addProperty("total", coords.size()); + response.addProperty("success", successCount); + response.addProperty("failed", failureCount); + response.add("results", results); + + logger.atInfo().log("[BREAK_BLOCKS_BATCH] Processed " + coords.size() + + " coordinates (success: " + successCount + ", failed: " + failureCount + ")"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[BREAK_BLOCKS_BATCH] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canBreakBlock(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canBreakBlock(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From f28f90e6ab583e961b5dd5e663aa9d0a444fa655 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 18:40:36 -0500 Subject: [PATCH 06/17] Add get_heightmap tool: compact terrain-shape queries scan_region returns every block (mostly air) in a bounded box, wrong shaped for terrain-following builds like roads. This exposes the engine's own maintained heightmap (WorldChunk.getHeight) directly - an O(1) per-column lookup instead of scanning down from the top - giving surface height + block type per X/Z column, with an optional stride for coarse scans over large areas. Co-Authored-By: Claude Sonnet 5 --- config.example.json | 9 +- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../hytale/plugins/mcp/config/McpConfig.java | 18 ++ .../mcp/features/GetHeightmapFeature.java | 211 ++++++++++++++++++ 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java diff --git a/config.example.json b/config.example.json index d5055d5..60dc66f 100644 --- a/config.example.json +++ b/config.example.json @@ -23,7 +23,8 @@ "getServerInfo": false, "getBlock": false, "breakBlock": false, - "scanRegion": false + "scanRegion": false, + "getHeightmap": false }, "admins": { "listPlayers": true, @@ -39,9 +40,11 @@ "getServerInfo": true, "getBlock": true, "breakBlock": true, - "scanRegion": true + "scanRegion": true, + "getHeightmap": true }, "maxBlocksBatch": 1000, - "maxScanVolume": 32768 + "maxScanVolume": 32768, + "maxHeightmapSamples": 10000 } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 03faaac..a816e2a 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -83,6 +83,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new ListBlocksFeature(logger)); featureRegistry.registerFeature(new GetBlockFeature(logger)); featureRegistry.registerFeature(new ScanRegionFeature(logger, config)); + featureRegistry.registerFeature(new GetHeightmapFeature(logger, config)); featureRegistry.registerFeature(new ExecuteCommandFeature(logger, config)); featureRegistry.registerFeature(new GiveItemFeature(logger, config)); featureRegistry.registerFeature(new BroadcastMessageFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 039c208..67f3bc9 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -142,6 +142,7 @@ public static class FeaturesConfig { private FeaturePermissions admins = new FeaturePermissions(); private int maxBlocksBatch = 1000; private int maxScanVolume = 32768; + private int maxHeightmapSamples = 10000; public FeaturePermissions getPlayers() { return players; @@ -174,6 +175,14 @@ public int getMaxScanVolume() { public void setMaxScanVolume(int maxScanVolume) { this.maxScanVolume = maxScanVolume; } + + public int getMaxHeightmapSamples() { + return maxHeightmapSamples; + } + + public void setMaxHeightmapSamples(int maxHeightmapSamples) { + this.maxHeightmapSamples = maxHeightmapSamples; + } } public static class FeaturePermissions { @@ -191,6 +200,7 @@ public static class FeaturePermissions { private boolean getBlock = false; private boolean breakBlock = false; private boolean scanRegion = false; + private boolean getHeightmap = false; public boolean canListPlayers() { return listPlayers; @@ -303,5 +313,13 @@ public boolean canScanRegion() { public void setScanRegion(boolean scanRegion) { this.scanRegion = scanRegion; } + + public boolean canGetHeightmap() { + return getHeightmap; + } + + public void setGetHeightmap(boolean getHeightmap) { + this.getHeightmap = getHeightmap; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java new file mode 100644 index 0000000..38a32ca --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java @@ -0,0 +1,211 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Reports ground surface height (+ top block type) per column over an X/Z area - a compact 2D + * alternative to scan_region for terrain-following builds (roads, rivers, settlement siting) where + * a full 3D block dump would be mostly air and wildly oversized for the question being asked. + * + *

Uses {@code WorldChunk.getHeight(localX, localZ)} - the engine's own maintained heightmap - for + * an O(1) lookup per column instead of scanning blocks top-down. {@code World} doesn't expose this + * directly; it's reached via {@code getChunkIfLoaded(ChunkUtil.indexChunkFromBlock(x, z))} then + * {@code chunk.getHeight(x & ChunkUtil.SIZE_MASK, z & ChunkUtil.SIZE_MASK)}. + */ +public class GetHeightmapFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + private final McpConfig config; + + public GetHeightmapFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "get_heightmap"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "get_heightmap", + "Gets the ground surface height and top block type for every column in an X/Z area - a compact terrain-shape query for roads/rivers/siting, much lighter than scan_region for the same area. Max " + + config.getFeatures().getMaxHeightmapSamples() + " sampled columns; use the stride parameter for a coarse scan over a large area.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "x1", McpToolSchema.integerProperty("First corner X coordinate"), + "z1", McpToolSchema.integerProperty("First corner Z coordinate"), + "x2", McpToolSchema.integerProperty("Opposite corner X coordinate"), + "z2", McpToolSchema.integerProperty("Opposite corner Z coordinate"), + "stride", McpToolSchema.integerProperty("Sample every Nth block per axis (optional, default 1 - use a larger stride for a coarse scan over a large area)"), + "world", McpToolSchema.stringProperty("World UUID") + ), + java.util.List.of("x1", "z1", "x2", "z2", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + + int x1 = getArgumentAsInt(call, "x1"); + int z1 = getArgumentAsInt(call, "z1"); + int x2 = getArgumentAsInt(call, "x2"); + int z2 = getArgumentAsInt(call, "z2"); + Integer strideArg = getArgumentAsInteger(call, "stride"); + int stride = (strideArg == null || strideArg < 1) ? 1 : strideArg; + String worldUuidStr = getArgumentAsString(call, "world"); + + if (x1 == Integer.MIN_VALUE || z1 == Integer.MIN_VALUE || x2 == Integer.MIN_VALUE || z2 == Integer.MIN_VALUE) { + return McpToolResponse.error("x1, z1, x2 and z2 are required integers"); + } + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + int minX = Math.min(x1, x2); + int maxX = Math.max(x1, x2); + int minZ = Math.min(z1, z2); + int maxZ = Math.max(z1, z2); + + long samplesX = (long) (maxX - minX) / stride + 1; + long samplesZ = (long) (maxZ - minZ) / stride + 1; + long totalSamples = samplesX * samplesZ; + int maxSamples = config.getFeatures().getMaxHeightmapSamples(); + if (totalSamples > maxSamples) { + return McpToolResponse.error("Sample count " + totalSamples + " exceeds maximum " + maxSamples + " - shrink the area or increase stride"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + JsonArray columns = new JsonArray(); + int unloadedCount = 0; + + for (int x = minX; x <= maxX; x += stride) { + for (int z = minZ; z <= maxZ; z += stride) { + JsonObject column = new JsonObject(); + column.addProperty("x", x); + column.addProperty("z", z); + + WorldChunk chunk = world.getChunkIfLoaded(ChunkUtil.indexChunkFromBlock(x, z)); + if (chunk == null) { + column.addProperty("loaded", false); + unloadedCount++; + columns.add(column); + continue; + } + + int localX = x & ChunkUtil.SIZE_MASK; + int localZ = z & ChunkUtil.SIZE_MASK; + short surfaceY = chunk.getHeight(localX, localZ); + + column.addProperty("loaded", true); + column.addProperty("surfaceY", surfaceY); + + BlockType surfaceType = world.getBlockType(x, surfaceY, z); + if (surfaceType != null && surfaceType != BlockType.EMPTY) { + column.addProperty("blockType", surfaceType.getId()); + } else { + column.add("blockType", null); + } + + columns.add(column); + } + } + + JsonObject response = new JsonObject(); + response.addProperty("totalSamples", totalSamples); + response.addProperty("unloadedCount", unloadedCount); + response.add("columns", columns); + + logger.atInfo().log("[GET_HEIGHTMAP] Sampled " + totalSamples + " columns (" + unloadedCount + " unloaded)"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[GET_HEIGHTMAP] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canGetHeightmap(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canGetHeightmap(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private Integer getArgumentAsInteger(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + if (value instanceof Number) return ((Number) value).intValue(); + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 8b1d6509189b4c0a3bb264e29b7a5e4f9c9e6558 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 13 Jul 2026 23:12:59 -0500 Subject: [PATCH 07/17] Add fluid detection, foliage-skip, real hytaleVersion, block rotation, and auto break-then-place Found live while building a road/bridge on Willikins: get_block/scan_region/get_heightmap were blind to water (fluids live on a separate per-chunk channel from BlockType), get_heightmap reported raw tree-canopy height instead of real ground, and set_block/ set_blocks_batch had no way to control facing (rotation was always hardcoded to None) and could silently fail to persist when overwriting an already-solid block. Each fix was confirmed live: fluid detection found real water a "dry" gorge scan had missed, rotation control fixed backwards bridge railings, and auto break-then-place was verified by overwriting solid ground with a single set_block call with no manual break step. Co-Authored-By: Claude Sonnet 5 --- .../plugins/mcp/features/GetBlockFeature.java | 29 +++++- .../mcp/features/GetHeightmapFeature.java | 97 +++++++++++++++++-- .../mcp/features/GetServerInfoFeature.java | 4 + .../plugins/mcp/features/McpToolSchema.java | 7 ++ .../mcp/features/ScanRegionFeature.java | 38 ++++++-- .../plugins/mcp/features/SetBlockFeature.java | 48 +++++++-- .../mcp/features/SetBlocksBatchFeature.java | 45 ++++++++- 7 files changed, 241 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java index 1e2163c..e410993 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetBlockFeature.java @@ -3,9 +3,12 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.fluid.Fluid; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; @@ -34,7 +37,7 @@ public String getName() { public McpTool getToolDefinition() { return new McpTool( "get_block", - "Gets the block type actually placed at specific world coordinates - the read counterpart to set_block.", + "Gets the block type actually placed at specific world coordinates - the read counterpart to set_block. Also reports fluid presence/type/level, which is tracked separately from block type and would otherwise read as plain air.", "function" ); } @@ -88,23 +91,26 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { // condition, just unresolvable right now) - distinct from BlockType.EMPTY, which // means the chunk is loaded and the position is genuinely air. BlockType blockType = world.getBlockType(x, y, z); + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); JsonObject json = new JsonObject(); json.addProperty("x", x); json.addProperty("y", y); json.addProperty("z", z); - if (blockType == null) { + if (blockType == null || chunk == null) { json.addProperty("loaded", false); json.add("blockType", null); } else if (blockType == BlockType.EMPTY) { json.addProperty("loaded", true); json.addProperty("air", true); json.add("blockType", null); + addFluidInfo(json, chunk, x, y, z); } else { json.addProperty("loaded", true); json.addProperty("air", false); json.addProperty("blockType", blockType.getId()); + addFluidInfo(json, chunk, x, y, z); } future.complete(McpToolResponse.success(GSON.toJson(json))); @@ -129,6 +135,25 @@ public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig confi return false; } + /** + * Fluid presence/type/level lives on a separate data channel from {@code BlockType} in this + * engine - a position can read as plain air via {@code getBlockType} while still being full of + * water, since fluids are tracked per-chunk via {@code WorldChunk.getFluidId}/{@code + * getFluidLevel} rather than occupying a distinct {@code BlockType}. Confirmed the hard way: a + * player standing in visibly 2-block-deep water had {@code get_block} report plain air at their + * feet before this was added. + */ + private void addFluidInfo(JsonObject json, WorldChunk chunk, int x, int y, int z) { + int fluidId = chunk.getFluidId(x, y, z); + boolean hasFluid = fluidId != Fluid.EMPTY_ID; + json.addProperty("hasFluid", hasFluid); + if (hasFluid) { + Fluid fluid = Fluid.getAssetMap().getAsset(fluidId); + json.addProperty("fluidType", fluid != null ? fluid.getId() : null); + json.addProperty("fluidLevel", chunk.getFluidLevel(x, y, z)); + } + } + private int getArgumentAsInt(McpToolCall call, String key) { try { Object value = call.getArguments().get(key); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java index 38a32ca..72b2b6c 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetHeightmapFeature.java @@ -6,6 +6,7 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.fluid.Fluid; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; @@ -26,12 +27,41 @@ * *

Uses {@code WorldChunk.getHeight(localX, localZ)} - the engine's own maintained heightmap - for * an O(1) lookup per column instead of scanning blocks top-down. {@code World} doesn't expose this - * directly; it's reached via {@code getChunkIfLoaded(ChunkUtil.indexChunkFromBlock(x, z))} then + * directly; it's reached via {@code getChunk(ChunkUtil.indexChunkFromBlock(x, z))} then * {@code chunk.getHeight(x & ChunkUtil.SIZE_MASK, z & ChunkUtil.SIZE_MASK)}. + * + *

Deliberately calls {@code World.getChunk(long)}, not {@code getChunkIfLoaded(long)} - the + * latter returns {@code null} for an unloaded chunk instead of loading it, which made this tool + * silently fail (report every column as unloaded) whenever no player was nearby to keep chunks + * loaded, even though {@code get_block}/{@code scan_region} worked fine in the same situation. + * Confirmed via decompiling {@code IChunkAccessorSync.getBlock} (which {@code getBlockType} calls + * internally) that it forces a load through this exact same {@code getChunk(long)} default method - + * {@code getChunkIfLoaded} was just the wrong call for a tool that's supposed to work regardless of + * player proximity. {@code getChunk} blocks synchronously (safe from the world thread specifically - + * it pumps the world's own task queue while waiting rather than deadlocking) until the chunk loads, + * so worst-case latency scales with how many distinct, currently-unloaded chunks a call touches; + * {@code maxHeightmapSamples} already bounds that. + * + *

The engine's own heightmap reports the literal topmost block, which is overhanging tree canopy + * ({@code Plant_Leaves_*}) more often than not near vegetation - by default this walks down past + * canopy block types to report the block a builder would actually stand on, keeping the raw canopy + * top too so callers can tell it happened. {@link #FOLIAGE_ID_SUBSTRING} is a hardcoded guess at + * every leaf-type id in the current pack; re-derive it (grep the live asset registry / {@code + * list_blocks} for {@code Leaves}) against {@code get_server_info}'s {@code hytaleVersion} whenever + * that version changes, since new tree species could ship under a different naming scheme. + * + *

Also reports the water surface (if any) above each column's ground: fluids live on a separate + * per-chunk data channel from block type in this engine ({@code WorldChunk.getFluidId}/{@code + * getFluidLevel}), so a lake bed can be all `chunk.getHeight` ever reports, with the actual water + * surface floating invisibly above it as far as block-type-only tools are concerned. Confirmed live: + * a player standing in visibly 2-block-deep water had every block-type-based tool report plain air. */ public class GetHeightmapFeature implements McpFeature { private static final Gson GSON = new Gson(); + private static final String FOLIAGE_ID_SUBSTRING = "_Leaves_"; + private static final int MAX_FOLIAGE_SKIP_DEPTH = 24; + private static final int MAX_FLUID_SCAN_HEIGHT = 16; private final HytaleLogger logger; private final McpConfig config; @@ -49,7 +79,7 @@ public String getName() { public McpTool getToolDefinition() { return new McpTool( "get_heightmap", - "Gets the ground surface height and top block type for every column in an X/Z area - a compact terrain-shape query for roads/rivers/siting, much lighter than scan_region for the same area. Max " + "Gets the ground surface height and top block type for every column in an X/Z area, plus the water surface height/type above it if any (fluids are tracked separately from block type, so they'd otherwise be invisible) - a compact terrain-shape query for roads/rivers/siting, much lighter than scan_region for the same area. Max " + config.getFeatures().getMaxHeightmapSamples() + " sampled columns; use the stride parameter for a coarse scan over a large area.", "function" ); @@ -64,7 +94,8 @@ public String getInputSchema() { "x2", McpToolSchema.integerProperty("Opposite corner X coordinate"), "z2", McpToolSchema.integerProperty("Opposite corner Z coordinate"), "stride", McpToolSchema.integerProperty("Sample every Nth block per axis (optional, default 1 - use a larger stride for a coarse scan over a large area)"), - "world", McpToolSchema.stringProperty("World UUID") + "world", McpToolSchema.stringProperty("World UUID"), + "skipFoliage", McpToolSchema.booleanProperty("Walk down past overhanging tree canopy to report the actual ground surface instead of the literal topmost block (optional, default true)") ), java.util.List.of("x1", "z1", "x2", "z2", "world") ); @@ -80,6 +111,8 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { Integer strideArg = getArgumentAsInteger(call, "stride"); int stride = (strideArg == null || strideArg < 1) ? 1 : strideArg; String worldUuidStr = getArgumentAsString(call, "world"); + Boolean skipFoliageArg = getArgumentAsBoolean(call, "skipFoliage"); + boolean skipFoliage = skipFoliageArg == null || skipFoliageArg; if (x1 == Integer.MIN_VALUE || z1 == Integer.MIN_VALUE || x2 == Integer.MIN_VALUE || z2 == Integer.MIN_VALUE) { return McpToolResponse.error("x1, z1, x2 and z2 are required integers"); @@ -127,7 +160,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { column.addProperty("x", x); column.addProperty("z", z); - WorldChunk chunk = world.getChunkIfLoaded(ChunkUtil.indexChunkFromBlock(x, z)); + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); if (chunk == null) { column.addProperty("loaded", false); unloadedCount++; @@ -137,18 +170,49 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { int localX = x & ChunkUtil.SIZE_MASK; int localZ = z & ChunkUtil.SIZE_MASK; - short surfaceY = chunk.getHeight(localX, localZ); + short canopyTopY = chunk.getHeight(localX, localZ); column.addProperty("loaded", true); - column.addProperty("surfaceY", surfaceY); - BlockType surfaceType = world.getBlockType(x, surfaceY, z); - if (surfaceType != null && surfaceType != BlockType.EMPTY) { - column.addProperty("blockType", surfaceType.getId()); + int groundY = canopyTopY; + BlockType groundType = world.getBlockType(x, groundY, z); + int skipped = 0; + if (skipFoliage) { + while (isFoliageCanopy(groundType) && skipped < MAX_FOLIAGE_SKIP_DEPTH) { + groundY--; + groundType = world.getBlockType(x, groundY, z); + skipped++; + } + } + + column.addProperty("surfaceY", groundY); + if (groundType != null && groundType != BlockType.EMPTY) { + column.addProperty("blockType", groundType.getId()); } else { column.add("blockType", null); } + if (skipped > 0) { + column.addProperty("canopyTopY", (int) canopyTopY); + BlockType canopyType = world.getBlockType(x, canopyTopY, z); + column.addProperty("canopyBlockType", canopyType != null ? canopyType.getId() : null); + } + + Integer waterSurfaceY = null; + String fluidType = null; + for (int fy = groundY + 1; fy <= groundY + MAX_FLUID_SCAN_HEIGHT; fy++) { + int fluidId = chunk.getFluidId(x, fy, z); + if (fluidId != Fluid.EMPTY_ID) { + waterSurfaceY = fy; + Fluid fluid = Fluid.getAssetMap().getAsset(fluidId); + fluidType = fluid != null ? fluid.getId() : null; + } + } + if (waterSurfaceY != null) { + column.addProperty("waterSurfaceY", waterSurfaceY); + column.addProperty("fluidType", fluidType); + } + columns.add(column); } } @@ -193,6 +257,21 @@ private int getArgumentAsInt(McpToolCall call, String key) { } } + private boolean isFoliageCanopy(BlockType type) { + if (type == null || type == BlockType.EMPTY) { + return false; + } + String id = type.getId(); + return id != null && id.contains(FOLIAGE_ID_SUBSTRING); + } + + private Boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } + private Integer getArgumentAsInteger(McpToolCall call, String key) { Object value = call.getArguments().get(key); if (value == null) return null; diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetServerInfoFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetServerInfoFeature.java index c032649..f0b8aee 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetServerInfoFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetServerInfoFeature.java @@ -5,6 +5,7 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.common.plugin.PluginIdentifier; +import com.hypixel.hytale.common.util.java.ManifestUtil; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; import com.top_serveurs.hytale.plugins.mcp.models.McpTool; @@ -46,6 +47,9 @@ public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLe JsonObject response = new JsonObject(); response.addProperty("name", pluginId.getName()); response.addProperty("version", "1.0.0"); + response.addProperty("hytaleVersion", ManifestUtil.getImplementationVersion()); + response.addProperty("hytalePatchline", ManifestUtil.getPatchline()); + response.addProperty("hytaleRevision", ManifestUtil.getImplementationRevisionId()); response.addProperty("uptime", getUptime()); response.addProperty("tps", getTps()); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/McpToolSchema.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/McpToolSchema.java index b0107dd..e6a2989 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/McpToolSchema.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/McpToolSchema.java @@ -54,6 +54,13 @@ public static JsonObject integerProperty(String description) { return schema; } + public static JsonObject booleanProperty(String description) { + JsonObject schema = new JsonObject(); + schema.addProperty("type", "boolean"); + addDescription(schema, description); + return schema; + } + public static JsonObject arrayProperty(JsonObject items, String description) { JsonObject schema = new JsonObject(); schema.addProperty("type", "array"); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java index 9a0ee3a..c1009d5 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ScanRegionFeature.java @@ -4,9 +4,12 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.fluid.Fluid; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; @@ -24,6 +27,10 @@ * a large box would blow up payload size for little value, since the caller almost always wants * "what's actually built here", not a full lattice. * + *

Also reports fluid (water/lava) presence separately from block type - this engine tracks + * fluids on their own per-chunk data channel ({@code WorldChunk.getFluidId}/{@code getFluidLevel}), + * so a position can be full of water while {@code getBlockType} still reports plain air there. + * *

Volume is capped by {@code maxScanVolume} (mirrors set_blocks_batch's maxBlocksBatch cap) to * bound both response size and worst-case blocking time: each position not yet resolved triggers * getBlockType's normal chunk-load-on-demand behavior, so a very large box spanning many unloaded @@ -50,7 +57,7 @@ public String getName() { public McpTool getToolDefinition() { return new McpTool( "scan_region", - "Scans a bounding box (any two opposite corners) and reports every non-air block found, plus counts of air/unloaded positions. Max volume " + "Scans a bounding box (any two opposite corners) and reports every non-air block found plus every position with fluid (water/lava - tracked separately from block type, so a position can be fluid-filled while reading as air), plus counts of air/unloaded positions. Max volume " + config.getFeatures().getMaxScanVolume() + " blocks. Use this instead of repeated get_block calls to check whether a structure is actually built as expected.", "function" ); @@ -122,16 +129,33 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { world.execute(() -> { try { JsonArray blocks = new JsonArray(); + JsonArray fluids = new JsonArray(); int airCount = 0; int unloadedCount = 0; for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - for (int z = minZ; z <= maxZ; z++) { + for (int z = minZ; z <= maxZ; z++) { + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); + for (int y = minY; y <= maxY; y++) { BlockType blockType = world.getBlockType(x, y, z); - if (blockType == null) { + if (blockType == null || chunk == null) { unloadedCount++; - } else if (blockType == BlockType.EMPTY) { + continue; + } + + int fluidId = chunk.getFluidId(x, y, z); + if (fluidId != Fluid.EMPTY_ID) { + JsonObject fluidBlock = new JsonObject(); + fluidBlock.addProperty("x", x); + fluidBlock.addProperty("y", y); + fluidBlock.addProperty("z", z); + Fluid fluid = Fluid.getAssetMap().getAsset(fluidId); + fluidBlock.addProperty("fluidType", fluid != null ? fluid.getId() : null); + fluidBlock.addProperty("fluidLevel", chunk.getFluidLevel(x, y, z)); + fluids.add(fluidBlock); + } + + if (blockType == BlockType.EMPTY) { airCount++; } else { JsonObject block = new JsonObject(); @@ -148,12 +172,14 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { JsonObject response = new JsonObject(); response.addProperty("volume", volume); response.addProperty("nonAirCount", blocks.size()); + response.addProperty("fluidCount", fluids.size()); response.addProperty("airCount", airCount); response.addProperty("unloadedCount", unloadedCount); response.add("blocks", blocks); + response.add("fluids", fluids); logger.atInfo().log("[SCAN_REGION] Scanned " + volume + " positions (" - + blocks.size() + " non-air, " + airCount + " air, " + unloadedCount + " unloaded)"); + + blocks.size() + " non-air, " + fluids.size() + " fluid, " + airCount + " air, " + unloadedCount + " unloaded)"); future.complete(McpToolResponse.success(GSON.toJson(response))); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java index 531ffe1..5e8287b 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java @@ -3,9 +3,12 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.Rotation; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; @@ -34,7 +37,7 @@ public String getName() { public McpTool getToolDefinition() { return new McpTool( "set_block", - "Sets a block at specified world coordinates. ", + "Sets a block at specified world coordinates. Optional 'rotation' controls facing for directional blocks (fences, stairs, etc) - one of None/Ninety/OneEighty/TwoSeventy, a 90-degree yaw step; defaults to None. ", "function" ); } @@ -47,6 +50,7 @@ public String getInputSchema() { "y", McpToolSchema.integerProperty("Y coordinate"), "z", McpToolSchema.integerProperty("Z coordinate"), "blockType", McpToolSchema.stringProperty("Block type identifier"), + "rotation", McpToolSchema.stringProperty("Optional yaw rotation for directional blocks: None, Ninety, OneEighty, or TwoSeventy. Defaults to None."), "world", McpToolSchema.stringProperty("World UUID") ), java.util.List.of("x", "y", "z", "blockType", "world") @@ -61,6 +65,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { int z = getArgumentAsInt(call, "z"); String blockTypeStr = getArgumentAsString(call, "blockType"); String worldUuidStr = getArgumentAsString(call, "world"); + String rotationStr = getArgumentAsString(call, "rotation"); if (x == Integer.MIN_VALUE || y == Integer.MIN_VALUE || z == Integer.MIN_VALUE) { return McpToolResponse.error("x, y and z are required integers"); @@ -79,6 +84,11 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { return McpToolResponse.error("Unknown block type: " + blockTypeStr); } + Rotation rotation = parseRotation(rotationStr); + if (rotation == null) { + return McpToolResponse.error("Invalid rotation: " + rotationStr + " (expected None, Ninety, OneEighty, or TwoSeventy)"); + } + UUID worldUuid; try { worldUuid = UUID.fromString(worldUuidStr); @@ -99,19 +109,30 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { "[SET_BLOCK] Using WORLD API at (" + x + "," + y + "," + z + ")" ); - world.setBlock( - x, - y, - z, - blockType.getId(), - 0 - ); + // world.setBlock's default IChunkAccessorSync implementation hardcodes rotation + // to None - the only way to set a non-default facing is BlockAccessor.placeBlock + // on the chunk directly, which takes yaw/pitch/roll Rotation values. + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); + if (chunk == null) { + future.complete(McpToolResponse.error("Chunk not loaded at (" + x + "," + y + "," + z + ")")); + return; + } + // Overwriting an already-solid block in place (same or different blockType) can + // silently fail to persist - confirmed by immediate read-back mismatches on both + // rotation-only and full blockType changes. Breaking to air first reliably avoids + // this, so always do it when the target isn't already air. + BlockType existing = chunk.getBlockType(x, y, z); + if (existing != null && existing != BlockType.EMPTY) { + chunk.breakBlock(x, y, z, 0); + } + chunk.placeBlock(x, y, z, blockType.getId(), rotation, Rotation.None, Rotation.None, 0); JsonObject json = new JsonObject(); json.addProperty("x", x); json.addProperty("y", y); json.addProperty("z", z); json.addProperty("blockType", blockTypeStr); + json.addProperty("rotation", rotation.name()); future.complete(McpToolResponse.success(GSON.toJson(json))); @@ -150,4 +171,15 @@ private String getArgumentAsString(McpToolCall call, String key) { Object value = call.getArguments().get(key); return value != null ? value.toString() : null; } + + static Rotation parseRotation(String rotationStr) { + if (rotationStr == null || rotationStr.isEmpty()) { + return Rotation.None; + } + try { + return Rotation.valueOf(rotationStr); + } catch (IllegalArgumentException e) { + return null; + } + } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java index 84de312..2b49a68 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java @@ -5,9 +5,12 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.Rotation; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; @@ -51,7 +54,8 @@ public String getInputSchema() { "x", McpToolSchema.integerProperty("X coordinate"), "y", McpToolSchema.integerProperty("Y coordinate"), "z", McpToolSchema.integerProperty("Z coordinate"), - "blockType", McpToolSchema.stringProperty("Block type identifier") + "blockType", McpToolSchema.stringProperty("Block type identifier"), + "rotation", McpToolSchema.stringProperty("Optional yaw rotation for directional blocks: None, Ninety, OneEighty, or TwoSeventy. Defaults to None.") ), java.util.List.of("x", "y", "z", "blockType"), "Block placement description" @@ -134,6 +138,8 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { int y = blockData.get("y").getAsInt(); int z = blockData.get("z").getAsInt(); String blockTypeStr = blockData.get("blockType").getAsString(); + String rotationStr = blockData.has("rotation") && !blockData.get("rotation").isJsonNull() + ? blockData.get("rotation").getAsString() : null; BlockType blockType = BlockType.getAssetMap().getAsset(blockTypeStr); if (blockType == null || blockType == BlockType.EMPTY) { @@ -148,8 +154,42 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { continue; } + Rotation rotation = SetBlockFeature.parseRotation(rotationStr); + if (rotation == null) { + failureCount++; + JsonObject result = new JsonObject(); + result.addProperty("x", x); + result.addProperty("y", y); + result.addProperty("z", z); + result.addProperty("status", "error"); + result.addProperty("message", "Invalid rotation: " + rotationStr); + results.add(result); + continue; + } + try { - world.setBlock(x, y, z, blockType.getId(), 0); + // See SetBlockFeature: world.setBlock always hardcodes rotation to None; + // placeBlock on the chunk's BlockAccessor is the only way to set facing. + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); + if (chunk == null) { + failureCount++; + JsonObject result = new JsonObject(); + result.addProperty("x", x); + result.addProperty("y", y); + result.addProperty("z", z); + result.addProperty("status", "error"); + result.addProperty("message", "Chunk not loaded"); + results.add(result); + continue; + } + // Overwriting an already-solid block in place can silently fail to persist + // (confirmed for both rotation-only and full blockType changes) - break it + // to air first whenever the target isn't already air. + BlockType existing = chunk.getBlockType(x, y, z); + if (existing != null && existing != BlockType.EMPTY) { + chunk.breakBlock(x, y, z, 0); + } + chunk.placeBlock(x, y, z, blockType.getId(), rotation, Rotation.None, Rotation.None, 0); successCount++; JsonObject result = new JsonObject(); @@ -157,6 +197,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { result.addProperty("y", y); result.addProperty("z", z); result.addProperty("blockType", blockTypeStr); + result.addProperty("rotation", rotation.name()); result.addProperty("status", "success"); results.add(result); } catch (Exception e) { From a17e07115ab203fe4181e73fcae890571f0d5f1f Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Tue, 14 Jul 2026 15:03:08 -0500 Subject: [PATCH 08/17] Fix overwrite-in-place by using placeBlock's test=false overload directly The break-then-place workaround masked the real cause: the 8-arg placeBlock convenience overload always runs with an internal occupancy check (test=true) that silently rejects placement over any already-solid block. Calling the 7-arg overload with test=false skips that check and writes straight through for both air and occupied targets in one call, and now surfaces a real error if placeBlock still returns false instead of discarding the result. Deployed and verified live on Willikins across single-overwrite, rotation-only-overwrite, and batch-overwrite scenarios. Co-Authored-By: Claude Sonnet 5 --- .../plugins/mcp/features/SetBlockFeature.java | 21 +++++++++------ .../mcp/features/SetBlocksBatchFeature.java | 26 ++++++++++++++----- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java index 5e8287b..0ef5f54 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlockFeature.java @@ -6,6 +6,7 @@ import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; import com.hypixel.hytale.server.core.asset.type.blocktype.config.Rotation; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.RotationTuple; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; @@ -117,15 +118,19 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { future.complete(McpToolResponse.error("Chunk not loaded at (" + x + "," + y + "," + z + ")")); return; } - // Overwriting an already-solid block in place (same or different blockType) can - // silently fail to persist - confirmed by immediate read-back mismatches on both - // rotation-only and full blockType changes. Breaking to air first reliably avoids - // this, so always do it when the target isn't already air. - BlockType existing = chunk.getBlockType(x, y, z); - if (existing != null && existing != BlockType.EMPTY) { - chunk.breakBlock(x, y, z, 0); + // The 8-arg placeBlock convenience overload always runs with an internal + // test=true occupancy check (testPlaceBlock), which rejects placement over any + // already-solid block and returns false without writing - previously worked + // around by breaking to air first. The 7-arg overload exposes the test flag + // directly; passing test=false skips the occupancy check and writes straight + // through for both air and occupied targets in one call, no break needed. + RotationTuple rotationTuple = RotationTuple.of(rotation, Rotation.None, Rotation.None); + boolean placed = chunk.placeBlock(x, y, z, blockType.getId(), rotationTuple, 0, false); + + if (!placed) { + future.complete(McpToolResponse.error("placeBlock rejected the placement at (" + x + "," + y + "," + z + ")")); + return; } - chunk.placeBlock(x, y, z, blockType.getId(), rotation, Rotation.None, Rotation.None, 0); JsonObject json = new JsonObject(); json.addProperty("x", x); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java index 2b49a68..8f2b0b6 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetBlocksBatchFeature.java @@ -8,6 +8,7 @@ import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; import com.hypixel.hytale.server.core.asset.type.blocktype.config.Rotation; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.RotationTuple; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; @@ -182,14 +183,25 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { results.add(result); continue; } - // Overwriting an already-solid block in place can silently fail to persist - // (confirmed for both rotation-only and full blockType changes) - break it - // to air first whenever the target isn't already air. - BlockType existing = chunk.getBlockType(x, y, z); - if (existing != null && existing != BlockType.EMPTY) { - chunk.breakBlock(x, y, z, 0); + // The 8-arg placeBlock convenience overload always runs with an internal + // test=true occupancy check, which rejects placement over any already-solid + // block and silently no-ops - previously worked around with a break-first. + // The 7-arg overload's test=false skips that check and writes straight + // through for both air and occupied targets in one call. + RotationTuple rotationTuple = RotationTuple.of(rotation, Rotation.None, Rotation.None); + boolean placed = chunk.placeBlock(x, y, z, blockType.getId(), rotationTuple, 0, false); + + if (!placed) { + failureCount++; + JsonObject result = new JsonObject(); + result.addProperty("x", x); + result.addProperty("y", y); + result.addProperty("z", z); + result.addProperty("status", "error"); + result.addProperty("message", "placeBlock rejected the placement"); + results.add(result); + continue; } - chunk.placeBlock(x, y, z, blockType.getId(), rotation, Rotation.None, Rotation.None, 0); successCount++; JsonObject result = new JsonObject(); From 60e1447ec660d369cc25c1691429ed3bf48a08a9 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Wed, 15 Jul 2026 16:30:17 -0500 Subject: [PATCH 09/17] Add waypoint tools, verify_placement, and generate_road_corridor Waypoint tools (add/remove/list) give a persistent way to mark locations for later reference. verify_placement replaces the hand-rolled scan-then-diff pattern used repeatedly for checking build results, with an optional support check for floating blocks. generate_road_corridor computes width/elevation for a road from a waypoint chain using true perpendicular distance and per-cell forward-progress projection, so diagonal and angled segments get correct level cross-sections instead of the naive z-row approach that produces uneven diagonal roads. All tested live on Willikins. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 5 + .../hytale/plugins/mcp/config/McpConfig.java | 27 ++ .../mcp/features/AddWaypointFeature.java | 228 +++++++++++++ .../features/GenerateRoadCorridorFeature.java | 309 ++++++++++++++++++ .../mcp/features/ListWaypointsFeature.java | 172 ++++++++++ .../mcp/features/RemoveWaypointFeature.java | 169 ++++++++++ .../mcp/features/VerifyPlacementFeature.java | 238 ++++++++++++++ 7 files changed, 1148 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/AddWaypointFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateRoadCorridorFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListWaypointsFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/RemoveWaypointFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/VerifyPlacementFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index a816e2a..18fc56f 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -97,6 +97,11 @@ private void registerFeatures() { featureRegistry.registerFeature(new GetServerInfoFeature(logger, config, getIdentifier())); featureRegistry.registerFeature(new SendChatMessageFeature(logger)); featureRegistry.registerFeature(new GetLogsFeature(logger)); + featureRegistry.registerFeature(new AddWaypointFeature(logger)); + featureRegistry.registerFeature(new RemoveWaypointFeature(logger)); + featureRegistry.registerFeature(new ListWaypointsFeature(logger)); + featureRegistry.registerFeature(new VerifyPlacementFeature(logger, config)); + featureRegistry.registerFeature(new GenerateRoadCorridorFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 67f3bc9..cff6a68 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -201,6 +201,9 @@ public static class FeaturePermissions { private boolean breakBlock = false; private boolean scanRegion = false; private boolean getHeightmap = false; + private boolean addWaypoint = false; + private boolean removeWaypoint = false; + private boolean listWaypoints = false; public boolean canListPlayers() { return listPlayers; @@ -321,5 +324,29 @@ public boolean canGetHeightmap() { public void setGetHeightmap(boolean getHeightmap) { this.getHeightmap = getHeightmap; } + + public boolean canAddWaypoint() { + return addWaypoint; + } + + public void setAddWaypoint(boolean addWaypoint) { + this.addWaypoint = addWaypoint; + } + + public boolean canRemoveWaypoint() { + return removeWaypoint; + } + + public void setRemoveWaypoint(boolean removeWaypoint) { + this.removeWaypoint = removeWaypoint; + } + + public boolean canListWaypoints() { + return listWaypoints; + } + + public void setListWaypoints(boolean listWaypoints) { + this.listWaypoints = listWaypoints; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/AddWaypointFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/AddWaypointFeature.java new file mode 100644 index 0000000..e7449fa --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/AddWaypointFeature.java @@ -0,0 +1,228 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.Color; +import com.hypixel.hytale.protocol.packets.worldmap.CreateUserMarker; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.user.UserMapMarker; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.user.UserMapMarkersStore; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.user.UserMarkerValidator; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class AddWaypointFeature implements McpFeature { + private static final Gson GSON = new Gson(); + // The engine's internal null-icon fallback ("User1.png") is a broken asset that doesn't render + // on the map screen - see the comment where this constant is used. + private static final String DEFAULT_ICON = "UserA.png"; + private final HytaleLogger logger; + + public AddWaypointFeature(HytaleLogger logger) { + this.logger = logger; + } + + @Override + public String getName() { + return "add_waypoint"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "add_waypoint", + "Places a waypoint marker on a specific player's in-game world map (the 'M' key map) at the given world x/z coordinates. Personal by default (only that player sees it); set shared=true to make it visible to everyone. Returns the marker's id, needed later by remove_waypoint. Placement has a distance limit tied to the player's view radius - it must land within their currently visible range, not anywhere on the whole map.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + Map.of( + "player", McpToolSchema.stringProperty("Player name or UUID"), + "x", McpToolSchema.integerProperty("World X coordinate"), + "z", McpToolSchema.integerProperty("World Z coordinate"), + "name", McpToolSchema.stringProperty("Marker label shown on the map (max 24 characters)"), + "world", McpToolSchema.stringProperty("World UUID"), + "icon", McpToolSchema.stringProperty("Optional marker icon filename. Defaults to 'UserA.png' (the icon the in-game Quick Marker button uses) if omitted - NOT the engine's own internal default ('User1.png'), which is a broken/missing icon asset that silently fails to render on the map screen (confirmed live 2026-07-15)."), + "colorHex", McpToolSchema.stringProperty("Optional tint color as a hex string, e.g. '#ffcc00'. Defaults to no tint."), + "shared", McpToolSchema.booleanProperty("Optional - true makes the marker visible to all players, not just this one. Defaults to false.") + ), + List.of("player", "x", "z", "name", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLevel) { + try { + Map args = call.getArguments(); + if (!args.containsKey("player") || !args.containsKey("x") || !args.containsKey("z") + || !args.containsKey("name") || !args.containsKey("world")) { + return McpToolResponse.error("Missing required parameter: player, x, z, name, and world are all required"); + } + + String playerIdentifier = args.get("player").toString(); + float x = ((Number) args.get("x")).floatValue(); + float z = ((Number) args.get("z")).floatValue(); + String name = args.get("name").toString(); + String worldUuidStr = args.get("world").toString(); + // The engine's own internal fallback for a null markerImage is "User1.png", which is a + // broken/missing icon asset in this client build: it still shows on the compass (as a + // generic broken-image glyph) but the map screen silently drops the marker entirely. + // Default to "UserA.png" (the icon the real in-game Quick Marker button sends) instead, + // confirmed live to render correctly on both the compass and the map. + String iconArg = args.containsKey("icon") && args.get("icon") != null + ? args.get("icon").toString() + : DEFAULT_ICON; + String colorHexArg = args.containsKey("colorHex") && args.get("colorHex") != null ? args.get("colorHex").toString() : null; + boolean shared = args.containsKey("shared") && Boolean.parseBoolean(args.get("shared").toString()); + + Color tintColor = null; + if (colorHexArg != null && !colorHexArg.isBlank()) { + tintColor = parseColor(colorHexArg); + if (tintColor == null) { + return McpToolResponse.error("Invalid colorHex: " + colorHexArg + " (expected a hex string like #ffcc00)"); + } + } + final Color finalTintColor = tintColor; + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + PlayerRef playerRef = findPlayer(playerIdentifier); + if (playerRef == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + Ref ref = playerRef.getReference(); + if (ref == null) { + future.complete(McpToolResponse.error("Player entity not currently loaded: " + playerIdentifier)); + return; + } + + CreateUserMarker packet = new CreateUserMarker(x, z, name, iconArg, finalTintColor, shared); + + UserMarkerValidator.PlaceResult result = UserMarkerValidator.validatePlacing(ref, packet); + if (result instanceof UserMarkerValidator.Fail fail) { + future.complete(McpToolResponse.error("Marker placement rejected: " + fail.errorMsg().getRawText())); + return; + } + + UserMapMarkersStore store = ((UserMarkerValidator.CanSpawn) result).markersStore(); + + // The real client-triggered code path - does its own (redundant but cheap) + // validation, generates the marker id, and stores it. Using this instead of + // writing the store directly so waypoints behave exactly like a player-placed + // pin, including whatever client sync the engine does on this path. + world.getWorldMapManager().handleUserCreateMarker(playerRef, packet); + + String markerId = null; + for (UserMapMarker m : store.getUserMapMarkers()) { + if (m.getX() == x && m.getZ() == z && name.equals(m.getName())) { + markerId = m.getId(); + } + } + + JsonObject json = new JsonObject(); + json.addProperty("player", playerRef.getUsername()); + json.addProperty("x", x); + json.addProperty("z", z); + json.addProperty("name", name); + json.addProperty("shared", shared); + if (markerId != null) { + json.addProperty("markerId", markerId); + } else { + json.addProperty("warning", "Marker was placed but its id could not be confirmed by re-reading the store"); + } + + future.complete(McpToolResponse.success(GSON.toJson(json))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[ADD_WAYPOINT] Exception"); + future.complete(McpToolResponse.error("Failed to add waypoint: " + t.getMessage())); + } + }); + + return future.join(); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error adding waypoint"); + return McpToolResponse.error("Failed to add waypoint: " + e.getMessage()); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canAddWaypoint(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canAddWaypoint(); + } + return false; + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + private static Color parseColor(String hex) { + String h = hex.startsWith("#") ? hex.substring(1) : hex; + if (h.length() != 6) { + return null; + } + try { + int r = Integer.parseInt(h.substring(0, 2), 16); + int g = Integer.parseInt(h.substring(2, 4), 16); + int b = Integer.parseInt(h.substring(4, 6), 16); + return new Color((byte) r, (byte) g, (byte) b); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateRoadCorridorFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateRoadCorridorFeature.java new file mode 100644 index 0000000..a30e069 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateRoadCorridorFeature.java @@ -0,0 +1,309 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Pure geometry computation (no world access, no chunk loading) that generates a road/path corridor + * block plan from a waypoint chain. Exists specifically to replace hand-derived road-geometry math + * that was re-derived live, under pressure, multiple times in one hytale-block-mod session - including + * a real bug (see that project's project_road_building_rules memory, "Pythagoras nails us" section) + * where elevation was keyed to a grid axis (z) instead of true forward-progress along a diagonal + * segment, producing a road with an uneven left-right cross-section and locally-too-steep grade. + * + *

This tool gets that geometry right once: + *

    + *
  • Width is a perpendicular Euclidean distance from the segment (not a grid-axis offset), so a + * diagonal segment gets a properly angled band, not a staircase.
  • + *
  • Elevation is a function of each cell's own projected position along the segment (t, 0..1), + * so every cell at the same forward-progress value gets the identical Y - a level cross-section + * perpendicular to true direction of travel, on any segment orientation.
  • + *
  • Grade is validated per segment against the standard 1-block-rise-per-2-blocks-horizontal- + * distance rule before any blocks are generated; segments that would require a steeper grade are + * rejected with the minimum compliant distance, rather than silently producing an over-steep + * road.
  • + *
+ * + *

Multi-segment waypoint chains are supported; a cell whose perpendicular distance qualifies it for + * more than one segment (e.g. near a shared waypoint) is assigned to whichever segment it is closest + * to, avoiding duplicate/conflicting entries at joints. + * + *

This is a planning tool only - it returns a block list, it does not write to the world. Pass the + * result straight to set_blocks_batch, then verify_placement afterward. + */ +public class GenerateRoadCorridorFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double MAX_GRADE_RATIO = 0.5; // 1 block of rise per 2 blocks of horizontal travel + + private final HytaleLogger logger; + private final McpConfig config; + + public GenerateRoadCorridorFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "generate_road_corridor"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "generate_road_corridor", + "Computes a road/path block plan from a chain of waypoints - pure geometry, does not touch the " + + "world. Handles cardinal, diagonal, or any-angle segments correctly: width is measured as true " + + "perpendicular distance from the segment (not a grid-axis offset), and elevation is assigned " + + "per-cell from its own projected forward-progress along the segment, so every true left-right " + + "cross-section comes out level regardless of segment angle. Validates each segment against the " + + "standard grade rule (max 1 block of rise per 2 blocks of horizontal distance) up front and " + + "returns an error naming the offending segment instead of silently generating an over-steep " + + "road - add an intermediate waypoint or reduce the elevation change if that happens. Optionally " + + "adds a same-elevation shoulder ring on both sides. Returns a flat blocks array ready to pass " + + "directly to set_blocks_batch, then verify_placement.", + "function" + ); + } + + @Override + public String getInputSchema() { + var waypointSchema = McpToolSchema.objectProperty( + java.util.Map.of( + "x", McpToolSchema.integerProperty("X coordinate"), + "y", McpToolSchema.integerProperty("Y coordinate (elevation) at this waypoint"), + "z", McpToolSchema.integerProperty("Z coordinate") + ), + java.util.List.of("x", "y", "z"), + "A waypoint the corridor passes through; consecutive waypoints form straight segments" + ); + + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "waypoints", McpToolSchema.arrayProperty(waypointSchema, "Ordered list of 2+ waypoints; consecutive pairs form straight segments"), + "width", McpToolSchema.integerProperty("Total path width in blocks (e.g. 3 for a cardinal road, 4-5 for a diagonal one)"), + "blockType", McpToolSchema.stringProperty("Block type identifier for the path surface"), + "shoulderWidth", McpToolSchema.integerProperty("Optional: width in blocks of a same-elevation shoulder ring on each side of the path. Omit or 0 for no shoulder."), + "shoulderBlockType", McpToolSchema.stringProperty("Block type identifier for the shoulder ring. Required if shoulderWidth > 0.") + ), + java.util.List.of("waypoints", "width", "blockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object waypointsObj = call.getArguments().get("waypoints"); + int width = getArgumentAsInt(call, "width"); + String blockType = getArgumentAsString(call, "blockType"); + int shoulderWidth = getArgumentAsInt(call, "shoulderWidth"); + if (shoulderWidth == Integer.MIN_VALUE) shoulderWidth = 0; + String shoulderBlockType = getArgumentAsString(call, "shoulderBlockType"); + + if (waypointsObj == null) { + return McpToolResponse.error("waypoints array is required"); + } + if (width == Integer.MIN_VALUE || width < 1) { + return McpToolResponse.error("width must be a positive integer"); + } + if (blockType == null) { + return McpToolResponse.error("blockType is required"); + } + if (shoulderWidth > 0 && shoulderBlockType == null) { + return McpToolResponse.error("shoulderBlockType is required when shoulderWidth > 0"); + } + + JsonArray waypointsArr; + try { + if (waypointsObj instanceof JsonArray) { + waypointsArr = (JsonArray) waypointsObj; + } else if (waypointsObj instanceof List) { + waypointsArr = GSON.toJsonTree(waypointsObj).getAsJsonArray(); + } else { + JsonElement element = GSON.toJsonTree(waypointsObj); + if (element.isJsonArray()) { + waypointsArr = element.getAsJsonArray(); + } else { + return McpToolResponse.error("waypoints must be an array"); + } + } + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error parsing waypoints array"); + return McpToolResponse.error("Invalid waypoints format: " + e.getMessage()); + } + + if (waypointsArr.size() < 2) { + return McpToolResponse.error("At least 2 waypoints are required to form a segment"); + } + + double[][] waypoints = new double[waypointsArr.size()][3]; + for (int i = 0; i < waypointsArr.size(); i++) { + JsonObject wp = waypointsArr.get(i).getAsJsonObject(); + waypoints[i][0] = wp.get("x").getAsDouble(); + waypoints[i][1] = wp.get("y").getAsDouble(); + waypoints[i][2] = wp.get("z").getAsDouble(); + } + + double halfWidth = width / 2.0; + double outerHalfWidth = halfWidth + Math.max(0, shoulderWidth); + + // Validate grade on every segment before generating anything. + for (int i = 0; i < waypoints.length - 1; i++) { + double[] a = waypoints[i]; + double[] b = waypoints[i + 1]; + double horizontalDistance = Math.hypot(b[0] - a[0], b[2] - a[2]); + double rise = Math.abs(b[1] - a[1]); + + if (horizontalDistance == 0) { + if (rise > 0) { + return McpToolResponse.error("Segment " + i + " (waypoint " + i + " to " + (i + 1) + + ") has zero horizontal distance but a " + rise + "-block elevation change - " + + "that's a vertical wall, not a gradeable road segment."); + } + return McpToolResponse.error("Segment " + i + " (waypoint " + i + " to " + (i + 1) + + ") has zero length - waypoints " + i + " and " + (i + 1) + " are the same (x,z) position."); + } + + double maxRise = horizontalDistance * MAX_GRADE_RATIO; + if (rise > maxRise) { + double minDistance = rise / MAX_GRADE_RATIO; + return McpToolResponse.error(String.format( + "Segment %d (waypoint %d to %d) requires %.1f blocks of elevation change over only %.1f " + + "blocks of horizontal distance - exceeds the max grade of 1 block per 2 blocks " + + "traveled. Needs at least %.1f blocks of horizontal distance for that much rise; " + + "add an intermediate waypoint or reduce the elevation change.", + i, i, i + 1, rise, horizontalDistance, minDistance)); + } + } + + // Bounding box across all waypoints, padded by the outer width. + double minX = waypoints[0][0], maxX = waypoints[0][0]; + double minZ = waypoints[0][2], maxZ = waypoints[0][2]; + for (double[] wp : waypoints) { + minX = Math.min(minX, wp[0]); + maxX = Math.max(maxX, wp[0]); + minZ = Math.min(minZ, wp[2]); + maxZ = Math.max(maxZ, wp[2]); + } + int pad = (int) Math.ceil(outerHalfWidth) + 1; + int loX = (int) Math.floor(minX) - pad; + int hiX = (int) Math.ceil(maxX) + pad; + int loZ = (int) Math.floor(minZ) - pad; + int hiZ = (int) Math.ceil(maxZ) + pad; + + long candidateCount = (long) (hiX - loX + 1) * (hiZ - loZ + 1); + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + if (candidateCount > maxBlocks * 4L) { + return McpToolResponse.error("Waypoint span is too large for a single call (bounding box covers " + + candidateCount + " columns) - split the road into shorter waypoint chains and call this " + + "tool once per chain."); + } + + // For each candidate column, find the closest segment and its projected t. + Map best = new LinkedHashMap<>(); // "x,z" -> {distance, y, isPath(1/0)} + for (int x = loX; x <= hiX; x++) { + for (int z = loZ; z <= hiZ; z++) { + double cx = x + 0.5, cz = z + 0.5; + double bestDist = Double.MAX_VALUE; + double bestY = 0; + + for (int i = 0; i < waypoints.length - 1; i++) { + double[] a = waypoints[i]; + double[] b = waypoints[i + 1]; + double dx = b[0] - a[0], dz = b[2] - a[2]; + double len2 = dx * dx + dz * dz; + double t = ((cx - a[0]) * dx + (cz - a[2]) * dz) / len2; + double tClamped = Math.max(0, Math.min(1, t)); + double px = a[0] + tClamped * dx, pz = a[2] + tClamped * dz; + double dist = Math.hypot(cx - px, cz - pz); + + if (dist < bestDist) { + bestDist = dist; + bestY = Math.round(a[1] + (b[1] - a[1]) * tClamped); + } + } + + if (bestDist <= outerHalfWidth) { + best.put(x + "," + z, new double[]{bestDist, bestY}); + } + } + } + + if (best.size() > maxBlocks) { + return McpToolResponse.error("Generated corridor has " + best.size() + " cells, exceeding the " + + maxBlocks + "-block set_blocks_batch limit - split the road into shorter waypoint chains."); + } + + JsonArray blocks = new JsonArray(); + int pathCount = 0, shoulderCount = 0; + for (Map.Entry entry : best.entrySet()) { + String[] xz = entry.getKey().split(","); + double dist = entry.getValue()[0]; + int y = (int) entry.getValue()[1]; + boolean isPath = dist <= halfWidth; + + JsonObject block = new JsonObject(); + block.addProperty("x", Integer.parseInt(xz[0])); + block.addProperty("y", y); + block.addProperty("z", Integer.parseInt(xz[1])); + block.addProperty("blockType", isPath ? blockType : shoulderBlockType); + block.addProperty("role", isPath ? "path" : "shoulder"); + blocks.add(block); + + if (isPath) pathCount++; else shoulderCount++; + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("pathCount", pathCount); + response.addProperty("shoulderCount", shoulderCount); + response.add("blocks", blocks); + + logger.atInfo().log("[GENERATE_ROAD_CORRIDOR] Generated " + blocks.size() + " blocks (" + + pathCount + " path, " + shoulderCount + " shoulder) across " + (waypoints.length - 1) + " segment(s)"); + + return McpToolResponse.success(GSON.toJson(response)); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + // Pure computation, no world access - gate at the same level as scan_region (read-only tier) + // rather than requiring a new permission flag/live config edit. + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListWaypointsFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListWaypointsFeature.java new file mode 100644 index 0000000..d47b4aa --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListWaypointsFeature.java @@ -0,0 +1,172 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.Color; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.data.PlayerWorldData; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.user.UserMapMarker; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.worldstore.WorldMarkersResource; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class ListWaypointsFeature implements McpFeature { + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public ListWaypointsFeature(HytaleLogger logger) { + this.logger = logger; + } + + @Override + public String getName() { + return "list_waypoints"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "list_waypoints", + "Lists the waypoint markers currently on a specific player's world map in a world - their personal markers plus any shared markers on that world.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + Map.of( + "player", McpToolSchema.stringProperty("Player name or UUID"), + "world", McpToolSchema.stringProperty("World UUID") + ), + List.of("player", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLevel) { + try { + Map args = call.getArguments(); + if (!args.containsKey("player") || !args.containsKey("world")) { + return McpToolResponse.error("Missing required parameter: player and world are both required"); + } + + String playerIdentifier = args.get("player").toString(); + String worldUuidStr = args.get("world").toString(); + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + PlayerRef playerRef = findPlayer(playerIdentifier); + if (playerRef == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + Player player = playerRef.getComponent(Player.getComponentType()); + PlayerWorldData personal = player.getPlayerConfigData().getPerWorldData(world.getName()); + WorldMarkersResource shared = world.getChunkStore().getStore() + .getResource(WorldMarkersResource.getResourceType()); + + JsonArray markers = new JsonArray(); + addMarkers(markers, personal.getUserMapMarkers(), false); + addMarkers(markers, shared.getUserMapMarkers(), true); + + JsonObject json = new JsonObject(); + json.addProperty("player", playerRef.getUsername()); + json.add("markers", markers); + json.addProperty("count", markers.size()); + + future.complete(McpToolResponse.success(GSON.toJson(json))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[LIST_WAYPOINTS] Exception"); + future.complete(McpToolResponse.error("Failed to list waypoints: " + t.getMessage())); + } + }); + + return future.join(); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error listing waypoints"); + return McpToolResponse.error("Failed to list waypoints: " + e.getMessage()); + } + } + + private void addMarkers(JsonArray out, Collection markers, boolean shared) { + for (UserMapMarker m : markers) { + JsonObject obj = new JsonObject(); + obj.addProperty("markerId", m.getId()); + obj.addProperty("name", m.getName()); + obj.addProperty("x", m.getX()); + obj.addProperty("z", m.getZ()); + obj.addProperty("icon", m.getIcon()); + obj.addProperty("shared", shared); + obj.addProperty("createdByName", m.getCreatedByName()); + Color tint = m.getColorTint(); + if (tint != null) { + obj.addProperty("colorHex", String.format("#%02x%02x%02x", + tint.red & 0xFF, tint.green & 0xFF, tint.blue & 0xFF)); + } + out.add(obj); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canListWaypoints(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canListWaypoints(); + } + return false; + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/RemoveWaypointFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/RemoveWaypointFeature.java new file mode 100644 index 0000000..6eda30c --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/RemoveWaypointFeature.java @@ -0,0 +1,169 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.packets.player.RemoveMapMarker; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.data.PlayerWorldData; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.user.UserMapMarker; +import com.hypixel.hytale.server.core.universe.world.worldmap.markers.worldstore.WorldMarkersResource; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class RemoveWaypointFeature implements McpFeature { + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public RemoveWaypointFeature(HytaleLogger logger) { + this.logger = logger; + } + + @Override + public String getName() { + return "remove_waypoint"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "remove_waypoint", + "Removes a waypoint marker (personal or shared, previously placed with add_waypoint or by the player themselves) from a specific player's map, by its marker id.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + Map.of( + "player", McpToolSchema.stringProperty("Player name or UUID"), + "markerId", McpToolSchema.stringProperty("The marker's id, from add_waypoint's response or list_waypoints"), + "world", McpToolSchema.stringProperty("World UUID") + ), + List.of("player", "markerId", "world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, McpAuthManager.AuthLevel authLevel) { + try { + Map args = call.getArguments(); + if (!args.containsKey("player") || !args.containsKey("markerId") || !args.containsKey("world")) { + return McpToolResponse.error("Missing required parameter: player, markerId, and world are all required"); + } + + String playerIdentifier = args.get("player").toString(); + String markerId = args.get("markerId").toString(); + String worldUuidStr = args.get("world").toString(); + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + PlayerRef playerRef = findPlayer(playerIdentifier); + if (playerRef == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + boolean existedBefore = findMarkerAnywhere(markerId, playerRef, world) != null; + + world.getWorldMapManager().handleUserRemoveMarker(playerRef, new RemoveMapMarker(markerId)); + + boolean existsAfter = findMarkerAnywhere(markerId, playerRef, world) != null; + boolean changed = existedBefore && !existsAfter; + + JsonObject json = new JsonObject(); + json.addProperty("player", playerRef.getUsername()); + json.addProperty("markerId", markerId); + json.addProperty("changed", changed); + if (!existedBefore) { + json.addProperty("note", "No marker with that id was found (already removed, or the id was wrong)"); + } else if (!changed) { + json.addProperty("note", "Marker still exists after removal attempt - the player may be too far from it (removal has the same distance limit as placement)"); + } + + future.complete(McpToolResponse.success(GSON.toJson(json))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[REMOVE_WAYPOINT] Exception"); + future.complete(McpToolResponse.error("Failed to remove waypoint: " + t.getMessage())); + } + }); + + return future.join(); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error removing waypoint"); + return McpToolResponse.error("Failed to remove waypoint: " + e.getMessage()); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canRemoveWaypoint(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canRemoveWaypoint(); + } + return false; + } + + /** Checks the player's personal markers first, then the world's shared markers - same order WorldMapManager's own (private) lookup uses. */ + private UserMapMarker findMarkerAnywhere(String markerId, PlayerRef playerRef, World world) { + Player player = playerRef.getComponent(Player.getComponentType()); + PlayerWorldData personal = player.getPlayerConfigData().getPerWorldData(world.getName()); + UserMapMarker marker = personal.getUserMapMarker(markerId); + if (marker != null) { + return marker; + } + WorldMarkersResource shared = world.getChunkStore().getStore().getResource(WorldMarkersResource.getResourceType()); + return shared.getUserMapMarker(markerId); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/VerifyPlacementFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/VerifyPlacementFeature.java new file mode 100644 index 0000000..39c512d --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/VerifyPlacementFeature.java @@ -0,0 +1,238 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Verifies a planned block placement against live world state in one call, replacing the + * scan_region-then-diff-client-side pattern that was previously hand-rolled for every build/repair + * pass (see hytale-block-mod's project_road_building_rules memory - this tool exists specifically + * because that manual diff script was rewritten a dozen+ times in one session and, separately, a + * "floating block" bug slipped through because the support check wasn't run consistently). + * + *

For each entry in the input list, compares the live block at (x,y,z) against the expected + * blockType. Optionally also checks that (x,y-1,z) is non-air, catching blocks placed with nothing + * underneath (the concrete bug that motivated adding this check as a first-class option rather than + * something the caller has to remember to do separately). + */ +public class VerifyPlacementFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + private final McpConfig config; + + public VerifyPlacementFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "verify_placement"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "verify_placement", + "Verifies a list of expected block placements against live world state in one call - replaces " + + "manually scanning a region and diffing it client-side. For each {x,y,z,blockType} entry, " + + "reports whether the live block matches. Set checkSupport:true to also flag any entry whose " + + "block directly below (y-1) is air/unloaded (a floating block with nothing holding it up). " + + "Always use this after any set_blocks_batch/break_blocks_batch call before considering a " + + "build step done - batch placement APIs can silently report success for cells that didn't " + + "actually change. Max " + config.getFeatures().getMaxBlocksBatch() + " entries per call.", + "function" + ); + } + + @Override + public String getInputSchema() { + var blockSchema = McpToolSchema.objectProperty( + java.util.Map.of( + "x", McpToolSchema.integerProperty("X coordinate"), + "y", McpToolSchema.integerProperty("Y coordinate"), + "z", McpToolSchema.integerProperty("Z coordinate"), + "blockType", McpToolSchema.stringProperty("Expected block type identifier at this position") + ), + java.util.List.of("x", "y", "z", "blockType"), + "Expected block placement to verify" + ); + + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "blocks", McpToolSchema.arrayProperty(blockSchema, "List of expected placements to verify (max " + config.getFeatures().getMaxBlocksBatch() + ")"), + "checkSupport", McpToolSchema.booleanProperty("If true, also flag any entry whose (x,y-1,z) is air/unloaded (floating with nothing underneath). Defaults to false.") + ), + java.util.List.of("world", "blocks") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object blocksObj = call.getArguments().get("blocks"); + String worldUuidStr = getArgumentAsString(call, "world"); + boolean checkSupport = getArgumentAsBoolean(call, "checkSupport"); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + if (blocksObj == null) { + return McpToolResponse.error("blocks array is required"); + } + + JsonArray blocks; + try { + if (blocksObj instanceof JsonArray) { + blocks = (JsonArray) blocksObj; + } else if (blocksObj instanceof List) { + blocks = GSON.toJsonTree(blocksObj).getAsJsonArray(); + } else { + JsonElement element = GSON.toJsonTree(blocksObj); + if (element.isJsonArray()) { + blocks = element.getAsJsonArray(); + } else { + return McpToolResponse.error("blocks must be an array"); + } + } + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error parsing blocks array"); + return McpToolResponse.error("Invalid blocks format: " + e.getMessage()); + } + + if (blocks.size() == 0) { + return McpToolResponse.error("blocks array cannot be empty"); + } + + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + if (blocks.size() > maxBlocks) { + return McpToolResponse.error("Maximum " + maxBlocks + " blocks per request"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + JsonArray wrong = new JsonArray(); + JsonArray unsupported = new JsonArray(); + int correctCount = 0; + + for (int i = 0; i < blocks.size(); i++) { + JsonObject blockData = blocks.get(i).getAsJsonObject(); + + int x = blockData.get("x").getAsInt(); + int y = blockData.get("y").getAsInt(); + int z = blockData.get("z").getAsInt(); + String expected = blockData.get("blockType").getAsString(); + + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); + BlockType actualType = world.getBlockType(x, y, z); + + String actualId = (chunk == null || actualType == null) + ? null + : (actualType == BlockType.EMPTY ? "AIR" : actualType.getId()); + + boolean matches = actualId != null && actualId.equals(expected); + if (matches) { + correctCount++; + } else { + JsonObject mismatch = new JsonObject(); + mismatch.addProperty("x", x); + mismatch.addProperty("y", y); + mismatch.addProperty("z", z); + mismatch.addProperty("expected", expected); + mismatch.addProperty("actual", actualId == null ? "UNLOADED" : actualId); + wrong.add(mismatch); + } + + if (checkSupport) { + BlockType belowType = world.getBlockType(x, y - 1, z); + boolean supported = belowType != null && belowType != BlockType.EMPTY; + if (!supported) { + JsonObject floating = new JsonObject(); + floating.addProperty("x", x); + floating.addProperty("y", y); + floating.addProperty("z", z); + unsupported.add(floating); + } + } + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("correct", correctCount); + response.add("wrong", wrong); + if (checkSupport) { + response.add("unsupported", unsupported); + } + + logger.atInfo().log("[VERIFY_PLACEMENT] Checked " + blocks.size() + " positions (" + + correctCount + " correct, " + wrong.size() + " wrong" + + (checkSupport ? ", " + unsupported.size() + " unsupported" : "") + ")"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[VERIFY_PLACEMENT] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } +} From c0ece5446839da3501e66b3090a1b0c0e02cb451 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Wed, 15 Jul 2026 16:40:25 -0500 Subject: [PATCH 10/17] Fix send_chat_message: it never actually sent anything The previous implementation only logged the message and returned {"sent": true} without calling into the game - nothing ever reached a player despite the tool description already claiming otherwise. Now takes a required player argument and calls PlayerRef.sendMessage, the per-player counterpart to broadcast_message's Universe.get().sendMessage. Live-verified: message received in-game. Co-Authored-By: Claude Sonnet 5 --- .../mcp/features/SendChatMessageFeature.java | 86 +++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SendChatMessageFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SendChatMessageFeature.java index 4d3747e..31be276 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SendChatMessageFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SendChatMessageFeature.java @@ -3,6 +3,10 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; @@ -10,6 +14,18 @@ import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Sends a chat message to one specific player - the previous implementation only logged the + * message and returned {"sent": true} without ever calling into the game, so nothing actually + * reached any player despite the tool description already claiming "sends a chat message to a + * specific player." Real fix: PlayerRef.sendMessage(Message), the per-player counterpart to + * BroadcastMessageFeature's Universe.get().sendMessage(Message.raw(...)). + */ public class SendChatMessageFeature implements McpFeature { private static final Gson GSON = new Gson(); @@ -28,7 +44,7 @@ public String getName() { public McpTool getToolDefinition() { return new McpTool( "send_chat_message", - "Sends a chat message to a specific player", + "Sends a chat message to a specific player's in-game chat", "function" ); } @@ -37,27 +53,83 @@ public McpTool getToolDefinition() { public String getInputSchema() { return McpToolSchema.schemaWithProperties( java.util.Map.of( + "player", McpToolSchema.stringProperty("Player name or UUID to send the message to"), "message", McpToolSchema.stringProperty("Message to send in chat") ), - java.util.List.of("message") + java.util.List.of("player", "message") ); } @Override public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String playerIdentifier = getArgumentAsString(call, "player"); String message = getArgumentAsString(call, "message"); + if (playerIdentifier == null || playerIdentifier.isEmpty()) { + return McpToolResponse.error("player is required"); + } if (message == null || message.isEmpty()) { return McpToolResponse.error("message is required"); } - JsonObject response = new JsonObject(); - response.addProperty("message", message); - response.addProperty("sent", true); + Map worlds = Universe.get().getWorlds(); + if (worlds.isEmpty()) { + return McpToolResponse.error("No world available to send chat message"); + } + World world = worlds.values().iterator().next(); + + // PlayerRef state must be touched on the owning world's thread, same rule as every + // other tool here that reaches into Universe/PlayerRef (see get_player_position) - + // otherwise the call hangs forever with no exception on the Jetty request thread. + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + player.sendMessage(Message.raw(message)); + + JsonObject response = new JsonObject(); + response.addProperty("player", player.getUsername()); + response.addProperty("message", message); + response.addProperty("sent", true); + + logger.atInfo().log("[SEND_CHAT_MESSAGE] To " + player.getUsername() + ": " + message); - logger.atInfo().log("[SEND_CHAT_MESSAGE] Message: " + message); + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[SEND_CHAT_MESSAGE] Exception"); + future.complete(McpToolResponse.error("Failed to send chat message: " + t.getMessage())); + } + }); + + return future.join(); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } - return McpToolResponse.success(GSON.toJson(response)); + return null; } @Override From 6da13e5c57f890bb5acef3897c1fa226cc482eb5 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Wed, 15 Jul 2026 17:03:25 -0500 Subject: [PATCH 11/17] Add spawn_npc and list_npc_roles Calls NPCPlugin.spawnNPC directly, the same API the in-game "/npc spawn" player command uses internally - that command can't be driven via execute_command since it's player-only and execute_command runs as console. spawn_npc places a role by name at an explicit position or a few blocks in front of a named player, computed from their facing yaw. list_npc_roles enumerates valid role names first, since there was no prior way to discover them (760 confirmed live). Live-verified: spawned a Chicken next to a player in-game. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 2 + .../hytale/plugins/mcp/config/McpConfig.java | 18 ++ .../mcp/features/ListNpcRolesFeature.java | 100 ++++++++ .../plugins/mcp/features/SpawnNpcFeature.java | 233 ++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListNpcRolesFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SpawnNpcFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 18fc56f..223fbba 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -102,6 +102,8 @@ private void registerFeatures() { featureRegistry.registerFeature(new ListWaypointsFeature(logger)); featureRegistry.registerFeature(new VerifyPlacementFeature(logger, config)); featureRegistry.registerFeature(new GenerateRoadCorridorFeature(logger, config)); + featureRegistry.registerFeature(new ListNpcRolesFeature(logger, config)); + featureRegistry.registerFeature(new SpawnNpcFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index cff6a68..f1d0d25 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -204,6 +204,8 @@ public static class FeaturePermissions { private boolean addWaypoint = false; private boolean removeWaypoint = false; private boolean listWaypoints = false; + private boolean listNpcRoles = false; + private boolean spawnNpc = false; public boolean canListPlayers() { return listPlayers; @@ -348,5 +350,21 @@ public boolean canListWaypoints() { public void setListWaypoints(boolean listWaypoints) { this.listWaypoints = listWaypoints; } + + public boolean canListNpcRoles() { + return listNpcRoles; + } + + public void setListNpcRoles(boolean listNpcRoles) { + this.listNpcRoles = listNpcRoles; + } + + public boolean canSpawnNpc() { + return spawnNpc; + } + + public void setSpawnNpc(boolean spawnNpc) { + this.spawnNpc = spawnNpc; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListNpcRolesFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListNpcRolesFeature.java new file mode 100644 index 0000000..f3a0117 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListNpcRolesFeature.java @@ -0,0 +1,100 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.npc.NPCPlugin; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; + +/** + * Lists registered NPC role names (spawnable-only by default) so a caller can pick a valid + * {@code role} value for spawn_npc without guessing. Pure asset-registry read, same risk profile + * as BlockType.getAssetMap() elsewhere in this codebase - no world.execute() needed. + */ +public class ListNpcRolesFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public ListNpcRolesFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "list_npc_roles"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "list_npc_roles", + "Lists registered NPC role names that can be passed to spawn_npc's role argument. " + + "Set includeNonSpawnable:true to also list abstract/template-only roles that " + + "can't be spawned directly.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "includeNonSpawnable", McpToolSchema.booleanProperty( + "If true, also include abstract/template roles that aren't directly spawnable. Defaults to false.") + ), + java.util.List.of() + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + try { + boolean includeNonSpawnable = getArgumentAsBoolean(call, "includeNonSpawnable"); + + List roles = NPCPlugin.get().getRoleTemplateNames(!includeNonSpawnable); + + JsonArray rolesArray = new JsonArray(); + for (String role : roles) { + rolesArray.add(role); + } + + JsonObject response = new JsonObject(); + response.addProperty("count", roles.size()); + response.add("roles", rolesArray); + + logger.atInfo().log("[LIST_NPC_ROLES] Returned " + roles.size() + " roles"); + + return McpToolResponse.success(GSON.toJson(response)); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[LIST_NPC_ROLES] Exception"); + return McpToolResponse.error("Failed to list NPC roles: " + t.getMessage()); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canListNpcRoles(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canListNpcRoles(); + } + return false; + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SpawnNpcFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SpawnNpcFeature.java new file mode 100644 index 0000000..7f58340 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SpawnNpcFeature.java @@ -0,0 +1,233 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.npc.INonPlayerCharacter; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.NPCPlugin; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import it.unimi.dsi.fastutil.Pair; +import org.joml.Vector3d; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Spawns a real NPC entity by role name, either at an explicit position or near a named player. + * Backed by NPCPlugin.spawnNPC(Store, role, flock, position, rotation) - the same call the real + * "/npc spawn" player command uses internally (that command is AbstractPlayerCommand and can't be + * driven from the console sender execute_command already uses, so this calls the underlying API + * directly instead of shelling out). Use list_npc_roles first to find a valid role name. + */ +public class SpawnNpcFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_OFFSET_FORWARD = 3.0; + private final HytaleLogger logger; + private final McpConfig config; + + public SpawnNpcFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "spawn_npc"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "spawn_npc", + "Spawns an NPC by role name (see list_npc_roles for valid values). Either give an " + + "explicit x/y/z, or give player to spawn a few blocks in front of that player " + + "(control the distance with offsetForward, default " + DEFAULT_OFFSET_FORWARD + ").", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "role", McpToolSchema.stringProperty("NPC role name to spawn (see list_npc_roles)"), + "player", McpToolSchema.stringProperty("Player name or UUID to spawn near. Required if x/y/z are omitted."), + "offsetForward", McpToolSchema.stringProperty( + "Distance in blocks to spawn in front of the player, along their facing yaw. Only used with player. Defaults to " + DEFAULT_OFFSET_FORWARD + "."), + "x", McpToolSchema.stringProperty("Explicit X coordinate. Required together with y/z if player is omitted."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate.") + ), + java.util.List.of("world", "role") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String worldUuidStr = getArgumentAsString(call, "world"); + String role = getArgumentAsString(call, "role"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double offsetForward = getArgumentAsDoubleOrDefault(call, "offsetForward", DEFAULT_OFFSET_FORWARD); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + if (role == null || role.isEmpty()) { + return McpToolResponse.error("role is required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d position; + + if (hasExplicitPosition) { + position = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + + com.hypixel.hytale.math.vector.Transform transform = player.getTransform(); + Vector3d playerPos = transform.getPosition(); + float yawDegrees = transform.getRotation().yaw(); + double yawRadians = Math.toRadians(yawDegrees); + + // Yaw 0 = East (+X) on this engine, confirmed live in the Clacks Tower mod's + // Facing.java: forwardX = cos(yaw), forwardZ = sin(yaw) matches that convention. + double forwardX = Math.cos(yawRadians); + double forwardZ = Math.sin(yawRadians); + + position = new Vector3d( + playerPos.x() + forwardX * offsetForward, + playerPos.y(), + playerPos.z() + forwardZ * offsetForward + ); + } + + Store store = world.getEntityStore().getStore(); + + Pair, INonPlayerCharacter> result = + NPCPlugin.get().spawnNPC(store, role, null, position, Rotation3f.ZERO); + + if (result == null) { + future.complete(McpToolResponse.error( + "Failed to spawn NPC with role '" + role + "' - role not found, not spawnable, " + + "or spawn position invalid. Check list_npc_roles for valid role names.")); + return; + } + + JsonObject posJson = new JsonObject(); + posJson.addProperty("x", position.x()); + posJson.addProperty("y", position.y()); + posJson.addProperty("z", position.z()); + + JsonObject response = new JsonObject(); + response.addProperty("role", role); + response.addProperty("npcTypeId", result.second().getNPCTypeId()); + response.add("position", posJson); + + logger.atInfo().log("[SPAWN_NPC] Spawned role '" + role + "' at " + position); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[SPAWN_NPC] Exception"); + future.complete(McpToolResponse.error("Failed to spawn NPC: " + t.getMessage())); + } + }); + + return future.join(); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canSpawnNpc(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canSpawnNpc(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } +} From 61b89981b8a5280dd7656bebc7da510564493476 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Wed, 15 Jul 2026 17:28:14 -0500 Subject: [PATCH 12/17] Add despawn_npc and set_npc_path despawn_npc removes the nearest NPC(s) within a search radius of a position or player, since spawn_npc doesn't hand back a trackable entity id to target directly. set_npc_path assigns a real patrol route to an already-spawned NPC using absolute {x,y,z} waypoints, calling TransientPath.addWaypoint directly rather than going through the built-in "/npc path set" command, which only accepts a relative turn+distance instruction string - not usable for walking a route already planned in world coordinates (e.g. an existing road). *_Patrol roles carry the follow-a-path behavior but no baked-in route; this is what actually gives them somewhere to walk. Live-verified: spawned Trork_Sentry_Patrol, assigned a 2-point route, confirmed it walks the path in-game. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 2 + .../mcp/features/DespawnNpcFeature.java | 259 ++++++++++++++++ .../mcp/features/SetNpcPathFeature.java | 292 ++++++++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcPathFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 223fbba..2b1711a 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -104,6 +104,8 @@ private void registerFeatures() { featureRegistry.registerFeature(new GenerateRoadCorridorFeature(logger, config)); featureRegistry.registerFeature(new ListNpcRolesFeature(logger, config)); featureRegistry.registerFeature(new SpawnNpcFeature(logger, config)); + featureRegistry.registerFeature(new DespawnNpcFeature(logger, config)); + featureRegistry.registerFeature(new SetNpcPathFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java new file mode 100644 index 0000000..a0d1f58 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java @@ -0,0 +1,259 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.RemoveReason; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import org.joml.Vector3d; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Removes NPC entities near a position (either explicit x/y/z or near a named player). Finds + * candidates by scanning every entity carrying an NPCEntity component and comparing live Transform + * position against the search center - there's no per-NPC handle returned by spawn_npc to target + * directly (yet), so "nearest within radius" is the practical way to despawn something just placed. + * Removes only the single nearest match by default; set all:true to clear everything in radius. + */ +public class DespawnNpcFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_RADIUS = 10.0; + private final HytaleLogger logger; + + public DespawnNpcFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "despawn_npc"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "despawn_npc", + "Removes NPC entities near a position. Either give an explicit x/y/z, or give player " + + "to search near that player. Removes only the single nearest NPC within radius " + + "(default " + DEFAULT_RADIUS + ") by default; set all:true to remove every NPC in radius.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "player", McpToolSchema.stringProperty("Player name or UUID to search near. Required if x/y/z are omitted."), + "x", McpToolSchema.stringProperty("Explicit X coordinate to search near. Required together with y/z if player is omitted."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate."), + "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + "."), + "all", McpToolSchema.booleanProperty("If true, remove every NPC within radius instead of just the nearest one. Defaults to false.") + ), + java.util.List.of("world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String worldUuidStr = getArgumentAsString(call, "world"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); + boolean all = getArgumentAsBoolean(call, "all"); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d center; + + if (hasExplicitPosition) { + center = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + center = player.getTransform().getPosition(); + } + + Store store = world.getEntityStore().getStore(); + Vector3d searchCenter = center; + + List candidates = new ArrayList<>(); + store.forEachChunk(NPCEntity.getComponentType(), (ArchetypeChunk chunk, CommandBuffer cmdBuffer) -> { + for (int i = 0; i < chunk.size(); i++) { + TransformComponent transform = chunk.getComponent(i, TransformComponent.getComponentType()); + if (transform == null) continue; + Vector3d pos = transform.getPosition(); + double distance = pos.distance(searchCenter); + if (distance <= radius) { + NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + String npcTypeId = npc != null ? npc.getNPCTypeId() : "unknown"; + candidates.add(new Candidate(chunk.getReferenceTo(i), npcTypeId, pos, distance)); + } + } + }); + + candidates.sort((a, b) -> Double.compare(a.distance, b.distance)); + + List toRemove = all + ? candidates + : (candidates.isEmpty() ? candidates : candidates.subList(0, 1)); + + JsonArray removedArray = new JsonArray(); + for (Candidate c : toRemove) { + store.removeEntity(c.ref, RemoveReason.REMOVE); + + JsonObject entry = new JsonObject(); + entry.addProperty("npcTypeId", c.npcTypeId); + entry.addProperty("distance", c.distance); + JsonObject posJson = new JsonObject(); + posJson.addProperty("x", c.position.x()); + posJson.addProperty("y", c.position.y()); + posJson.addProperty("z", c.position.z()); + entry.add("position", posJson); + removedArray.add(entry); + } + + JsonObject response = new JsonObject(); + response.addProperty("candidatesFound", candidates.size()); + response.addProperty("removedCount", toRemove.size()); + response.add("removed", removedArray); + + logger.atInfo().log("[DESPAWN_NPC] Removed " + toRemove.size() + " of " + candidates.size() + + " candidates within " + radius + " blocks"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[DESPAWN_NPC] Exception"); + future.complete(McpToolResponse.error("Failed to despawn NPC: " + t.getMessage())); + } + }); + + return future.join(); + } + + private static final class Candidate { + final Ref ref; + final String npcTypeId; + final Vector3d position; + final double distance; + + Candidate(Ref ref, String npcTypeId, Vector3d position, double distance) { + this.ref = ref; + this.npcTypeId = npcTypeId; + this.position = position; + this.distance = distance; + } + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canSpawnNpc(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canSpawnNpc(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcPathFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcPathFeature.java new file mode 100644 index 0000000..069ad86 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcPathFeature.java @@ -0,0 +1,292 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.builtin.path.path.TransientPath; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import org.joml.Vector3d; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Assigns a real, absolute-coordinate patrol path to the NPC nearest a search position. The two + * built-in "/npc path" commands (set/polygon) only accept relative turn+distance instructions or a + * regular polygon - neither takes literal world waypoints, which is what's actually needed to walk a + * road already planned in absolute coordinates. TransientPath.addWaypoint(Vector3d, Rotation3f) + * takes an absolute position directly, so this bypasses the command's relative-instruction parsing + * entirely and builds the path straight from real coordinates. + */ +public class SetNpcPathFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_SEARCH_RADIUS = 10.0; + private final HytaleLogger logger; + + public SetNpcPathFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "set_npc_path"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "set_npc_path", + "Assigns a patrol path (a list of absolute {x,y,z} waypoints) to the NPC nearest a search " + + "position - either an explicit x/y/z or near a named player. The NPC must already be " + + "spawned (see spawn_npc). Needs at least 2 waypoints. This only sets the route; whether " + + "the NPC's role actually follows it (vs. wandering/idling) depends on its role - " + + "*_Patrol roles are designed to follow an assigned path.", + "function" + ); + } + + @Override + public String getInputSchema() { + var waypointSchema = McpToolSchema.objectProperty( + java.util.Map.of( + "x", McpToolSchema.stringProperty("X coordinate"), + "y", McpToolSchema.stringProperty("Y coordinate"), + "z", McpToolSchema.stringProperty("Z coordinate") + ), + java.util.List.of("x", "y", "z"), + "An absolute waypoint on the patrol path" + ); + + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "waypoints", McpToolSchema.arrayProperty(waypointSchema, "Ordered list of 2+ absolute waypoints the NPC will walk"), + "player", McpToolSchema.stringProperty("Player name or UUID to search near for the target NPC. Required if x/y/z are omitted."), + "x", McpToolSchema.stringProperty("Explicit X coordinate to search near for the target NPC."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate."), + "radius", McpToolSchema.stringProperty("Search radius in blocks for finding the target NPC. Defaults to " + DEFAULT_SEARCH_RADIUS + ".") + ), + java.util.List.of("world", "waypoints") + ); + } + + @SuppressWarnings("unchecked") + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String worldUuidStr = getArgumentAsString(call, "world"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_SEARCH_RADIUS); + Object waypointsObj = call.getArguments().get("waypoints"); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + if (waypointsObj == null) { + return McpToolResponse.error("waypoints is required"); + } + + List waypoints = new ArrayList<>(); + try { + for (Object entryObj : (List) waypointsObj) { + var entry = (java.util.Map) entryObj; + double x = Double.parseDouble(entry.get("x").toString()); + double y = Double.parseDouble(entry.get("y").toString()); + double z = Double.parseDouble(entry.get("z").toString()); + waypoints.add(new Vector3d(x, y, z)); + } + } catch (Exception e) { + return McpToolResponse.error("Invalid waypoints format: " + e.getMessage()); + } + + if (waypoints.size() < 2) { + return McpToolResponse.error("At least 2 waypoints are required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided to locate the target NPC"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d searchCenter; + + if (hasExplicitPosition) { + searchCenter = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + searchCenter = player.getTransform().getPosition(); + } + + Store store = world.getEntityStore().getStore(); + + Ref[] nearestRefHolder = new Ref[1]; + String[] nearestTypeIdHolder = new String[1]; + double[] nearestDistanceHolder = { Double.MAX_VALUE }; + Vector3d finalSearchCenter = searchCenter; + + store.forEachChunk(NPCEntity.getComponentType(), (ArchetypeChunk chunk, CommandBuffer cmdBuffer) -> { + for (int i = 0; i < chunk.size(); i++) { + TransformComponent transform = chunk.getComponent(i, TransformComponent.getComponentType()); + if (transform == null) continue; + double distance = transform.getPosition().distance(finalSearchCenter); + if (distance <= radius && distance < nearestDistanceHolder[0]) { + NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + nearestRefHolder[0] = chunk.getReferenceTo(i); + nearestTypeIdHolder[0] = npc != null ? npc.getNPCTypeId() : "unknown"; + nearestDistanceHolder[0] = distance; + } + } + }); + + if (nearestRefHolder[0] == null) { + future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position")); + return; + } + + NPCEntity targetNpc = store.getComponent(nearestRefHolder[0], NPCEntity.getComponentType()); + if (targetNpc == null) { + future.complete(McpToolResponse.error("Found a nearby NPC reference but it no longer resolves - it may have despawned")); + return; + } + + TransientPath path = new TransientPath(); + for (int i = 0; i < waypoints.size(); i++) { + Vector3d point = waypoints.get(i); + Vector3d directionSource = (i < waypoints.size() - 1) ? waypoints.get(i + 1) : waypoints.get(i - 1); + Vector3d direction = new Vector3d(directionSource).sub(point); + if (i == waypoints.size() - 1) { + direction.negate(); + } + Rotation3f facing = direction.lengthSquared() > 0 + ? Rotation3f.lookAt(direction) + : new Rotation3f(); + path.addWaypoint(point, facing); + } + + targetNpc.getPathManager().setTransientPath(path); + + JsonArray waypointsJson = new JsonArray(); + for (Vector3d wp : waypoints) { + JsonObject wpJson = new JsonObject(); + wpJson.addProperty("x", wp.x()); + wpJson.addProperty("y", wp.y()); + wpJson.addProperty("z", wp.z()); + waypointsJson.add(wpJson); + } + + JsonObject response = new JsonObject(); + response.addProperty("npcTypeId", nearestTypeIdHolder[0]); + response.addProperty("distanceFromSearchCenter", nearestDistanceHolder[0]); + response.addProperty("waypointCount", waypoints.size()); + response.add("waypoints", waypointsJson); + + logger.atInfo().log("[SET_NPC_PATH] Assigned " + waypoints.size() + "-waypoint path to " + + nearestTypeIdHolder[0]); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[SET_NPC_PATH] Exception"); + future.complete(McpToolResponse.error("Failed to set NPC path: " + t.getMessage())); + } + }); + + return future.join(); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canSpawnNpc(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canSpawnNpc(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } +} From b877492470f281ce821549a07403b7c048a73b40 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 16 Jul 2026 14:25:50 -0500 Subject: [PATCH 13/17] Add list_models, get_npc_position tools; filter despawn_npc by type list_models surfaces registered Model asset ids (needed to pick a valid NPC Appearance). get_npc_position lets behavior be verified by polling position/rotation instead of watching in-game. despawn_npc now takes an optional npcTypeId filter so a cleanup sweep doesn't also remove unrelated wildlife caught in the same radius. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013DVP2Ei2oDhwk9NNKZAaEc --- .../hytale/plugins/mcp/McpPlugin.java | 2 + .../hytale/plugins/mcp/config/McpConfig.java | 18 ++ .../mcp/features/DespawnNpcFeature.java | 5 +- .../mcp/features/GetNpcPositionFeature.java | 226 ++++++++++++++++++ .../mcp/features/ListModelsFeature.java | 108 +++++++++ 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListModelsFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 2b1711a..50b53ba 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -103,9 +103,11 @@ private void registerFeatures() { featureRegistry.registerFeature(new VerifyPlacementFeature(logger, config)); featureRegistry.registerFeature(new GenerateRoadCorridorFeature(logger, config)); featureRegistry.registerFeature(new ListNpcRolesFeature(logger, config)); + featureRegistry.registerFeature(new ListModelsFeature(logger, config)); featureRegistry.registerFeature(new SpawnNpcFeature(logger, config)); featureRegistry.registerFeature(new DespawnNpcFeature(logger, config)); featureRegistry.registerFeature(new SetNpcPathFeature(logger, config)); + featureRegistry.registerFeature(new GetNpcPositionFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index f1d0d25..0ca96c3 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -206,6 +206,8 @@ public static class FeaturePermissions { private boolean listWaypoints = false; private boolean listNpcRoles = false; private boolean spawnNpc = false; + private boolean listModels = false; + private boolean getNpcPosition = false; public boolean canListPlayers() { return listPlayers; @@ -366,5 +368,21 @@ public boolean canSpawnNpc() { public void setSpawnNpc(boolean spawnNpc) { this.spawnNpc = spawnNpc; } + + public boolean canListModels() { + return listModels; + } + + public void setListModels(boolean listModels) { + this.listModels = listModels; + } + + public boolean canGetNpcPosition() { + return getNpcPosition; + } + + public void setGetNpcPosition(boolean getNpcPosition) { + this.getNpcPosition = getNpcPosition; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java index a0d1f58..990551a 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/DespawnNpcFeature.java @@ -72,7 +72,8 @@ public String getInputSchema() { "y", McpToolSchema.stringProperty("Explicit Y coordinate."), "z", McpToolSchema.stringProperty("Explicit Z coordinate."), "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + "."), - "all", McpToolSchema.booleanProperty("If true, remove every NPC within radius instead of just the nearest one. Defaults to false.") + "all", McpToolSchema.booleanProperty("If true, remove every NPC within radius instead of just the nearest one. Defaults to false."), + "npcTypeId", McpToolSchema.stringProperty("If set, only remove NPCs whose role/type id matches this exactly (case-insensitive) - use to avoid sweeping up unrelated wildlife when clearing test NPCs of a specific role.") ), java.util.List.of("world") ); @@ -87,6 +88,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { Double explicitZ = getArgumentAsDouble(call, "z"); double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); boolean all = getArgumentAsBoolean(call, "all"); + String npcTypeIdFilter = getArgumentAsString(call, "npcTypeId"); if (worldUuidStr == null) { return McpToolResponse.error("world UUID is required"); @@ -139,6 +141,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { if (distance <= radius) { NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); String npcTypeId = npc != null ? npc.getNPCTypeId() : "unknown"; + if (npcTypeIdFilter != null && !npcTypeIdFilter.equalsIgnoreCase(npcTypeId)) continue; candidates.add(new Candidate(chunk.getReferenceTo(i), npcTypeId, pos, distance)); } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java new file mode 100644 index 0000000..981355c --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java @@ -0,0 +1,226 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import org.joml.Vector3d; + +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Reads the live position/rotation of the NPC nearest a search position (either an explicit x/y/z + * or near a named player). Same "nearest within radius" search as despawn_npc/set_npc_path - there's + * no per-NPC handle returned by spawn_npc to target directly (yet). Read-only, no world mutation: + * lets a caller poll an NPC's position over time to verify movement/behavior without needing someone + * watching in-game. + */ +public class GetNpcPositionFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_RADIUS = 10.0; + private final HytaleLogger logger; + + public GetNpcPositionFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "get_npc_position"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "get_npc_position", + "Gets the live position, rotation, and role of the NPC nearest a search position - either " + + "an explicit x/y/z or near a named player. Poll this repeatedly to verify an NPC is " + + "actually moving/behaving as expected without needing to watch in-game.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "player", McpToolSchema.stringProperty("Player name or UUID to search near. Required if x/y/z are omitted."), + "x", McpToolSchema.stringProperty("Explicit X coordinate to search near. Required together with y/z if player is omitted."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate."), + "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + ".") + ), + java.util.List.of("world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String worldUuidStr = getArgumentAsString(call, "world"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d searchCenter; + + if (hasExplicitPosition) { + searchCenter = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + searchCenter = player.getTransform().getPosition(); + } + + Store store = world.getEntityStore().getStore(); + Vector3d finalSearchCenter = searchCenter; + + TransformComponent[] nearestTransformHolder = new TransformComponent[1]; + String[] nearestTypeIdHolder = new String[1]; + double[] nearestDistanceHolder = { Double.MAX_VALUE }; + + store.forEachChunk(NPCEntity.getComponentType(), (ArchetypeChunk chunk, CommandBuffer cmdBuffer) -> { + for (int i = 0; i < chunk.size(); i++) { + TransformComponent transform = chunk.getComponent(i, TransformComponent.getComponentType()); + if (transform == null) continue; + double distance = transform.getPosition().distance(finalSearchCenter); + if (distance <= radius && distance < nearestDistanceHolder[0]) { + NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + nearestTransformHolder[0] = transform; + nearestTypeIdHolder[0] = npc != null ? npc.getNPCTypeId() : "unknown"; + nearestDistanceHolder[0] = distance; + } + } + }); + + if (nearestTransformHolder[0] == null) { + future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position")); + return; + } + + Vector3d pos = nearestTransformHolder[0].getPosition(); + Rotation3f rotation = nearestTransformHolder[0].getRotation(); + + JsonObject position = new JsonObject(); + position.addProperty("x", pos.x()); + position.addProperty("y", pos.y()); + position.addProperty("z", pos.z()); + position.addProperty("yaw", rotation.yaw()); + position.addProperty("pitch", rotation.pitch()); + + JsonObject response = new JsonObject(); + response.addProperty("npcTypeId", nearestTypeIdHolder[0]); + response.addProperty("distanceFromSearchCenter", nearestDistanceHolder[0]); + response.add("position", position); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[GET_NPC_POSITION] Exception"); + future.complete(McpToolResponse.error("Failed to get NPC position: " + t.getMessage())); + } + }); + + return future.join(); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canGetNpcPosition(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canGetNpcPosition(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListModelsFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListModelsFeature.java new file mode 100644 index 0000000..2ce6331 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListModelsFeature.java @@ -0,0 +1,108 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.asset.type.model.config.ModelAsset; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Lists registered Model asset ids (the "Appearance" value an NPC Role JSON references) so a + * caller can pick a valid model without guessing. Pure asset-registry read, same risk profile as + * BlockType.getAssetMap() elsewhere in this codebase - no world.execute() needed. + */ +public class ListModelsFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public ListModelsFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "list_models"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "list_models", + "Lists registered Model asset ids that can be used as an NPC Role's Appearance value. " + + "Optionally filter by a case-insensitive substring of the id.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "search", McpToolSchema.stringProperty("Case-insensitive substring to filter model ids by (optional).") + ), + java.util.List.of() + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + try { + String search = getArgumentAsString(call, "search"); + + Map models = ModelAsset.getAssetMap().getAssetMap(); + + List ids = models.keySet().stream() + .filter(id -> search == null || search.isEmpty() || id.toLowerCase().contains(search.toLowerCase())) + .sorted() + .collect(Collectors.toList()); + + JsonArray idsArray = new JsonArray(); + for (String id : ids) { + idsArray.add(id); + } + + JsonObject response = new JsonObject(); + response.addProperty("total", models.size()); + response.addProperty("returned", ids.size()); + response.add("models", idsArray); + if (search != null && !search.isEmpty()) { + response.addProperty("searchTerm", search); + } + + logger.atInfo().log("[LIST_MODELS] Returned " + ids.size() + " of " + models.size() + " models" + + (search != null ? " (search: " + search + ")" : "")); + + return McpToolResponse.success(GSON.toJson(response)); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[LIST_MODELS] Exception"); + return McpToolResponse.error("Failed to list models: " + t.getMessage()); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canListModels(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canListModels(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 03f686b594b960a45bf1a54b1e25a63331a0b55f Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Tue, 21 Jul 2026 14:43:50 -0500 Subject: [PATCH 14/17] Add NPC flag control, position tracing, item search, and NPC-type filtering set_npc_flag lets a Role's flag slots be set by raw index for testing custom Role logic (e.g. npc-road-guard's heading bits). start_npc_trace/ stop_npc_trace sample an NPC's position and all 4 flag bits on a fixed wall-clock interval to CSV, independent of game ticks - used to empirically reverse-engineer heading-to-flag-bit mappings live rather than guessing. list_items closes a real gap: list_blocks/give_item only ever searched the BlockType registry, never the separate ~3690-entry Item registry where weapons/tools/wieldables actually live. get_npc_position gained an npcTypeId filter to avoid picking up nearby wildlife when hunting for a specific test NPC. Co-Authored-By: Claude Sonnet 5 --- deploy.sh | 31 ++ .../hytale/plugins/mcp/McpPlugin.java | 4 + .../hytale/plugins/mcp/config/McpConfig.java | 18 + .../mcp/features/GetNpcPositionFeature.java | 11 +- .../mcp/features/ListItemsFeature.java | 163 +++++++++ .../plugins/mcp/features/NpcTraceState.java | 38 ++ .../mcp/features/SetNpcFlagFeature.java | 263 ++++++++++++++ .../mcp/features/StartNpcTraceFeature.java | 330 ++++++++++++++++++ .../mcp/features/StopNpcTraceFeature.java | 129 +++++++ 9 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 deploy.sh create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListItemsFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/NpcTraceState.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcFlagFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StartNpcTraceFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StopNpcTraceFeature.java diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..61ad940 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Builds and deploys the MCP plugin jar to Willikins, backing up the previous +# jar first (timestamped, inside the container), and restarts the server. +# Run from anywhere - paths are relative to this script's own location. +# +# Usage: deploy.sh +set -e + +HOST="chad@willikins.cetacean-cloud.ts.net" +PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JAR_NAME="MCP-0.2.0.jar" + +echo "Building..." +(cd "$PLUGIN_DIR" && mvn -q -f pom.xml package) + +echo "Backing up previous jar (if any)..." +ssh "$HOST" "docker exec hytale sh -c 'test -f /home/hytale/server-files/mods/$JAR_NAME && cp /home/hytale/server-files/mods/$JAR_NAME /home/hytale/server-files/mods/$JAR_NAME.bak-\$(date +%Y%m%d-%H%M%S) || true'" + +echo "Copying jar to server..." +scp -q "$PLUGIN_DIR/target/$JAR_NAME" "$HOST":~/"$JAR_NAME" + +echo "Installing jar and restarting..." +ssh "$HOST" "docker cp ~/$JAR_NAME hytale:/home/hytale/server-files/mods/$JAR_NAME && docker restart hytale" + +echo "Waiting for server to come back up..." +sleep 14 +ssh "$HOST" "docker ps | grep hytale" + +echo "" +echo "Recent log lines (check for a clean init):" +ssh "$HOST" "docker exec hytale sh -c 'LOG=\$(ls -t /home/hytale/server-files/logs/*_server.log | head -1); grep -iE \"MCP\\|P\" \"\$LOG\" | tail -15'" diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 50b53ba..9cb77fe 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -108,6 +108,10 @@ private void registerFeatures() { featureRegistry.registerFeature(new DespawnNpcFeature(logger, config)); featureRegistry.registerFeature(new SetNpcPathFeature(logger, config)); featureRegistry.registerFeature(new GetNpcPositionFeature(logger, config)); + featureRegistry.registerFeature(new SetNpcFlagFeature(logger, config)); + featureRegistry.registerFeature(new StartNpcTraceFeature(logger, config)); + featureRegistry.registerFeature(new StopNpcTraceFeature(logger, config)); + featureRegistry.registerFeature(new ListItemsFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 0ca96c3..2f0548a 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -208,6 +208,8 @@ public static class FeaturePermissions { private boolean spawnNpc = false; private boolean listModels = false; private boolean getNpcPosition = false; + private boolean npcTrace = false; + private boolean listItems = false; public boolean canListPlayers() { return listPlayers; @@ -384,5 +386,21 @@ public boolean canGetNpcPosition() { public void setGetNpcPosition(boolean getNpcPosition) { this.getNpcPosition = getNpcPosition; } + + public boolean canNpcTrace() { + return npcTrace; + } + + public void setNpcTrace(boolean npcTrace) { + this.npcTrace = npcTrace; + } + + public boolean canListItems() { + return listItems; + } + + public void setListItems(boolean listItems) { + this.listItems = listItems; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java index 981355c..18fb4af 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GetNpcPositionFeature.java @@ -67,7 +67,8 @@ public String getInputSchema() { "x", McpToolSchema.stringProperty("Explicit X coordinate to search near. Required together with y/z if player is omitted."), "y", McpToolSchema.stringProperty("Explicit Y coordinate."), "z", McpToolSchema.stringProperty("Explicit Z coordinate."), - "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + ".") + "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + "."), + "npcTypeId", McpToolSchema.stringProperty("If set, only consider NPCs whose role/type id matches this exactly (case-insensitive) - use to find a specific NPC without picking up nearby wildlife.") ), java.util.List.of("world") ); @@ -81,6 +82,7 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { Double explicitY = getArgumentAsDouble(call, "y"); Double explicitZ = getArgumentAsDouble(call, "z"); double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); + String npcTypeIdFilter = getArgumentAsString(call, "npcTypeId"); if (worldUuidStr == null) { return McpToolResponse.error("world UUID is required"); @@ -134,15 +136,18 @@ public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { double distance = transform.getPosition().distance(finalSearchCenter); if (distance <= radius && distance < nearestDistanceHolder[0]) { NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + String npcTypeId = npc != null ? npc.getNPCTypeId() : "unknown"; + if (npcTypeIdFilter != null && !npcTypeIdFilter.equalsIgnoreCase(npcTypeId)) continue; nearestTransformHolder[0] = transform; - nearestTypeIdHolder[0] = npc != null ? npc.getNPCTypeId() : "unknown"; + nearestTypeIdHolder[0] = npcTypeId; nearestDistanceHolder[0] = distance; } } }); if (nearestTransformHolder[0] == null) { - future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position")); + String suffix = npcTypeIdFilter != null ? " matching npcTypeId '" + npcTypeIdFilter + "'" : ""; + future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position" + suffix)); return; } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListItemsFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListItemsFeature.java new file mode 100644 index 0000000..ff424fd --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ListItemsFeature.java @@ -0,0 +1,163 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.asset.type.item.config.Item; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Lists real Item asset ids (weapons, tools, food, armor, etc.) - a separate registry from + * BlockType (confirmed via the boot log's "Total Loaded Assets" line: Item ~3690 vs BlockType + * ~5755). list_blocks/give_item only ever searched BlockType, so anything wieldable (a sword, a + * held torch) was unfindable through this MCP before - found the real ids by extracting and + * grepping vanilla Assets.zip directly (Server/Item/Items/**) instead, this tool closes that gap + * properly. Item's own Categories (real data on the asset, e.g. "Weapon.Sword") are used directly + * rather than guessing from the id string the way list_blocks's categorizeBlock() has to. + */ +public class ListItemsFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public ListItemsFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "list_items"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "list_items", + "Lists real Item asset ids (weapons, tools, armor, food, etc.) - a separate registry " + + "from blocks (list_blocks only searches BlockType). Use this to find an item id " + + "for give_item or an NPC Role's inventory-equip Actions. Optionally filter by a " + + "case-insensitive substring of the id, and/or by an exact (case-insensitive) " + + "category string as it appears on the item (e.g. \"Weapon.Sword\") - call with no " + + "category to see what values exist for a given search first.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "search", McpToolSchema.stringProperty("Case-insensitive substring to filter item ids by (optional)."), + "category", McpToolSchema.stringProperty("Exact (case-insensitive) category string to filter by, e.g. \"Weapon.Sword\" (optional)."), + "limit", McpToolSchema.integerProperty("Maximum number of items to return (optional).") + ), + java.util.List.of() + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + try { + String search = getArgumentAsString(call, "search"); + String category = getArgumentAsString(call, "category"); + Integer limit = getArgumentAsInteger(call, "limit"); + + Map items = Item.getAssetMap().getAssetMap(); + + List ids = items.entrySet().stream() + .filter(e -> search == null || search.isEmpty() + || e.getKey().toLowerCase().contains(search.toLowerCase())) + .filter(e -> { + if (category == null || category.isEmpty()) { + return true; + } + String[] categories = e.getValue().getCategories(); + if (categories == null) { + return false; + } + for (String c : categories) { + if (c.equalsIgnoreCase(category)) { + return true; + } + } + return false; + }) + .map(Map.Entry::getKey) + .sorted() + .collect(Collectors.toList()); + + int totalMatched = ids.size(); + if (limit != null && limit > 0 && limit < ids.size()) { + ids = ids.subList(0, limit); + } + + JsonArray idsArray = new JsonArray(); + for (String id : ids) { + idsArray.add(id); + } + + JsonObject response = new JsonObject(); + response.addProperty("total", items.size()); + response.addProperty("matched", totalMatched); + response.addProperty("returned", ids.size()); + response.add("items", idsArray); + if (search != null && !search.isEmpty()) { + response.addProperty("searchTerm", search); + } + if (category != null && !category.isEmpty()) { + response.addProperty("filterCategory", category); + } + + logger.atInfo().log("[LIST_ITEMS] Returned " + ids.size() + " of " + totalMatched + " matched (" + + items.size() + " total)" + + (search != null ? " search=" + search : "") + + (category != null ? " category=" + category : "")); + + return McpToolResponse.success(GSON.toJson(response)); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[LIST_ITEMS] Exception"); + return McpToolResponse.error("Failed to list items: " + t.getMessage()); + } + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canListItems(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canListItems(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Integer getArgumentAsInteger(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/NpcTraceState.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/NpcTraceState.java new file mode 100644 index 0000000..9a0b3c4 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/NpcTraceState.java @@ -0,0 +1,38 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.hypixel.hytale.server.core.universe.world.World; +import org.joml.Vector3d; + +import java.io.BufferedWriter; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared, single-slot state for the start_npc_trace/stop_npc_trace tool pair. Only one trace can + * be active at a time - deliberately simple, matching the "flip it on for a debug session, flip it + * off when done" use case rather than supporting concurrent traces. All mutation happens inside + * World.execute() callbacks (either the periodic sample task or the start/stop tool calls + * themselves), which serialize onto the world's own single thread, so this is effectively + * single-threaded despite the fields not being individually synchronized. + */ +final class NpcTraceState { + static volatile ScheduledExecutorService executor; + static volatile ScheduledFuture task; + static volatile BufferedWriter writer; + static volatile Vector3d lastKnownPosition; + static volatile World world; + static volatile String npcTypeIdFilter; + static volatile double radius; + static volatile long startTimeMillis; + static volatile String filePath; + static final AtomicInteger sampleCount = new AtomicInteger(0); + static final AtomicInteger missCount = new AtomicInteger(0); + + static boolean isActive() { + return writer != null; + } + + private NpcTraceState() { + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcFlagFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcFlagFeature.java new file mode 100644 index 0000000..58d10cf --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/SetNpcFlagFeature.java @@ -0,0 +1,263 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.hypixel.hytale.server.npc.role.Role; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import org.joml.Vector3d; + +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Sets one of a Role's named-flag slots directly on the nearest matching NPC (Role.setFlag(int, + * boolean), the same runtime state ActionSetFlag/SensorFlag read and write from inside Role JSON). + * Flag *names* only exist at Role-build time - each name gets assigned the next free integer slot + * on first reference, via a per-Role-asset SlotMapper (confirmed via javap on + * BuilderSupport/SlotMapper/ActionSetFlag/Role in HytaleServer.jar) - there's no live name->index + * lookup exposed at runtime, so callers pass the raw slot index and must determine which index + * corresponds to which flag name empirically (e.g. set index 0, spawn, see which Instruction fires + * in logs) for a given Role, rather than by name here. + */ +public class SetNpcFlagFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_RADIUS = 10.0; + private final HytaleLogger logger; + + public SetNpcFlagFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "set_npc_flag"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "set_npc_flag", + "Sets a Role flag slot (by integer index, not name - flag names only exist at Role-build " + + "time) to true/false on the NPC nearest a search position. Use to force a Role's " + + "internal state (e.g. a directional Flag/SetFlag pair in a custom Role) without " + + "waiting for the NPC's own AI to reach that state naturally. Index must be " + + "determined empirically for a given Role (deploy, set index 0, observe behavior/" + + "logs, repeat) - there is no name-to-index lookup at runtime.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "player", McpToolSchema.stringProperty("Player name or UUID to search near. Required if x/y/z are omitted."), + "x", McpToolSchema.stringProperty("Explicit X coordinate to search near. Required together with y/z if player is omitted."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate."), + "radius", McpToolSchema.stringProperty("Search radius in blocks. Defaults to " + DEFAULT_RADIUS + "."), + "npcTypeId", McpToolSchema.stringProperty("If set, only consider NPCs whose role/type id matches this exactly (case-insensitive)."), + "flagIndex", McpToolSchema.stringProperty("The Role flag slot index to set (integer, 0-based - see tool description)."), + "value", McpToolSchema.booleanProperty("The value to set the flag to. Defaults to true.") + ), + java.util.List.of("world", "flagIndex") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + String worldUuidStr = getArgumentAsString(call, "world"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); + String npcTypeIdFilter = getArgumentAsString(call, "npcTypeId"); + Integer flagIndex = getArgumentAsInteger(call, "flagIndex"); + boolean value = getArgumentAsBooleanOrDefault(call, "value", true); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + if (flagIndex == null) { + return McpToolResponse.error("flagIndex is required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + int finalFlagIndex = flagIndex; + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d searchCenter; + + if (hasExplicitPosition) { + searchCenter = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + searchCenter = player.getTransform().getPosition(); + } + + Store store = world.getEntityStore().getStore(); + Vector3d finalSearchCenter = searchCenter; + + NPCEntity[] nearestHolder = new NPCEntity[1]; + double[] nearestDistanceHolder = { Double.MAX_VALUE }; + + store.forEachChunk(NPCEntity.getComponentType(), (ArchetypeChunk chunk, CommandBuffer cmdBuffer) -> { + for (int i = 0; i < chunk.size(); i++) { + TransformComponent transform = chunk.getComponent(i, TransformComponent.getComponentType()); + if (transform == null) continue; + double distance = transform.getPosition().distance(finalSearchCenter); + if (distance <= radius && distance < nearestDistanceHolder[0]) { + NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + if (npc == null) continue; + String npcTypeId = npc.getNPCTypeId(); + if (npcTypeIdFilter != null && !npcTypeIdFilter.equalsIgnoreCase(npcTypeId)) continue; + nearestHolder[0] = npc; + nearestDistanceHolder[0] = distance; + } + } + }); + + if (nearestHolder[0] == null) { + String suffix = npcTypeIdFilter != null ? " matching npcTypeId '" + npcTypeIdFilter + "'" : ""; + future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position" + suffix)); + return; + } + + Role role = nearestHolder[0].getRole(); + if (role == null) { + future.complete(McpToolResponse.error("Nearest NPC has no active Role")); + return; + } + + role.setFlag(finalFlagIndex, value); + + JsonObject response = new JsonObject(); + response.addProperty("npcTypeId", nearestHolder[0].getNPCTypeId()); + response.addProperty("flagIndex", finalFlagIndex); + response.addProperty("value", value); + response.addProperty("nowSet", role.isFlagSet(finalFlagIndex)); + + logger.atInfo().log("[SET_NPC_FLAG] Set flag " + finalFlagIndex + "=" + value + " on nearest " + + nearestHolder[0].getNPCTypeId()); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[SET_NPC_FLAG] Exception"); + future.complete(McpToolResponse.error("Failed to set NPC flag: " + t.getMessage())); + } + }); + + return future.join(); + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canSpawnNpc(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canSpawnNpc(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } + + private Integer getArgumentAsInteger(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return (int) Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private boolean getArgumentAsBooleanOrDefault(McpToolCall call, String key, boolean defaultValue) { + Object value = call.getArguments().get(key); + if (value == null) return defaultValue; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StartNpcTraceFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StartNpcTraceFeature.java new file mode 100644 index 0000000..1aa6e76 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StartNpcTraceFeature.java @@ -0,0 +1,330 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.vector.Rotation3f; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.npc.entities.NPCEntity; +import com.hypixel.hytale.server.npc.role.Role; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; +import org.joml.Vector3d; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Starts a fine-grained position+heading-flag trace of the NPC nearest a search position, writing + * CSV rows to a dedicated file on a fixed wall-clock interval until stop_npc_trace is called. Built + * because polling get_npc_position by hand - even every few seconds - misses the exact moment a + * Role's committed heading flips. This samples much faster (default 200ms) and reads the Role's raw + * flag bits directly (Role.isFlagSet, the same accessor set_npc_flag already uses), so the trace + * shows precisely when and where a heading commitment changes, not just where the NPC ends up + * seconds later. + * + * Deliberately off by default and only writes while a trace is actively running - each start begins + * a brand new file rather than appending forever, and stop_npc_trace closes it, so a debug session + * never silently eats disk space once it's done. Only one trace can run at a time. + */ +public class StartNpcTraceFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final double DEFAULT_RADIUS = 10.0; + private static final long DEFAULT_INTERVAL_MS = 200; + private final HytaleLogger logger; + + public StartNpcTraceFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "start_npc_trace"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "start_npc_trace", + "Starts a fine-grained trace of the NPC nearest a search position (either an explicit " + + "x/y/z or near a named player), sampling its live position and Role flag bits on " + + "a fixed interval (default " + DEFAULT_INTERVAL_MS + "ms) and writing CSV rows to " + + "a dedicated file until stop_npc_trace is called. Off by default - only one trace " + + "can run at a time, and it writes nothing until started and nothing after " + + "stopped, so it never silently eats disk space.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "world", McpToolSchema.stringProperty("World UUID"), + "player", McpToolSchema.stringProperty("Player name or UUID to search near. Required if x/y/z are omitted."), + "x", McpToolSchema.stringProperty("Explicit X coordinate to search near. Required together with y/z if player is omitted."), + "y", McpToolSchema.stringProperty("Explicit Y coordinate."), + "z", McpToolSchema.stringProperty("Explicit Z coordinate."), + "radius", McpToolSchema.stringProperty("Search radius in blocks, re-used every sample to re-find the NPC. Defaults to " + DEFAULT_RADIUS + "."), + "npcTypeId", McpToolSchema.stringProperty("If set, only trace an NPC whose role/type id matches this exactly (case-insensitive)."), + "intervalMs", McpToolSchema.integerProperty("Sampling interval in milliseconds. Defaults to " + DEFAULT_INTERVAL_MS + ".") + ), + java.util.List.of("world") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + if (NpcTraceState.isActive()) { + return McpToolResponse.error("A trace is already running (" + NpcTraceState.filePath + + ") - call stop_npc_trace first"); + } + + String worldUuidStr = getArgumentAsString(call, "world"); + String playerIdentifier = getArgumentAsString(call, "player"); + Double explicitX = getArgumentAsDouble(call, "x"); + Double explicitY = getArgumentAsDouble(call, "y"); + Double explicitZ = getArgumentAsDouble(call, "z"); + double radius = getArgumentAsDoubleOrDefault(call, "radius", DEFAULT_RADIUS); + String npcTypeIdFilter = getArgumentAsString(call, "npcTypeId"); + long intervalMs = (long) getArgumentAsDoubleOrDefault(call, "intervalMs", DEFAULT_INTERVAL_MS); + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + boolean hasExplicitPosition = explicitX != null && explicitY != null && explicitZ != null; + if (!hasExplicitPosition && playerIdentifier == null) { + return McpToolResponse.error("Either player, or all of x/y/z, must be provided"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + long finalIntervalMs = intervalMs; + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + Vector3d searchCenter; + + if (hasExplicitPosition) { + searchCenter = new Vector3d(explicitX, explicitY, explicitZ); + } else { + PlayerRef player = findPlayer(playerIdentifier); + if (player == null) { + future.complete(McpToolResponse.error("Player not found: " + playerIdentifier)); + return; + } + searchCenter = player.getTransform().getPosition(); + } + + NearestResult nearest = findNearest(world, searchCenter, radius, npcTypeIdFilter); + if (nearest == null) { + future.complete(McpToolResponse.error("No NPC found within " + radius + " blocks of the search position")); + return; + } + + File dir = new File("mods/MCP/traces"); + if (!dir.exists()) { + dir.mkdirs(); + } + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + File file = new File(dir, timestamp + "-" + nearest.npcTypeId + ".csv"); + + BufferedWriter writer = Files.newBufferedWriter(file.toPath()); + writer.write("elapsedMs,x,y,z,yaw,flag0,flag1,flag2,flag3"); + writer.newLine(); + writer.flush(); + + NpcTraceState.writer = writer; + NpcTraceState.world = world; + NpcTraceState.lastKnownPosition = nearest.position; + NpcTraceState.npcTypeIdFilter = npcTypeIdFilter; + NpcTraceState.radius = radius; + NpcTraceState.startTimeMillis = System.currentTimeMillis(); + NpcTraceState.filePath = file.getPath(); + NpcTraceState.sampleCount.set(0); + NpcTraceState.missCount.set(0); + + NpcTraceState.executor = Executors.newSingleThreadScheduledExecutor(); + NpcTraceState.task = NpcTraceState.executor.scheduleAtFixedRate( + StartNpcTraceFeature::sampleTick, finalIntervalMs, finalIntervalMs, TimeUnit.MILLISECONDS); + + JsonObject response = new JsonObject(); + response.addProperty("tracing", true); + response.addProperty("npcTypeId", nearest.npcTypeId); + response.addProperty("file", file.getPath()); + response.addProperty("intervalMs", finalIntervalMs); + + logger.atInfo().log("[START_NPC_TRACE] Tracing " + nearest.npcTypeId + " to " + file.getPath() + + " every " + finalIntervalMs + "ms"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[START_NPC_TRACE] Exception"); + future.complete(McpToolResponse.error("Failed to start NPC trace: " + t.getMessage())); + } + }); + + return future.join(); + } + + private static void sampleTick() { + World world = NpcTraceState.world; + if (world == null) return; + world.execute(() -> { + BufferedWriter writer = NpcTraceState.writer; + if (writer == null) return; // stopped between schedule and execution + + try { + NearestResult nearest = findNearest(world, NpcTraceState.lastKnownPosition, + NpcTraceState.radius, NpcTraceState.npcTypeIdFilter); + if (nearest == null) { + NpcTraceState.missCount.incrementAndGet(); + return; + } + + NpcTraceState.lastKnownPosition = nearest.position; + long elapsed = System.currentTimeMillis() - NpcTraceState.startTimeMillis; + + boolean[] flags = new boolean[4]; + Role role = nearest.role; + for (int i = 0; i < 4; i++) { + flags[i] = role != null && role.isFlagSet(i); + } + + writer.write(elapsed + "," + nearest.position.x() + "," + nearest.position.y() + "," + + nearest.position.z() + "," + nearest.yaw + "," + + flags[0] + "," + flags[1] + "," + flags[2] + "," + flags[3]); + writer.newLine(); + NpcTraceState.sampleCount.incrementAndGet(); + } catch (IOException e) { + // best-effort - a single failed sample shouldn't kill the whole trace + } + }); + } + + static NearestResult findNearest(World world, Vector3d searchCenter, double radius, String npcTypeIdFilter) { + Store store = world.getEntityStore().getStore(); + + NearestResult[] holder = new NearestResult[1]; + double[] nearestDistanceHolder = { Double.MAX_VALUE }; + + store.forEachChunk(NPCEntity.getComponentType(), (ArchetypeChunk chunk, CommandBuffer cmdBuffer) -> { + for (int i = 0; i < chunk.size(); i++) { + TransformComponent transform = chunk.getComponent(i, TransformComponent.getComponentType()); + if (transform == null) continue; + double distance = transform.getPosition().distance(searchCenter); + if (distance <= radius && distance < nearestDistanceHolder[0]) { + NPCEntity npc = chunk.getComponent(i, NPCEntity.getComponentType()); + String npcTypeId = npc != null ? npc.getNPCTypeId() : "unknown"; + if (npcTypeIdFilter != null && !npcTypeIdFilter.equalsIgnoreCase(npcTypeId)) continue; + Rotation3f rotation = transform.getRotation(); + Role role = npc != null ? npc.getRole() : null; + holder[0] = new NearestResult(npcTypeId, transform.getPosition(), rotation.yaw(), role); + nearestDistanceHolder[0] = distance; + } + } + }); + + return holder[0]; + } + + static final class NearestResult { + final String npcTypeId; + final Vector3d position; + final float yaw; + final Role role; + + NearestResult(String npcTypeId, Vector3d position, float yaw, Role role) { + this.npcTypeId = npcTypeId; + this.position = position; + this.yaw = yaw; + this.role = role; + } + } + + private PlayerRef findPlayer(String identifier) { + Collection players = Universe.get().getPlayers(); + + try { + UUID uuid = UUID.fromString(identifier); + for (PlayerRef player : players) { + if (player.getUuid().equals(uuid)) { + return player; + } + } + } catch (IllegalArgumentException e) { + } + + for (PlayerRef player : players) { + if (player.getUsername().equalsIgnoreCase(identifier)) { + return player; + } + } + + return null; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canNpcTrace(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canNpcTrace(); + } + return false; + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } + + private Double getArgumentAsDouble(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return null; + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private double getArgumentAsDoubleOrDefault(McpToolCall call, String key, double defaultValue) { + Double value = getArgumentAsDouble(call, key); + return value != null ? value : defaultValue; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StopNpcTraceFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StopNpcTraceFeature.java new file mode 100644 index 0000000..8c0f137 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/StopNpcTraceFeature.java @@ -0,0 +1,129 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.world.World; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.io.BufferedWriter; +import java.util.concurrent.CompletableFuture; + +/** + * Stops whatever trace start_npc_trace has running and closes its file so it stops growing. + * Cancels the periodic schedule first (no new samples get submitted), then does one final + * World.execute() pass to close the writer - since every sample write also goes through + * World.execute() on the same world thread, that final pass is guaranteed to run after any sample + * that was already in flight when stop was called, so nothing is lost or corrupted by the shutdown + * race. + */ +public class StopNpcTraceFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + + public StopNpcTraceFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + } + + @Override + public String getName() { + return "stop_npc_trace"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "stop_npc_trace", + "Stops the currently running NPC trace started by start_npc_trace and closes its file. " + + "No-op (returns tracing:false, wasRunning:false) if nothing is currently running.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.emptyObjectSchema(); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + if (!NpcTraceState.isActive()) { + JsonObject response = new JsonObject(); + response.addProperty("tracing", false); + response.addProperty("wasRunning", false); + return McpToolResponse.success(GSON.toJson(response)); + } + + if (NpcTraceState.task != null) { + NpcTraceState.task.cancel(false); + } + + World world = NpcTraceState.world; + CompletableFuture future = new CompletableFuture<>(); + + Runnable closeAndRespond = () -> { + String filePath = NpcTraceState.filePath; + int samples = NpcTraceState.sampleCount.get(); + int misses = NpcTraceState.missCount.get(); + + BufferedWriter writer = NpcTraceState.writer; + try { + if (writer != null) { + writer.flush(); + writer.close(); + } + } catch (Exception e) { + logger.atWarning().withCause(e).log("[STOP_NPC_TRACE] Failed to close trace file cleanly"); + } + + if (NpcTraceState.executor != null) { + NpcTraceState.executor.shutdownNow(); + } + + NpcTraceState.writer = null; + NpcTraceState.world = null; + NpcTraceState.task = null; + NpcTraceState.executor = null; + NpcTraceState.lastKnownPosition = null; + NpcTraceState.npcTypeIdFilter = null; + NpcTraceState.filePath = null; + + JsonObject response = new JsonObject(); + response.addProperty("tracing", false); + response.addProperty("wasRunning", true); + response.addProperty("file", filePath); + response.addProperty("sampleCount", samples); + response.addProperty("missCount", misses); + + logger.atInfo().log("[STOP_NPC_TRACE] Stopped, " + samples + " samples (" + misses + + " misses) written to " + filePath); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + }; + + if (world != null) { + world.execute(closeAndRespond); + } else { + closeAndRespond.run(); + } + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canNpcTrace(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canNpcTrace(); + } + return false; + } +} From cd948f405d35d983c9e114e4e77f9b4fd478a34e Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Fri, 24 Jul 2026 16:55:36 -0500 Subject: [PATCH 15/17] Add replace_blocks_in_region: server-side bulk find-and-replace over a region scan_region + set_blocks_batch together were the bottleneck for any bulk block migration (e.g. the Soil_Pathway -> Road_Pathway road-guard fix): scan_region echoes every non-air block, and set_blocks_batch echoes every placed block, both scaling with volume regardless of how many actually matter. This does the scan and (optional) replace in one world.execute() pass and returns only a compact summary - counts, a bounding box, and per-axis histograms - so response size scales with the region's dimensions, not its volume. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../hytale/plugins/mcp/config/McpConfig.java | 18 ++ .../ReplaceBlocksInRegionFeature.java | 282 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ReplaceBlocksInRegionFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 9cb77fe..ef2d06a 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -83,6 +83,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new ListBlocksFeature(logger)); featureRegistry.registerFeature(new GetBlockFeature(logger)); featureRegistry.registerFeature(new ScanRegionFeature(logger, config)); + featureRegistry.registerFeature(new ReplaceBlocksInRegionFeature(logger, config)); featureRegistry.registerFeature(new GetHeightmapFeature(logger, config)); featureRegistry.registerFeature(new ExecuteCommandFeature(logger, config)); featureRegistry.registerFeature(new GiveItemFeature(logger, config)); diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java index 2f0548a..1c577e9 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/config/McpConfig.java @@ -143,6 +143,7 @@ public static class FeaturesConfig { private int maxBlocksBatch = 1000; private int maxScanVolume = 32768; private int maxHeightmapSamples = 10000; + private int maxReplaceVolume = 500000; public FeaturePermissions getPlayers() { return players; @@ -183,6 +184,14 @@ public int getMaxHeightmapSamples() { public void setMaxHeightmapSamples(int maxHeightmapSamples) { this.maxHeightmapSamples = maxHeightmapSamples; } + + public int getMaxReplaceVolume() { + return maxReplaceVolume; + } + + public void setMaxReplaceVolume(int maxReplaceVolume) { + this.maxReplaceVolume = maxReplaceVolume; + } } public static class FeaturePermissions { @@ -210,6 +219,7 @@ public static class FeaturePermissions { private boolean getNpcPosition = false; private boolean npcTrace = false; private boolean listItems = false; + private boolean replaceBlocksInRegion = false; public boolean canListPlayers() { return listPlayers; @@ -402,5 +412,13 @@ public boolean canListItems() { public void setListItems(boolean listItems) { this.listItems = listItems; } + + public boolean canReplaceBlocksInRegion() { + return replaceBlocksInRegion; + } + + public void setReplaceBlocksInRegion(boolean replaceBlocksInRegion) { + this.replaceBlocksInRegion = replaceBlocksInRegion; + } } } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ReplaceBlocksInRegionFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ReplaceBlocksInRegionFeature.java new file mode 100644 index 0000000..21255fa --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/ReplaceBlocksInRegionFeature.java @@ -0,0 +1,282 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.math.util.ChunkUtil; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.Rotation; +import com.hypixel.hytale.server.core.asset.type.blocktype.config.RotationTuple; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.chunk.WorldChunk; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Server-side find-and-replace over a bounding box: scans for one block type and, if a + * replacement type is given, swaps every match in the same pass - all inside a single + * {@code world.execute()} call, same as {@link ScanRegionFeature}/{@link SetBlocksBatchFeature}. + * + *

Exists specifically to eliminate the two costs those two features impose when used together + * for a bulk migration (as this project has repeatedly needed - e.g. swapping every + * world-gen-ineligible road surface block to a dedicated custom type): {@code scan_region} reports + * every non-air block in the box (not just matches), and {@code set_blocks_batch} echoes every + * placed block back - both scale with volume and blow up response size long before the box itself + * gets particularly large. This feature never returns a raw block list either direction - only a + * compact summary (counts, a bounding box of matches, and per-axis histograms) - so its own + * response size scales with the region's *dimensions*, not its *volume*. + * + *

If {@code replaceBlockType} is omitted, this is a read-only dry run (report matches, write + * nothing) - useful for characterizing a region (e.g. spotting where a road's footprint jogs via + * the histograms) before committing to a replacement. + */ +public class ReplaceBlocksInRegionFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private final HytaleLogger logger; + private final McpConfig config; + + public ReplaceBlocksInRegionFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "replace_blocks_in_region"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "replace_blocks_in_region", + "Scans a bounding box (any two opposite corners) for every block matching " + + "matchBlockType, and if replaceBlockType is given, replaces each match in " + + "the same pass. Returns only a compact summary - matchCount, replacedCount, " + + "failedCount, a bounding box of matches, and per-axis (x/z) histograms of " + + "match counts - never the raw block list, so response size scales with the " + + "region's dimensions, not its volume. Omit replaceBlockType for a read-only " + + "dry run. Max volume " + config.getFeatures().getMaxReplaceVolume() + + " blocks. Use this instead of scan_region + set_blocks_batch for any bulk " + + "find-and-replace over a region.", + "function" + ); + } + + @Override + public String getInputSchema() { + return McpToolSchema.schemaWithProperties( + java.util.Map.of( + "x1", McpToolSchema.integerProperty("First corner X coordinate"), + "y1", McpToolSchema.integerProperty("First corner Y coordinate"), + "z1", McpToolSchema.integerProperty("First corner Z coordinate"), + "x2", McpToolSchema.integerProperty("Opposite corner X coordinate"), + "y2", McpToolSchema.integerProperty("Opposite corner Y coordinate"), + "z2", McpToolSchema.integerProperty("Opposite corner Z coordinate"), + "world", McpToolSchema.stringProperty("World UUID"), + "matchBlockType", McpToolSchema.stringProperty("Block type identifier to search for"), + "replaceBlockType", McpToolSchema.stringProperty( + "Block type identifier to replace matches with. Omit for a read-only dry run " + + "(report matches, write nothing).") + ), + java.util.List.of("x1", "y1", "z1", "x2", "y2", "z2", "world", "matchBlockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + int x1 = getArgumentAsInt(call, "x1"); + int y1 = getArgumentAsInt(call, "y1"); + int z1 = getArgumentAsInt(call, "z1"); + int x2 = getArgumentAsInt(call, "x2"); + int y2 = getArgumentAsInt(call, "y2"); + int z2 = getArgumentAsInt(call, "z2"); + String worldUuidStr = getArgumentAsString(call, "world"); + String matchBlockTypeStr = getArgumentAsString(call, "matchBlockType"); + String replaceBlockTypeStr = getArgumentAsString(call, "replaceBlockType"); + + if (x1 == Integer.MIN_VALUE || y1 == Integer.MIN_VALUE || z1 == Integer.MIN_VALUE + || x2 == Integer.MIN_VALUE || y2 == Integer.MIN_VALUE || z2 == Integer.MIN_VALUE) { + return McpToolResponse.error("x1, y1, z1, x2, y2 and z2 are required integers"); + } + + if (worldUuidStr == null) { + return McpToolResponse.error("world UUID is required"); + } + + if (matchBlockTypeStr == null) { + return McpToolResponse.error("matchBlockType is required"); + } + + BlockType matchBlockType = BlockType.getAssetMap().getAsset(matchBlockTypeStr); + if (matchBlockType == null) { + return McpToolResponse.error("Unknown block type: " + matchBlockTypeStr); + } + + BlockType replaceBlockType = null; + if (replaceBlockTypeStr != null) { + replaceBlockType = BlockType.getAssetMap().getAsset(replaceBlockTypeStr); + if (replaceBlockType == null || replaceBlockType == BlockType.EMPTY) { + return McpToolResponse.error("Unknown block type: " + replaceBlockTypeStr); + } + } + + int minX = Math.min(x1, x2); + int maxX = Math.max(x1, x2); + int minY = Math.min(y1, y2); + int maxY = Math.max(y1, y2); + int minZ = Math.min(z1, z2); + int maxZ = Math.max(z1, z2); + + long volume = (long) (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1); + int maxVolume = config.getFeatures().getMaxReplaceVolume(); + if (volume > maxVolume) { + return McpToolResponse.error("Region volume " + volume + " exceeds maximum " + maxVolume + " blocks - shrink the box"); + } + + UUID worldUuid; + try { + worldUuid = UUID.fromString(worldUuidStr); + } catch (IllegalArgumentException e) { + return McpToolResponse.error("Invalid world UUID"); + } + + World world = Universe.get().getWorld(worldUuid); + if (world == null) { + return McpToolResponse.error("World not found: " + worldUuidStr); + } + + BlockType finalReplaceBlockType = replaceBlockType; + CompletableFuture future = new CompletableFuture<>(); + + world.execute(() -> { + try { + int matchCount = 0; + int replacedCount = 0; + int failedCount = 0; + int xMin = Integer.MAX_VALUE, xMax = Integer.MIN_VALUE; + int yMin = Integer.MAX_VALUE, yMax = Integer.MIN_VALUE; + int zMin = Integer.MAX_VALUE, zMax = Integer.MIN_VALUE; + TreeMap histogramByX = new TreeMap<>(); + TreeMap histogramByZ = new TreeMap<>(); + + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + WorldChunk chunk = world.getChunk(ChunkUtil.indexChunkFromBlock(x, z)); + if (chunk == null) { + continue; + } + for (int y = minY; y <= maxY; y++) { + BlockType blockType = world.getBlockType(x, y, z); + if (blockType != matchBlockType) { + continue; + } + + matchCount++; + xMin = Math.min(xMin, x); + xMax = Math.max(xMax, x); + yMin = Math.min(yMin, y); + yMax = Math.max(yMax, y); + zMin = Math.min(zMin, z); + zMax = Math.max(zMax, z); + histogramByX.merge(x, 1, Integer::sum); + histogramByZ.merge(z, 1, Integer::sum); + + if (finalReplaceBlockType != null) { + // Same 7-arg placeBlock overload SetBlocksBatchFeature uses - + // test=false skips the occupancy check that would otherwise reject + // overwriting an already-solid block. + RotationTuple rotationTuple = RotationTuple.of(Rotation.None, Rotation.None, Rotation.None); + boolean placed = chunk.placeBlock(x, y, z, finalReplaceBlockType.getId(), rotationTuple, 0, false); + if (placed) { + replacedCount++; + } else { + failedCount++; + } + } + } + } + } + + JsonObject response = new JsonObject(); + response.addProperty("volume", volume); + response.addProperty("matchCount", matchCount); + response.addProperty("replacedCount", replacedCount); + response.addProperty("failedCount", failedCount); + + if (matchCount > 0) { + JsonObject boundingBox = new JsonObject(); + boundingBox.addProperty("xMin", xMin); + boundingBox.addProperty("xMax", xMax); + boundingBox.addProperty("yMin", yMin); + boundingBox.addProperty("yMax", yMax); + boundingBox.addProperty("zMin", zMin); + boundingBox.addProperty("zMax", zMax); + response.add("boundingBox", boundingBox); + } else { + response.add("boundingBox", null); + } + + JsonObject histX = new JsonObject(); + for (var entry : histogramByX.entrySet()) { + histX.addProperty(String.valueOf(entry.getKey()), entry.getValue()); + } + response.add("histogramByX", histX); + + JsonObject histZ = new JsonObject(); + for (var entry : histogramByZ.entrySet()) { + histZ.addProperty(String.valueOf(entry.getKey()), entry.getValue()); + } + response.add("histogramByZ", histZ); + + logger.atInfo().log("[REPLACE_BLOCKS_IN_REGION] Scanned " + volume + " positions for " + + matchBlockTypeStr + " (matched " + matchCount + ", replaced " + replacedCount + + ", failed " + failedCount + ")"); + + future.complete(McpToolResponse.success(GSON.toJson(response))); + + } catch (Throwable t) { + logger.atSevere().withCause(t).log("[REPLACE_BLOCKS_IN_REGION] Exception"); + future.complete(McpToolResponse.error(t.toString())); + } + }); + + return future.join(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canReplaceBlocksInRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canReplaceBlocksInRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 4e09eeb918f5d831d541e5784a2b00f93c9848cc Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Sat, 25 Jul 2026 23:21:34 +0000 Subject: [PATCH 16/17] Add generate_sphere, generate_cylinder, generate_lattice_column shape generators Pure-geometry planning tools (no world access) that replace hand-derived shape math - a hollow sphere previously needed a one-off script, and a lattice tower (e.g. a Discworld Clacks tower) had no generator at all. Each returns a flat blocks array ready for set_blocks_batch/verify_placement, following the same convention as generate_road_corridor. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 3 + .../mcp/features/GenerateCylinderFeature.java | 202 ++++++++++++++ .../GenerateLatticeColumnFeature.java | 248 ++++++++++++++++++ .../mcp/features/GenerateSphereFeature.java | 191 ++++++++++++++ 4 files changed, 644 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateCylinderFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateLatticeColumnFeature.java create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateSphereFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index ef2d06a..0d8be49 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -113,6 +113,9 @@ private void registerFeatures() { featureRegistry.registerFeature(new StartNpcTraceFeature(logger, config)); featureRegistry.registerFeature(new StopNpcTraceFeature(logger, config)); featureRegistry.registerFeature(new ListItemsFeature(logger, config)); + featureRegistry.registerFeature(new GenerateSphereFeature(logger, config)); + featureRegistry.registerFeature(new GenerateCylinderFeature(logger, config)); + featureRegistry.registerFeature(new GenerateLatticeColumnFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateCylinderFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateCylinderFeature.java new file mode 100644 index 0000000..c7d1d76 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateCylinderFeature.java @@ -0,0 +1,202 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.Map; + +/** + * Pure geometry computation (no world access, no chunk loading) that generates a vertical cylinder + * or hollow cylindrical shell (a tower/pillar/silo shape) as a block coordinate list. Only a + * vertical (Y-axis) orientation is supported - every real use case so far (towers, pillars, wells) + * has been vertical; a horizontal variant can be added later if one is actually needed. + * + *

This is a planning tool only - it returns a block list, it does not write to the world. Pass the + * result straight to set_blocks_batch, then verify_placement afterward. + */ +public class GenerateCylinderFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + + private final HytaleLogger logger; + private final McpConfig config; + + public GenerateCylinderFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "generate_cylinder"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "generate_cylinder", + "Computes a vertical cylinder or hollow cylindrical shell block plan (tower/pillar/silo " + + "shape) rising from a base center point - pure geometry, does not touch the world. " + + "Only vertical (Y-axis) orientation is supported. A column is included at radius r " + + "from the axis if r is within the requested radius (solid) or within shellThickness " + + "of the outer surface (hollow); capBottom/capTop optionally fill the end discs solid " + + "even when hollow. Returns a flat blocks array ready to pass directly to " + + "set_blocks_batch, then verify_placement.", + "function" + ); + } + + @Override + public String getInputSchema() { + var centerSchema = McpToolSchema.objectProperty( + Map.of( + "x", McpToolSchema.integerProperty("Base center X coordinate"), + "y", McpToolSchema.integerProperty("Base (bottom) Y coordinate - the cylinder rises from here"), + "z", McpToolSchema.integerProperty("Base center Z coordinate") + ), + List.of("x", "y", "z"), + "The cylinder's base (bottom) center point" + ); + + return McpToolSchema.schemaWithProperties( + Map.of( + "center", centerSchema, + "radius", McpToolSchema.integerProperty("Cylinder radius in blocks"), + "height", McpToolSchema.integerProperty("Cylinder height in blocks, rising from the base Y"), + "blockType", McpToolSchema.stringProperty("Block type identifier to fill the cylinder/shell with"), + "hollow", McpToolSchema.booleanProperty("If true, only generate the outer wall instead of a solid cylinder. Defaults to false (solid)."), + "shellThickness", McpToolSchema.integerProperty("Wall thickness in blocks when hollow is true. Defaults to 1. Ignored when hollow is false."), + "capBottom", McpToolSchema.booleanProperty("If true, fill the bottom disc solid even when hollow. Defaults to false."), + "capTop", McpToolSchema.booleanProperty("If true, fill the top disc solid even when hollow. Defaults to false.") + ), + List.of("center", "radius", "height", "blockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object centerObj = call.getArguments().get("center"); + int radius = getArgumentAsInt(call, "radius"); + int height = getArgumentAsInt(call, "height"); + String blockType = getArgumentAsString(call, "blockType"); + boolean hollow = getArgumentAsBoolean(call, "hollow"); + int shellThickness = getArgumentAsInt(call, "shellThickness"); + if (shellThickness == Integer.MIN_VALUE) shellThickness = 1; + boolean capBottom = getArgumentAsBoolean(call, "capBottom"); + boolean capTop = getArgumentAsBoolean(call, "capTop"); + + if (centerObj == null) { + return McpToolResponse.error("center is required"); + } + if (radius == Integer.MIN_VALUE || radius < 1) { + return McpToolResponse.error("radius must be a positive integer"); + } + if (height == Integer.MIN_VALUE || height < 1) { + return McpToolResponse.error("height must be a positive integer"); + } + if (blockType == null) { + return McpToolResponse.error("blockType is required"); + } + if (hollow && shellThickness < 1) { + return McpToolResponse.error("shellThickness must be a positive integer when hollow is true"); + } + + JsonObject center; + try { + center = GSON.toJsonTree(centerObj).getAsJsonObject(); + } catch (Exception e) { + return McpToolResponse.error("Invalid center format: " + e.getMessage()); + } + int cx = center.get("x").getAsInt(); + int baseY = center.get("y").getAsInt(); + int cz = center.get("z").getAsInt(); + + long candidateCount = (long) Math.pow(2 * radius + 1, 2) * height; + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + if (candidateCount > maxBlocks * 8L) { + return McpToolResponse.error("radius " + radius + " / height " + height + " covers " + candidateCount + + " candidate positions - too large to evaluate in a single call. Use a smaller radius/height."); + } + + JsonArray blocks = new JsonArray(); + for (int dy = 0; dy < height; dy++) { + int y = baseY + dy; + boolean solidDisc = (dy == 0 && capBottom) || (dy == height - 1 && capTop); + for (int x = cx - radius; x <= cx + radius; x++) { + for (int z = cz - radius; z <= cz + radius; z++) { + double r = Math.hypot(x - cx, z - cz); + boolean include = (hollow && !solidDisc) + ? (r <= radius && r > radius - shellThickness) + : r <= radius; + if (!include) continue; + + JsonObject block = new JsonObject(); + block.addProperty("x", x); + block.addProperty("y", y); + block.addProperty("z", z); + block.addProperty("blockType", blockType); + blocks.add(block); + + if (blocks.size() > maxBlocks) { + return McpToolResponse.error("Generated cylinder exceeds the " + maxBlocks + + "-block set_blocks_batch limit - use a smaller radius/height, or a thinner " + + "shell if hollow."); + } + } + } + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("hollow", hollow); + response.add("blocks", blocks); + + logger.atInfo().log("[GENERATE_CYLINDER] Generated " + blocks.size() + " blocks (radius " + radius + + ", height " + height + ", hollow=" + hollow + ") based at (" + cx + "," + baseY + "," + cz + ")"); + + return McpToolResponse.success(GSON.toJson(response)); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateLatticeColumnFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateLatticeColumnFeature.java new file mode 100644 index 0000000..7ba256b --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateLatticeColumnFeature.java @@ -0,0 +1,248 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Pure geometry computation (no world access, no chunk loading) that generates a tall thin lattice + * tower - vertical corner posts evenly spaced around a circle, with optional horizontal ring braces + * connecting them at regular height intervals. Motivated by the Discworld Clacks tower shape + * (hytale-block-mod), which is exactly this kind of open lattice structure rather than a solid form. + * + *

This is a planning tool only - it returns a block list, it does not write to the world. Pass the + * result straight to set_blocks_batch, then verify_placement afterward. Diagonal/X cross-bracing is + * deliberately not included in this first version - only vertical posts and horizontal rings. + */ +public class GenerateLatticeColumnFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + + private final HytaleLogger logger; + private final McpConfig config; + + public GenerateLatticeColumnFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "generate_lattice_column"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "generate_lattice_column", + "Computes a tall thin lattice tower block plan - postCount vertical corner posts evenly " + + "spaced around a circle of the given radius, rising from a base center point, plus " + + "optional horizontal ring braces connecting adjacent posts every braceInterval blocks " + + "of height. Pure geometry, does not touch the world. Good for open lattice/truss-style " + + "towers (e.g. a Discworld Clacks semaphore tower) as opposed to a solid cylinder. " + + "Diagonal cross-bracing is not included in this version, only vertical posts and " + + "horizontal rings. Returns a flat blocks array ready to pass directly to " + + "set_blocks_batch, then verify_placement.", + "function" + ); + } + + @Override + public String getInputSchema() { + var centerSchema = McpToolSchema.objectProperty( + Map.of( + "x", McpToolSchema.integerProperty("Base center X coordinate"), + "y", McpToolSchema.integerProperty("Base Y coordinate - the tower rises from here"), + "z", McpToolSchema.integerProperty("Base center Z coordinate") + ), + List.of("x", "y", "z"), + "The tower's base center point (posts are placed around this axis, not on it)" + ); + + return McpToolSchema.schemaWithProperties( + Map.of( + "center", centerSchema, + "height", McpToolSchema.integerProperty("Tower height in blocks, rising from the base Y"), + "radius", McpToolSchema.integerProperty("Distance in blocks from the center axis to each corner post"), + "postCount", McpToolSchema.integerProperty("Number of vertical corner posts evenly spaced around the circle (e.g. 4 for a square lattice tower)"), + "postBlockType", McpToolSchema.stringProperty("Block type identifier for the vertical corner posts"), + "braceBlockType", McpToolSchema.stringProperty("Optional: block type identifier for horizontal ring braces connecting adjacent posts. Omit for posts only, no bracing."), + "braceInterval", McpToolSchema.integerProperty("Height interval in blocks between horizontal ring braces (e.g. 4 = a ring every 4 blocks). Required if braceBlockType is given; a ring is always placed at the base (y=0) as well.") + ), + List.of("center", "height", "radius", "postCount", "postBlockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object centerObj = call.getArguments().get("center"); + int height = getArgumentAsInt(call, "height"); + int radius = getArgumentAsInt(call, "radius"); + int postCount = getArgumentAsInt(call, "postCount"); + String postBlockType = getArgumentAsString(call, "postBlockType"); + String braceBlockType = getArgumentAsString(call, "braceBlockType"); + int braceInterval = getArgumentAsInt(call, "braceInterval"); + + if (centerObj == null) { + return McpToolResponse.error("center is required"); + } + if (height == Integer.MIN_VALUE || height < 1) { + return McpToolResponse.error("height must be a positive integer"); + } + if (radius == Integer.MIN_VALUE || radius < 1) { + return McpToolResponse.error("radius must be a positive integer"); + } + if (postCount == Integer.MIN_VALUE || postCount < 3) { + return McpToolResponse.error("postCount must be an integer >= 3"); + } + if (postBlockType == null) { + return McpToolResponse.error("postBlockType is required"); + } + if (braceBlockType != null && (braceInterval == Integer.MIN_VALUE || braceInterval < 1)) { + return McpToolResponse.error("braceInterval must be a positive integer when braceBlockType is given"); + } + + JsonObject center; + try { + center = GSON.toJsonTree(centerObj).getAsJsonObject(); + } catch (Exception e) { + return McpToolResponse.error("Invalid center format: " + e.getMessage()); + } + int cx = center.get("x").getAsInt(); + int baseY = center.get("y").getAsInt(); + int cz = center.get("z").getAsInt(); + + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + + // Post positions around the circle, rounded to integer block coordinates. + int[] postX = new int[postCount]; + int[] postZ = new int[postCount]; + for (int i = 0; i < postCount; i++) { + double angle = 2 * Math.PI * i / postCount; + postX[i] = cx + (int) Math.round(radius * Math.cos(angle)); + postZ[i] = cz + (int) Math.round(radius * Math.sin(angle)); + } + + // Keyed by "x,y,z" so a brace ring cell that coincides with a post never produces a duplicate entry. + Map cells = new LinkedHashMap<>(); // "x,y,z" -> {x,y,z} + int postBlockCount = 0; + int braceBlockCount = 0; + + for (int i = 0; i < postCount; i++) { + for (int dy = 0; dy < height; dy++) { + String key = postX[i] + "," + (baseY + dy) + "," + postZ[i]; + if (cells.putIfAbsent(key, new int[]{postX[i], baseY + dy, postZ[i]}) == null) { + postBlockCount++; + } + } + } + + if (braceBlockType != null) { + for (int dy = 0; dy < height; dy += braceInterval) { + int y = baseY + dy; + for (int i = 0; i < postCount; i++) { + int j = (i + 1) % postCount; + for (int[] xz : line2D(postX[i], postZ[i], postX[j], postZ[j])) { + String key = xz[0] + "," + y + "," + xz[1]; + if (!cells.containsKey(key)) { + cells.put(key, new int[]{xz[0], y, xz[1]}); + braceBlockCount++; + } + } + } + } + } + + if (cells.size() > maxBlocks) { + return McpToolResponse.error("Generated lattice column has " + cells.size() + " cells, exceeding " + + "the " + maxBlocks + "-block set_blocks_batch limit - use a smaller radius/height/postCount, " + + "or a larger braceInterval."); + } + + JsonArray blocks = new JsonArray(); + Map isPost = new LinkedHashMap<>(); + for (int i = 0; i < postCount; i++) { + for (int dy = 0; dy < height; dy++) { + isPost.put(postX[i] + "," + (baseY + dy) + "," + postZ[i], true); + } + } + for (Map.Entry entry : cells.entrySet()) { + int[] xyz = entry.getValue(); + boolean post = isPost.containsKey(entry.getKey()); + + JsonObject block = new JsonObject(); + block.addProperty("x", xyz[0]); + block.addProperty("y", xyz[1]); + block.addProperty("z", xyz[2]); + block.addProperty("blockType", post ? postBlockType : braceBlockType); + block.addProperty("role", post ? "post" : "brace"); + blocks.add(block); + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("postBlockCount", postBlockCount); + response.addProperty("braceBlockCount", braceBlockCount); + response.add("blocks", blocks); + + logger.atInfo().log("[GENERATE_LATTICE_COLUMN] Generated " + blocks.size() + " blocks (" + postCount + + " posts, height " + height + ", radius " + radius + ") based at (" + cx + "," + baseY + "," + cz + ")"); + + return McpToolResponse.success(GSON.toJson(response)); + } + + /** Integer 2D line between two points via a simple DDA walk, returns points inclusive of both ends. */ + private static List line2D(int x0, int z0, int x1, int z1) { + List points = new java.util.ArrayList<>(); + int steps = Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0)); + if (steps == 0) { + points.add(new int[]{x0, z0}); + return points; + } + for (int s = 0; s <= steps; s++) { + double t = (double) s / steps; + int x = (int) Math.round(x0 + (x1 - x0) * t); + int z = (int) Math.round(z0 + (z1 - z0) * t); + points.add(new int[]{x, z}); + } + return points; + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateSphereFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateSphereFeature.java new file mode 100644 index 0000000..b4c9ac2 --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateSphereFeature.java @@ -0,0 +1,191 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.List; +import java.util.Map; + +/** + * Pure geometry computation (no world access, no chunk loading) that generates a sphere or hollow + * spherical shell as a block coordinate list. Exists to replace the kind of one-off hand-derived + * shell-geometry script (round(distance from center) == radius) that building a hollow sphere used + * to require - see hytale-block-mod's project memory for the diameter-13 sphere that motivated this. + * + *

This is a planning tool only - it returns a block list, it does not write to the world. Pass the + * result straight to set_blocks_batch, then verify_placement afterward. + */ +public class GenerateSphereFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + + private final HytaleLogger logger; + private final McpConfig config; + + public GenerateSphereFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "generate_sphere"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "generate_sphere", + "Computes a sphere or hollow spherical shell block plan around a center point - pure " + + "geometry, does not touch the world. A block is included if its distance from the " + + "center is within the requested radius (solid) or within shellThickness of the " + + "surface (hollow). Returns a flat blocks array ready to pass directly to " + + "set_blocks_batch, then verify_placement.", + "function" + ); + } + + @Override + public String getInputSchema() { + var centerSchema = McpToolSchema.objectProperty( + Map.of( + "x", McpToolSchema.integerProperty("Center X coordinate"), + "y", McpToolSchema.integerProperty("Center Y coordinate"), + "z", McpToolSchema.integerProperty("Center Z coordinate") + ), + List.of("x", "y", "z"), + "The sphere's center point" + ); + + return McpToolSchema.schemaWithProperties( + Map.of( + "center", centerSchema, + "radius", McpToolSchema.integerProperty("Sphere radius in blocks (use the same value for a hollow shell's outer radius)"), + "blockType", McpToolSchema.stringProperty("Block type identifier to fill the sphere/shell with"), + "hollow", McpToolSchema.booleanProperty("If true, only generate a shell near the surface instead of a solid ball. Defaults to false (solid)."), + "shellThickness", McpToolSchema.integerProperty("Shell thickness in blocks when hollow is true. Defaults to 1 (a single-voxel-thick shell). Ignored when hollow is false.") + ), + List.of("center", "radius", "blockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object centerObj = call.getArguments().get("center"); + int radius = getArgumentAsInt(call, "radius"); + String blockType = getArgumentAsString(call, "blockType"); + boolean hollow = getArgumentAsBoolean(call, "hollow"); + int shellThickness = getArgumentAsInt(call, "shellThickness"); + if (shellThickness == Integer.MIN_VALUE) shellThickness = 1; + + if (centerObj == null) { + return McpToolResponse.error("center is required"); + } + if (radius == Integer.MIN_VALUE || radius < 1) { + return McpToolResponse.error("radius must be a positive integer"); + } + if (blockType == null) { + return McpToolResponse.error("blockType is required"); + } + if (hollow && shellThickness < 1) { + return McpToolResponse.error("shellThickness must be a positive integer when hollow is true"); + } + + JsonObject center; + try { + center = GSON.toJsonTree(centerObj).getAsJsonObject(); + } catch (Exception e) { + return McpToolResponse.error("Invalid center format: " + e.getMessage()); + } + int cx = center.get("x").getAsInt(); + int cy = center.get("y").getAsInt(); + int cz = center.get("z").getAsInt(); + + long candidateCount = (long) Math.pow(2 * radius + 1, 3); + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + if (candidateCount > maxBlocks * 8L) { + return McpToolResponse.error("radius " + radius + " bounding cube covers " + candidateCount + + " candidate positions - too large to evaluate in a single call. Use a smaller radius."); + } + + JsonArray blocks = new JsonArray(); + for (int x = cx - radius; x <= cx + radius; x++) { + for (int y = cy - radius; y <= cy + radius; y++) { + for (int z = cz - radius; z <= cz + radius; z++) { + double dist = Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2) + Math.pow(z - cz, 2)); + boolean include = hollow + ? (dist <= radius && dist > radius - shellThickness) + : dist <= radius; + if (!include) continue; + + JsonObject block = new JsonObject(); + block.addProperty("x", x); + block.addProperty("y", y); + block.addProperty("z", z); + block.addProperty("blockType", blockType); + blocks.add(block); + + if (blocks.size() > maxBlocks) { + return McpToolResponse.error("Generated sphere exceeds the " + maxBlocks + + "-block set_blocks_batch limit - use a smaller radius, or a thinner " + + "shell if hollow."); + } + } + } + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("hollow", hollow); + response.add("blocks", blocks); + + logger.atInfo().log("[GENERATE_SPHERE] Generated " + blocks.size() + " blocks (radius " + radius + + ", hollow=" + hollow + ") centered at (" + cx + "," + cy + "," + cz + ")"); + + return McpToolResponse.success(GSON.toJson(response)); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + // Pure computation, no world access - gate at the same level as scan_region/generate_road_corridor + // (read-only tier) rather than requiring a new permission flag/live config edit. + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +} From 1bf3cfa5bbc57c91b3bbe2fc3c9476214aca083c Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Sat, 25 Jul 2026 23:28:41 +0000 Subject: [PATCH 17/17] Add generate_staircase shape generator Pure-geometry planning tool (no world access) computing a solid block staircase ascending in one cardinal direction (North/South/East/West, matching the yaw convention already used in hytale-block-mod's Facing.java). Solid-by-default backfill means no floating overhangs; hollow:true gives surface-only treads instead. Follows the same compute-and-return convention as generate_sphere/generate_cylinder/generate_lattice_column. Co-Authored-By: Claude Sonnet 5 --- .../hytale/plugins/mcp/McpPlugin.java | 1 + .../features/GenerateStaircaseFeature.java | 240 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateStaircaseFeature.java diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java index 0d8be49..f1f2f38 100644 --- a/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/McpPlugin.java @@ -116,6 +116,7 @@ private void registerFeatures() { featureRegistry.registerFeature(new GenerateSphereFeature(logger, config)); featureRegistry.registerFeature(new GenerateCylinderFeature(logger, config)); featureRegistry.registerFeature(new GenerateLatticeColumnFeature(logger, config)); + featureRegistry.registerFeature(new GenerateStaircaseFeature(logger, config)); logger.atInfo().log("Registered " + featureRegistry.toString() + " features"); } diff --git a/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateStaircaseFeature.java b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateStaircaseFeature.java new file mode 100644 index 0000000..945673d --- /dev/null +++ b/src/main/java/com/top_serveurs/hytale/plugins/mcp/features/GenerateStaircaseFeature.java @@ -0,0 +1,240 @@ +package com.top_serveurs.hytale.plugins.mcp.features; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hypixel.hytale.logger.HytaleLogger; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager; +import com.top_serveurs.hytale.plugins.mcp.auth.McpAuthManager.AuthLevel; +import com.top_serveurs.hytale.plugins.mcp.config.McpConfig; +import com.top_serveurs.hytale.plugins.mcp.models.McpTool; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolCall; +import com.top_serveurs.hytale.plugins.mcp.models.McpToolResponse; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Pure geometry computation (no world access, no chunk loading) that generates a block staircase + * ascending from a base landing in one of the four cardinal directions. Forward vectors match the + * NORTH=-Z/SOUTH=+Z/EAST=+X/WEST=-X convention already established elsewhere in this project family + * (see hytale-block-mod's Facing.java) rather than inventing a new one. + * + *

Solid by default: every step's column is filled from the base landing height up to that step's + * own surface, so the result is a self-supporting ascending block mass with no floating overhangs - + * the same "safe by default" posture generate_cylinder/generate_sphere take with their hollow flag. + * Set hollow:true to place only each step's surface block instead (thin floating treads). + * + *

This is a planning tool only - it returns a block list, it does not write to the world. Pass the + * result straight to set_blocks_batch, then verify_placement afterward. + */ +public class GenerateStaircaseFeature implements McpFeature { + + private static final Gson GSON = new Gson(); + private static final Map FORWARD_VECTORS = Map.of( + "North", new int[]{0, -1}, + "South", new int[]{0, 1}, + "East", new int[]{1, 0}, + "West", new int[]{-1, 0} + ); + + private final HytaleLogger logger; + private final McpConfig config; + + public GenerateStaircaseFeature(HytaleLogger logger, McpConfig config) { + this.logger = logger; + this.config = config; + } + + @Override + public String getName() { + return "generate_staircase"; + } + + @Override + public McpTool getToolDefinition() { + return new McpTool( + "generate_staircase", + "Computes a block staircase block plan ascending from a base landing in one cardinal " + + "direction (North/South/East/West) - pure geometry, does not touch the world. Solid by " + + "default: each step's column is backfilled from the base landing height up to that " + + "step's own surface, so the result never has a floating overhang. Set hollow:true to " + + "place only each step's surface tread instead. Returns a flat blocks array ready to " + + "pass directly to set_blocks_batch, then verify_placement.", + "function" + ); + } + + @Override + public String getInputSchema() { + var baseSchema = McpToolSchema.objectProperty( + Map.of( + "x", McpToolSchema.integerProperty("Base landing X coordinate"), + "y", McpToolSchema.integerProperty("Base landing Y coordinate - the floor the first step rises from"), + "z", McpToolSchema.integerProperty("Base landing Z coordinate") + ), + List.of("x", "y", "z"), + "The floor/landing position the staircase rises from - step 1 begins one block forward of here" + ); + + return McpToolSchema.schemaWithProperties( + Map.of( + "base", baseSchema, + "direction", McpToolSchema.stringProperty("Cardinal direction the staircase ascends toward: North, South, East, or West"), + "steps", McpToolSchema.integerProperty("Number of steps"), + "width", McpToolSchema.integerProperty("Width in blocks perpendicular to the direction of travel, centered on the base position"), + "blockType", McpToolSchema.stringProperty("Block type identifier for the staircase"), + "stepDepth", McpToolSchema.integerProperty("Horizontal blocks of run per step, in the direction of travel. Defaults to 1."), + "stepHeight", McpToolSchema.integerProperty("Vertical rise in blocks per step. Defaults to 1."), + "hollow", McpToolSchema.booleanProperty("If true, place only each step's surface tread instead of backfilling solid down to the base landing height. Defaults to false (solid, no floating overhangs).") + ), + List.of("base", "direction", "steps", "width", "blockType") + ); + } + + @Override + public McpToolResponse execute(McpToolCall call, AuthLevel authLevel) { + Object baseObj = call.getArguments().get("base"); + String direction = getArgumentAsString(call, "direction"); + int steps = getArgumentAsInt(call, "steps"); + int width = getArgumentAsInt(call, "width"); + String blockType = getArgumentAsString(call, "blockType"); + int stepDepth = getArgumentAsInt(call, "stepDepth"); + if (stepDepth == Integer.MIN_VALUE) stepDepth = 1; + int stepHeight = getArgumentAsInt(call, "stepHeight"); + if (stepHeight == Integer.MIN_VALUE) stepHeight = 1; + boolean hollow = getArgumentAsBoolean(call, "hollow"); + + if (baseObj == null) { + return McpToolResponse.error("base is required"); + } + int[] forward = direction != null ? FORWARD_VECTORS.get(capitalize(direction)) : null; + if (forward == null) { + return McpToolResponse.error("direction must be one of North, South, East, West"); + } + if (steps == Integer.MIN_VALUE || steps < 1) { + return McpToolResponse.error("steps must be a positive integer"); + } + if (width == Integer.MIN_VALUE || width < 1) { + return McpToolResponse.error("width must be a positive integer"); + } + if (blockType == null) { + return McpToolResponse.error("blockType is required"); + } + if (stepDepth < 1) { + return McpToolResponse.error("stepDepth must be a positive integer"); + } + if (stepHeight < 1) { + return McpToolResponse.error("stepHeight must be a positive integer"); + } + + JsonObject base; + try { + base = GSON.toJsonTree(baseObj).getAsJsonObject(); + } catch (Exception e) { + return McpToolResponse.error("Invalid base format: " + e.getMessage()); + } + int bx = base.get("x").getAsInt(); + int by = base.get("y").getAsInt(); + int bz = base.get("z").getAsInt(); + + int perpX = -forward[1]; + int perpZ = forward[0]; + int halfWidthLow = (width - 1) / 2; + + int maxBlocks = config.getFeatures().getMaxBlocksBatch(); + long worstCase = (long) steps * stepDepth * width * (hollow ? 1 : (long) steps * stepHeight); + if (worstCase > maxBlocks * 8L) { + return McpToolResponse.error("steps " + steps + " / width " + width + " is too large to evaluate in " + + "a single call - use fewer steps, a narrower width, or hollow:true."); + } + + // Keyed by "x,y,z" so a step's backfill never double-emits a cell another step's backfill also covers. + Map cells = new LinkedHashMap<>(); + + for (int i = 0; i < steps; i++) { + int stepY = by + (i + 1) * stepHeight; + for (int d = 0; d < stepDepth; d++) { + int forwardDist = i * stepDepth + d + 1; + for (int j = 0; j < width; j++) { + int offset = j - halfWidthLow; + int x = bx + forward[0] * forwardDist + perpX * offset; + int z = bz + forward[1] * forwardDist + perpZ * offset; + + if (hollow) { + cells.putIfAbsent(x + "," + stepY + "," + z, new int[]{x, stepY, z}); + } else { + for (int y = by + 1; y <= stepY; y++) { + cells.putIfAbsent(x + "," + y + "," + z, new int[]{x, y, z}); + } + } + + if (cells.size() > maxBlocks) { + return McpToolResponse.error("Generated staircase exceeds the " + maxBlocks + + "-block set_blocks_batch limit - use fewer steps, a narrower width, or hollow:true."); + } + } + } + } + + JsonArray blocks = new JsonArray(); + for (int[] xyz : cells.values()) { + JsonObject block = new JsonObject(); + block.addProperty("x", xyz[0]); + block.addProperty("y", xyz[1]); + block.addProperty("z", xyz[2]); + block.addProperty("blockType", blockType); + blocks.add(block); + } + + JsonObject response = new JsonObject(); + response.addProperty("total", blocks.size()); + response.addProperty("hollow", hollow); + response.add("blocks", blocks); + + logger.atInfo().log("[GENERATE_STAIRCASE] Generated " + blocks.size() + " blocks (" + steps + + " steps, direction " + direction + ", hollow=" + hollow + ") from base (" + bx + "," + by + "," + bz + ")"); + + return McpToolResponse.success(GSON.toJson(response)); + } + + private static String capitalize(String s) { + if (s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(); + } + + @Override + public boolean hasPermission(McpAuthManager.AuthLevel authLevel, McpConfig config) { + if (authLevel == McpAuthManager.AuthLevel.ADMIN) { + return config.getFeatures().getAdmins().canScanRegion(); + } + if (authLevel == McpAuthManager.AuthLevel.PLAYER) { + return config.getFeatures().getPlayers().canScanRegion(); + } + return false; + } + + private int getArgumentAsInt(McpToolCall call, String key) { + try { + Object value = call.getArguments().get(key); + if (value == null) return Integer.MIN_VALUE; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } catch (Exception e) { + return Integer.MIN_VALUE; + } + } + + private boolean getArgumentAsBoolean(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + if (value == null) return false; + if (value instanceof Boolean) return (Boolean) value; + return Boolean.parseBoolean(value.toString()); + } + + private String getArgumentAsString(McpToolCall call, String key) { + Object value = call.getArguments().get(key); + return value != null ? value.toString() : null; + } +}