diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 51d6bd8..fe0bc61 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,10 @@ "mcp__minecraft-bridge__fill", "mcp__minecraft-bridge__teleport", "mcp__minecraft-bridge__save_schematic", - "mcp__minecraft-bridge__get_biome" + "mcp__minecraft-bridge__get_biome", + "mcp__minecraft-bridge__break_block", + "mcp__minecraft-bridge__list_schematics", + "mcp__minecraft-bridge__load_schematic" ] } } diff --git a/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc b/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc index 57660ae..2954b86 100644 Binary files a/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc and b/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc differ diff --git a/minecraft-mcp/src/minecraft_mcp/server.py b/minecraft-mcp/src/minecraft_mcp/server.py index 06cd6e1..b79e811 100644 --- a/minecraft-mcp/src/minecraft_mcp/server.py +++ b/minecraft-mcp/src/minecraft_mcp/server.py @@ -315,11 +315,19 @@ async def save_schematic(name: str, x1: int, y1: int, z1: int, x2: int, y2: int, @mcp.tool() async def load_schematic(name: str, x: int, y: int, z: int) -> str: """Load a previously saved schematic and place it at the given origin coordinates. - Name may only contain letters, numbers, underscore, and hyphen.""" + Name may only contain letters, numbers, underscore, and hyphen. + Supports both .litematic (Litematica) and .json formats — .litematic is checked first.""" await _ensure_build_session(x, y, z) return _fmt(await _request("POST", "/load_schematic", json={ "name": name, "x": x, "y": y, "z": z, - }, timeout=30.0)) + }, timeout=120.0)) + + +@mcp.tool() +async def list_schematics() -> str: + """List all available schematics (.json and .litematic) in the world's schematics folder. + Returns name, format, and file size for each schematic.""" + return _fmt(await _request("GET", "/list_schematics")) @mcp.tool() diff --git a/src/main/java/com/clod/clod/server/ClodHttpServer.java b/src/main/java/com/clod/clod/server/ClodHttpServer.java index 760bdcd..9fbfa4f 100644 --- a/src/main/java/com/clod/clod/server/ClodHttpServer.java +++ b/src/main/java/com/clod/clod/server/ClodHttpServer.java @@ -16,6 +16,11 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.Entity; import net.minecraft.world.item.ItemStack; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.Tag; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; @@ -26,17 +31,21 @@ import com.google.gson.GsonBuilder; import net.minecraft.world.level.storage.LevelResource; import org.slf4j.Logger; +import java.io.BufferedInputStream; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.*; import java.util.function.Supplier; @@ -87,6 +96,7 @@ public class ClodHttpServer { httpServer.createContext("/clear_area", this::handleClearArea); httpServer.createContext("/save_schematic", this::handleSaveSchematic); httpServer.createContext("/load_schematic", this::handleLoadSchematic); + httpServer.createContext("/list_schematics", this::handleListSchematics); // Long-poll endpoint httpServer.createContext("/chat/wait", this::handleChatWait); @@ -126,6 +136,18 @@ public class ClodHttpServer { return future.get(10, TimeUnit.SECONDS); } + private T runOnServerThreadLong(Supplier task) throws Exception { + CompletableFuture future = new CompletableFuture<>(); + mcServer.execute(() -> { + try { + future.complete(task.get()); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + return future.get(60, TimeUnit.SECONDS); + } + // --- Helpers --- private void sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException { @@ -323,6 +345,99 @@ public class ClodHttpServer { return prop.getName(state.getValue(prop)); } + @SuppressWarnings("unchecked") + private > BlockState setPropertyValue(BlockState state, Property prop, String value) { + Optional optVal = prop.getValue(value); + if (optVal.isPresent()) { + return state.setValue(prop, optVal.get()); + } + return state; + } + + private BlockState parseBlockStateFromNBT(CompoundTag entry) { + String name = entry.getString("Name"); + ResourceLocation blockId = ResourceLocation.parse(name); + if (!BuiltInRegistries.BLOCK.containsKey(blockId)) { + LOGGER.warn("Unknown block in litematic: {}, using air", name); + return Blocks.AIR.defaultBlockState(); + } + Block block = BuiltInRegistries.BLOCK.get(blockId); + BlockState state = block.defaultBlockState(); + if (entry.contains("Properties", Tag.TAG_COMPOUND)) { + CompoundTag props = entry.getCompound("Properties"); + for (String key : props.getAllKeys()) { + String val = props.getString(key); + Property prop = block.getStateDefinition().getProperty(key); + if (prop != null) { + state = setPropertyValue(state, prop, val); + } + } + } + return state; + } + + private static int getFromBitArray(long[] array, int index, int bitsPerEntry) { + long mask = (1L << bitsPerEntry) - 1; + long bitIndex = (long) index * bitsPerEntry; + int startLong = (int) (bitIndex >>> 6); + int startBit = (int) (bitIndex & 63); + long value = array[startLong] >>> startBit; + if (startBit + bitsPerEntry > 64 && startLong + 1 < array.length) { + value |= array[startLong + 1] << (64 - startBit); + } + return (int) (value & mask); + } + + private record BlockPlacement(BlockPos pos, BlockState state) {} + + private List parseLitematicRegion(CompoundTag regionNBT, int originX, int originY, int originZ) { + List placements = new ArrayList<>(); + + CompoundTag posTag = regionNBT.getCompound("Position"); + int posX = posTag.getInt("x"); + int posY = posTag.getInt("y"); + int posZ = posTag.getInt("z"); + + CompoundTag sizeTag = regionNBT.getCompound("Size"); + int sizeX = sizeTag.getInt("x"); + int sizeY = sizeTag.getInt("y"); + int sizeZ = sizeTag.getInt("z"); + + int absX = Math.abs(sizeX); + int absY = Math.abs(sizeY); + int absZ = Math.abs(sizeZ); + + ListTag paletteList = regionNBT.getList("BlockStatePalette", Tag.TAG_COMPOUND); + BlockState[] palette = new BlockState[paletteList.size()]; + for (int i = 0; i < paletteList.size(); i++) { + palette[i] = parseBlockStateFromNBT(paletteList.getCompound(i)); + } + + int bitsPerEntry = Math.max(2, (int) Math.ceil(Math.log(palette.length) / Math.log(2))); + + long[] blockStates = regionNBT.getLongArray("BlockStates"); + int volume = absX * absY * absZ; + + for (int i = 0; i < volume; i++) { + int paletteIndex = getFromBitArray(blockStates, i, bitsPerEntry); + if (paletteIndex < 0 || paletteIndex >= palette.length) continue; + BlockState state = palette[paletteIndex]; + if (state.isAir()) continue; + + int localY = i / (absX * absZ); + int localZ = (i % (absX * absZ)) / absX; + int localX = i % absX; + + int worldX = originX + posX + (sizeX < 0 ? localX + sizeX + 1 : localX); + int worldY = originY + posY + (sizeY < 0 ? localY + sizeY + 1 : localY); + int worldZ = originZ + posZ + (sizeZ < 0 ? localZ + sizeZ + 1 : localZ); + + placements.add(new BlockPlacement(new BlockPos(worldX, worldY, worldZ), state)); + } + + return placements; + } + private void handleNearbyEntities(HttpExchange exchange) throws IOException { if (!requireGet(exchange)) return; try { @@ -965,6 +1080,13 @@ public class ClodHttpServer { blockObj.addProperty("y", y - minY); blockObj.addProperty("z", z - minZ); blockObj.addProperty("type", BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()); + if (!state.getProperties().isEmpty()) { + JsonObject props = new JsonObject(); + for (Property prop : state.getProperties()) { + props.addProperty(prop.getName(), getPropertyValueString(state, prop)); + } + blockObj.add("properties", props); + } arr.add(blockObj); } } @@ -974,7 +1096,7 @@ public class ClodHttpServer { // Build schematic JSON JsonObject schematic = new JsonObject(); - schematic.addProperty("version", 1); + schematic.addProperty("version", 2); JsonObject size = new JsonObject(); size.addProperty("x", maxX - minX + 1); size.addProperty("y", maxY - minY + 1); @@ -1023,59 +1145,151 @@ public class ClodHttpServer { } Path worldPath = mcServer.getWorldPath(LevelResource.ROOT); - Path filePath = worldPath.resolve("schematics").resolve(name + ".json"); + Path schematicsDir = worldPath.resolve("schematics"); + Path litematicPath = schematicsDir.resolve(name + ".litematic"); + Path jsonPath = schematicsDir.resolve(name + ".json"); - if (!Files.exists(filePath)) { - sendJson(exchange, 404, errorJson("Schematic not found: " + name)); - return; - } - - // Read and parse on HTTP thread - JsonObject schematic; - try (FileReader reader = new FileReader(filePath.toFile(), StandardCharsets.UTF_8)) { - schematic = JsonParser.parseReader(reader).getAsJsonObject(); - } - - JsonArray blocksArr = schematic.getAsJsonArray("blocks"); - - // Validate all block types before placing - for (int i = 0; i < blocksArr.size(); i++) { - JsonObject b = blocksArr.get(i).getAsJsonObject(); - String type = b.get("type").getAsString(); - ResourceLocation blockId = ResourceLocation.parse(type); - if (!BuiltInRegistries.BLOCK.containsKey(blockId)) { - sendJson(exchange, 400, errorJson("Invalid block type in schematic: " + type)); + if (Files.exists(litematicPath)) { + // --- Litematic format --- + CompoundTag root; + try (InputStream is = new BufferedInputStream(Files.newInputStream(litematicPath))) { + root = NbtIo.readCompressed(is, NbtAccounter.unlimitedHeap()); + } catch (Exception e) { + sendJson(exchange, 400, errorJson("Failed to read litematic file: " + e.getMessage())); return; } - } - // Place blocks on server thread - JsonObject result = runOnServerThread(() -> { - ServerLevel level = (ServerLevel) bot.level(); - int placed = 0; + if (!root.contains("Regions", Tag.TAG_COMPOUND)) { + sendJson(exchange, 400, errorJson("Invalid litematic file: no Regions compound found")); + return; + } + + CompoundTag regions = root.getCompound("Regions"); + List allPlacements = new ArrayList<>(); + for (String regionName : regions.getAllKeys()) { + CompoundTag regionNBT = regions.getCompound(regionName); + allPlacements.addAll(parseLitematicRegion(regionNBT, originX, originY, originZ)); + } + + if (allPlacements.isEmpty()) { + JsonObject result = okJson(); + result.addProperty("placed", 0); + result.addProperty("format", "litematic"); + sendJson(exchange, 200, result); + return; + } + + JsonObject result = runOnServerThreadLong(() -> { + ServerLevel level = (ServerLevel) bot.level(); + int placed = 0; + for (BlockPlacement bp : allPlacements) { + level.setBlock(bp.pos(), bp.state(), 2); + placed++; + } + JsonObject json = okJson(); + json.addProperty("placed", placed); + json.addProperty("format", "litematic"); + return json; + }); + sendJson(exchange, 200, result); + + } else if (Files.exists(jsonPath)) { + // --- JSON format --- + JsonObject schematic; + try (FileReader reader = new FileReader(jsonPath.toFile(), StandardCharsets.UTF_8)) { + schematic = JsonParser.parseReader(reader).getAsJsonObject(); + } + + JsonArray blocksArr = schematic.getAsJsonArray("blocks"); + + // Validate all block types before placing for (int i = 0; i < blocksArr.size(); i++) { JsonObject b = blocksArr.get(i).getAsJsonObject(); - int bx = b.get("x").getAsInt() + originX; - int by = b.get("y").getAsInt() + originY; - int bz = b.get("z").getAsInt() + originZ; String type = b.get("type").getAsString(); ResourceLocation blockId = ResourceLocation.parse(type); - Block block = BuiltInRegistries.BLOCK.get(blockId); - level.setBlock(new BlockPos(bx, by, bz), block.defaultBlockState(), 3); - placed++; + if (!BuiltInRegistries.BLOCK.containsKey(blockId)) { + sendJson(exchange, 400, errorJson("Invalid block type in schematic: " + type)); + return; + } } - JsonObject json = okJson(); - json.addProperty("placed", placed); - return json; - }); - sendJson(exchange, 200, result); + + // Place blocks on server thread + JsonObject result = runOnServerThreadLong(() -> { + ServerLevel level = (ServerLevel) bot.level(); + int placed = 0; + for (int i = 0; i < blocksArr.size(); i++) { + JsonObject b = blocksArr.get(i).getAsJsonObject(); + int bx = b.get("x").getAsInt() + originX; + int by = b.get("y").getAsInt() + originY; + int bz = b.get("z").getAsInt() + originZ; + String type = b.get("type").getAsString(); + ResourceLocation blockId = ResourceLocation.parse(type); + Block block = BuiltInRegistries.BLOCK.get(blockId); + BlockState state = block.defaultBlockState(); + if (b.has("properties")) { + JsonObject props = b.getAsJsonObject("properties"); + for (String key : props.keySet()) { + Property prop = block.getStateDefinition().getProperty(key); + if (prop != null) { + state = setPropertyValue(state, prop, props.get(key).getAsString()); + } + } + } + level.setBlock(new BlockPos(bx, by, bz), state, 2); + placed++; + } + JsonObject json = okJson(); + json.addProperty("placed", placed); + json.addProperty("format", "json"); + return json; + }); + sendJson(exchange, 200, result); + + } else { + sendJson(exchange, 404, errorJson("Schematic not found: " + name + " (looked for .litematic and .json)")); + } } catch (TimeoutException e) { - sendJson(exchange, 504, errorJson("Server thread timeout")); + sendJson(exchange, 504, errorJson("Server thread timeout (schematic may be too large)")); } catch (Exception e) { sendJson(exchange, 400, errorJson(e.getMessage())); } } + private void handleListSchematics(HttpExchange exchange) throws IOException { + if (!requireGet(exchange)) return; + try { + Path worldPath = mcServer.getWorldPath(LevelResource.ROOT); + Path schematicsDir = worldPath.resolve("schematics"); + + JsonArray arr = new JsonArray(); + if (Files.exists(schematicsDir)) { + try (var stream = Files.list(schematicsDir)) { + stream.filter(p -> { + String fname = p.getFileName().toString(); + return fname.endsWith(".json") || fname.endsWith(".litematic"); + }).sorted().forEach(p -> { + JsonObject obj = new JsonObject(); + String filename = p.getFileName().toString(); + obj.addProperty("name", filename.substring(0, filename.lastIndexOf('.'))); + obj.addProperty("format", filename.endsWith(".litematic") ? "litematic" : "json"); + try { + obj.addProperty("size_bytes", Files.size(p)); + } catch (IOException e) { + obj.addProperty("size_bytes", -1); + } + arr.add(obj); + }); + } + } + + JsonObject result = okJson(); + result.add("schematics", arr); + sendJson(exchange, 200, result); + } catch (Exception e) { + sendJson(exchange, 500, errorJson(e.getMessage())); + } + } + // --- Long-poll --- private void handleChatWait(HttpExchange exchange) throws IOException {