From 30289d591f2426989c56dba9fda74ba50f7db624 Mon Sep 17 00:00:00 2001 From: Martijn Jansen Date: Sun, 15 Feb 2026 12:10:29 +0100 Subject: [PATCH] feat: clod can build from litematica schematics --- .claude/settings.local.json | 5 +- .../__pycache__/server.cpython-314.pyc | Bin 27354 -> 28020 bytes minecraft-mcp/src/minecraft_mcp/server.py | 12 +- .../com/clod/clod/server/ClodHttpServer.java | 292 +++++++++++++++--- 4 files changed, 267 insertions(+), 42 deletions(-) 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 57660aeb079b0b28d0cf842939065d19fd52d663..2954b865eafd71d30b6b029f3dca54535f9d7b98 100644 GIT binary patch delta 993 zcmZ8fUr19?7(eH3SBL3bC{9b*)j(Y{O?2U$nOM|A{;8QcH`cgrckZsa$+>qu_@qSm z5{Vr_1EYeVpa|mj5Lnb(uZn~)da)oigk~H7;^QPk?MTjRKVd2w1c~YFKQpS6FQp0mQ&}=$kk~^ zQBxB;3rlTwbwRkp*W&7Kb|Vyoq~*xxs6cxW7UaT5GN09@wdHhl;UPNS+lN=R<=)de zyiO1I+l-$fRX4c-Xw4;zs-TJfO1wd5`&S`k%w3AV&_$z#zH}GZaoqMim6~tIj zsEa7^m=KB+*jI#)W7Gde%Zd;POM$CWPzcFHQK`*Sc61$XAx;LZ3{C5VZZ!4^H<#J_Do=Z{_B&G;4aagL|?L%#*Ga;E}kw+!Q3X&KI@8_%|T~N-b zsXhu9`}eP+#~Vu83;!d@H;6sUGzj2iT3|r=rs?o(Vxg*esl>5tax5KaSE1+GO^q!2Kv4}?XP{07%U>%|!y*4_c~J3tK4K;R9}L$In<2YF7{N({6<OXjtcpxV!m<#SAZhUXSu_29Vgk;JiGfLHb+l*eFtiTGgNdlr wMv8!C8%m!xGxV6V!jy06U>H&yc&TETh>6ni((8xP9K7`I4tS}2+8w$12R)V{-~a#s delta 583 zcmXX?L1+^}6rDfouCX*5n~+2?5L!=51SzQ!jYy5?#U{#DNn=U3YZ7TBF)~{(y{HFo zLXH0r1W!d!+FoS6Rq^go@DjwMiIKvF1TCWApT>cCf9B2q@BR71Hncy3p3wbcjF#DI zR(r{&enbyyjM?lVGqX{)diKqXpF<8a{5f28zwpz_3G)i^sp1)PF15f|nW|%~n3`lW zQifKXnQ(l68`s=>S|$q}bmwnC*FALoq5?h4<`a5{5_94)p>a@1Y7$@Nr(hR9<~x*f z!47HT^eBE{^H>~v3;u)#kv=1M;dnB<9EI&scsmO3oc$D>Ds1t!b^KIVKbjLs(pd|f ztTpVi<&;(`b$hj1B2V%Nev=-u7xA9JI6(BDo6{kWE@@!QDbV3N&0^@o*~Mf$G*o#s z{EP94@PP60h{w1Z=_^b%`kXj?wm6O3#RgnZp+^}{a>n<(FK>bO?96qpj zVBSsN%0Ot32yu$w|13c4;nPxB{Y{jKo23>kyP0we)W1Yn__gvuRj99;yK!6PN?K^R zxncp}Saqw|m0G(JWcxyz3u!2%n2-WO*0z0?b=R&}EK3|F&H$mwpVDTtB0$*oovSy> h_iL5&VuD!t;H2H?o!2}-a~~-5agWo<3u3qS 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 {