diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fe0bc61..55c76f4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,8 @@ "mcp__minecraft-bridge__get_biome", "mcp__minecraft-bridge__break_block", "mcp__minecraft-bridge__list_schematics", - "mcp__minecraft-bridge__load_schematic" + "mcp__minecraft-bridge__load_schematic", + "mcp__minecraft-bridge__build_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 2954b86..c6f571b 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 b79e811..f5277af 100644 --- a/minecraft-mcp/src/minecraft_mcp/server.py +++ b/minecraft-mcp/src/minecraft_mcp/server.py @@ -2,6 +2,7 @@ import asyncio import json import math import os +import random import time from typing import Any @@ -323,6 +324,122 @@ async def load_schematic(name: str, x: int, y: int, z: int) -> str: }, timeout=120.0)) +_SNARKY_25 = [ + "Quarter done with {name}... whoever designed this owes me an explanation.", + "25% into {name}. Starting to question my life choices.", + "A quarter of {name} is done. It's giving... abstract art.", + "25% through {name}. I deserve a raise for this.", + "One quarter of {name} complete. Three more quarters of suffering to go.", +] + +_SNARKY_50 = [ + "Halfway through {name}. It's... starting to look like something, I guess.", + "50% of {name} done. No turning back now.", + "Half of {name} built. The other half is judging me.", + "Halfway done with {name}. Send snacks.", + "50% into {name}. I've seen worse. Not much worse, but worse.", +] + +_SNARKY_75 = [ + "75% done with {name}. I can almost see the finish line... and it's ugly.", + "Almost done with {name}. My masterpiece. And by masterpiece, I mean your fault.", + "Three quarters through {name}. I'm basically an artist now.", + "75% of {name} complete. I'm starting to feel things. Mostly exhaustion.", + "Nearly there with {name}. Don't look at it yet. Actually, don't look at it ever.", +] + + +@mcp.tool() +async def build_schematic(name: str, x: int, y: int, z: int) -> str: + """Load a schematic and build it layer by layer (bottom to top) with chat progress. + Creates a satisfying visual building effect. Use load_schematic for instant placement instead. + Name may only contain letters, numbers, underscore, and hyphen.""" + # 1. Get schematic info + info = await _request("POST", "/get_schematic_info", json={ + "name": name, "x": x, "y": y, "z": z, + }, timeout=15.0) + + if isinstance(info, dict) and "error" in info: + return _fmt(info) + + total_blocks = info.get("total_blocks", 0) + layers = info.get("layers", []) + fmt = info.get("format", "unknown") + num_layers = len(layers) + + if num_layers == 0: + return _fmt({"ok": True, "message": "Schematic is empty, nothing to build.", "placed": 0}) + + # 2. Start build session and announce + await _ensure_build_session(x, y, z) + await _request("POST", "/chat", json={ + "message": f"Starting build of {name}! {total_blocks} blocks, {num_layers} layers." + }) + + # 3. Build layer by layer + total_placed = 0 + milestones_hit: set[int] = set() + milestone_remarks = {25: _SNARKY_25, 50: _SNARKY_50, 75: _SNARKY_75} + for i, layer in enumerate(layers): + layer_y = layer["y"] + block_count = layer["block_count"] + + # Keep build session alive and look at current layer + await _ensure_build_session(x, layer_y, z) + + # Check if this layer crosses a milestone threshold + progress = (i + 1) / num_layers + remark = None + for threshold, pool in milestone_remarks.items(): + if threshold not in milestones_hit and progress >= threshold / 100: + milestones_hit.add(threshold) + remark = random.choice(pool).format(name=name) + break + + if remark: + await _request("POST", "/chat", json={"message": remark}) + else: + await _request("POST", "/chat", json={ + "message": f"Layer {i + 1}/{num_layers} (Y={layer_y}) - {block_count} blocks..." + }) + + result = await _request("POST", "/place_schematic_layer", json={ + "name": name, "x": x, "y": y, "z": z, "layer_y": layer_y, + }, timeout=15.0) + + if isinstance(result, dict) and "error" in result: + await _request("POST", "/chat", json={ + "message": f"Build failed at layer {i + 1}/{num_layers}! Error: {result['error']}" + }) + return _fmt({ + "ok": False, + "error": result["error"], + "layers_completed": i, + "layers_total": num_layers, + "blocks_placed": total_placed, + }) + + placed = result.get("placed", 0) + total_placed += placed + + # Visual delay between layers + if i < num_layers - 1: + await asyncio.sleep(0.4) + + # 4. Announce completion + await _request("POST", "/chat", json={ + "message": f"Build complete! Placed {total_placed} blocks in {num_layers} layers." + }) + + return _fmt({ + "ok": True, + "name": name, + "format": fmt, + "layers": num_layers, + "total_placed": total_placed, + }) + + @mcp.tool() async def list_schematics() -> str: """List all available schematics (.json and .litematic) in the world's schematics folder. diff --git a/src/main/java/com/clod/clod/server/ClodHttpServer.java b/src/main/java/com/clod/clod/server/ClodHttpServer.java index 9fbfa4f..671b6c4 100644 --- a/src/main/java/com/clod/clod/server/ClodHttpServer.java +++ b/src/main/java/com/clod/clod/server/ClodHttpServer.java @@ -96,6 +96,8 @@ public class ClodHttpServer { httpServer.createContext("/clear_area", this::handleClearArea); httpServer.createContext("/save_schematic", this::handleSaveSchematic); httpServer.createContext("/load_schematic", this::handleLoadSchematic); + httpServer.createContext("/get_schematic_info", this::handleGetSchematicInfo); + httpServer.createContext("/place_schematic_layer", this::handlePlaceSchematicLayer); httpServer.createContext("/list_schematics", this::handleListSchematics); // Long-poll endpoint @@ -390,6 +392,8 @@ public class ClodHttpServer { private record BlockPlacement(BlockPos pos, BlockState state) {} + private record SchematicData(List placements, String format) {} + private List parseLitematicRegion(CompoundTag regionNBT, int originX, int originY, int originZ) { List placements = new ArrayList<>(); @@ -438,6 +442,69 @@ public class ClodHttpServer { return placements; } + private List parseJsonSchematic(Path jsonPath, int originX, int originY, int originZ) throws IOException { + List placements = new ArrayList<>(); + JsonObject schematic; + try (FileReader reader = new FileReader(jsonPath.toFile(), StandardCharsets.UTF_8)) { + schematic = JsonParser.parseReader(reader).getAsJsonObject(); + } + JsonArray blocksArr = schematic.getAsJsonArray("blocks"); + 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); + if (!BuiltInRegistries.BLOCK.containsKey(blockId)) { + LOGGER.warn("Unknown block in JSON schematic: {}, skipping", type); + continue; + } + 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()); + } + } + } + placements.add(new BlockPlacement(new BlockPos(bx, by, bz), state)); + } + return placements; + } + + private SchematicData parseSchematic(String name, int originX, int originY, int originZ) throws Exception { + Path worldPath = mcServer.getWorldPath(LevelResource.ROOT); + Path schematicsDir = worldPath.resolve("schematics"); + Path litematicPath = schematicsDir.resolve(name + ".litematic"); + Path jsonPath = schematicsDir.resolve(name + ".json"); + + if (Files.exists(litematicPath)) { + CompoundTag root; + try (InputStream is = new BufferedInputStream(Files.newInputStream(litematicPath))) { + root = NbtIo.readCompressed(is, NbtAccounter.unlimitedHeap()); + } + if (!root.contains("Regions", Tag.TAG_COMPOUND)) { + throw new IllegalArgumentException("Invalid litematic file: no Regions compound found"); + } + 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)); + } + return new SchematicData(allPlacements, "litematic"); + } else if (Files.exists(jsonPath)) { + List placements = parseJsonSchematic(jsonPath, originX, originY, originZ); + return new SchematicData(placements, "json"); + } else { + throw new java.io.FileNotFoundException("Schematic not found: " + name + " (looked for .litematic and .json)"); + } + } + private void handleNearbyEntities(HttpExchange exchange) throws IOException { if (!requireGet(exchange)) return; try { @@ -1144,110 +1211,38 @@ public class ClodHttpServer { return; } - Path worldPath = mcServer.getWorldPath(LevelResource.ROOT); - Path schematicsDir = worldPath.resolve("schematics"); - Path litematicPath = schematicsDir.resolve(name + ".litematic"); - Path jsonPath = schematicsDir.resolve(name + ".json"); - - 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; - } - - 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(); - 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)); - return; - } - } - - // 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)")); + SchematicData data; + try { + data = parseSchematic(name, originX, originY, originZ); + } catch (java.io.FileNotFoundException e) { + sendJson(exchange, 404, errorJson(e.getMessage())); + return; + } catch (IllegalArgumentException e) { + sendJson(exchange, 400, errorJson(e.getMessage())); + return; } + + if (data.placements().isEmpty()) { + JsonObject result = okJson(); + result.addProperty("placed", 0); + result.addProperty("format", data.format()); + sendJson(exchange, 200, result); + return; + } + + JsonObject result = runOnServerThreadLong(() -> { + ServerLevel level = (ServerLevel) bot.level(); + int placed = 0; + for (BlockPlacement bp : data.placements()) { + level.setBlock(bp.pos(), bp.state(), 2); + placed++; + } + JsonObject json = okJson(); + json.addProperty("placed", placed); + json.addProperty("format", data.format()); + return json; + }); + sendJson(exchange, 200, result); } catch (TimeoutException e) { sendJson(exchange, 504, errorJson("Server thread timeout (schematic may be too large)")); } catch (Exception e) { @@ -1255,6 +1250,139 @@ public class ClodHttpServer { } } + private void handleGetSchematicInfo(HttpExchange exchange) throws IOException { + if (!requirePost(exchange)) return; + try { + JsonObject body = readJsonBody(exchange); + String name = body.get("name").getAsString(); + int originX = body.get("x").getAsInt(); + int originY = body.get("y").getAsInt(); + int originZ = body.get("z").getAsInt(); + + if (!name.matches("[a-zA-Z0-9_\\-]+")) { + sendJson(exchange, 400, errorJson("Invalid schematic name. Only letters, numbers, underscore, and hyphen allowed.")); + return; + } + + SchematicData data; + try { + data = parseSchematic(name, originX, originY, originZ); + } catch (java.io.FileNotFoundException e) { + sendJson(exchange, 404, errorJson(e.getMessage())); + return; + } catch (IllegalArgumentException e) { + sendJson(exchange, 400, errorJson(e.getMessage())); + return; + } + + // Group blocks by world Y using TreeMap for natural sort order + java.util.TreeMap layerCounts = new java.util.TreeMap<>(); + int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE; + int minY = Integer.MAX_VALUE, maxY = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE, maxZ = Integer.MIN_VALUE; + + for (BlockPlacement bp : data.placements()) { + int y = bp.pos().getY(); + layerCounts.merge(y, 1, Integer::sum); + minX = Math.min(minX, bp.pos().getX()); + maxX = Math.max(maxX, bp.pos().getX()); + minY = Math.min(minY, bp.pos().getY()); + maxY = Math.max(maxY, bp.pos().getY()); + minZ = Math.min(minZ, bp.pos().getZ()); + maxZ = Math.max(maxZ, bp.pos().getZ()); + } + + JsonObject result = okJson(); + result.addProperty("format", data.format()); + result.addProperty("total_blocks", data.placements().size()); + + if (!data.placements().isEmpty()) { + JsonObject size = new JsonObject(); + size.addProperty("x", maxX - minX + 1); + size.addProperty("y", maxY - minY + 1); + size.addProperty("z", maxZ - minZ + 1); + result.add("size", size); + } else { + JsonObject size = new JsonObject(); + size.addProperty("x", 0); + size.addProperty("y", 0); + size.addProperty("z", 0); + result.add("size", size); + } + + JsonArray layers = new JsonArray(); + for (var entry : layerCounts.entrySet()) { + JsonObject layer = new JsonObject(); + layer.addProperty("y", entry.getKey()); + layer.addProperty("block_count", entry.getValue()); + layers.add(layer); + } + result.add("layers", layers); + + sendJson(exchange, 200, result); + } catch (Exception e) { + sendJson(exchange, 500, errorJson(e.getMessage())); + } + } + + private void handlePlaceSchematicLayer(HttpExchange exchange) throws IOException { + if (!requirePost(exchange)) return; + try { + ClodEntity bot = requireBot(exchange); + if (bot == null) return; + + JsonObject body = readJsonBody(exchange); + String name = body.get("name").getAsString(); + int originX = body.get("x").getAsInt(); + int originY = body.get("y").getAsInt(); + int originZ = body.get("z").getAsInt(); + int layerY = body.get("layer_y").getAsInt(); + + if (!name.matches("[a-zA-Z0-9_\\-]+")) { + sendJson(exchange, 400, errorJson("Invalid schematic name. Only letters, numbers, underscore, and hyphen allowed.")); + return; + } + + SchematicData data; + try { + data = parseSchematic(name, originX, originY, originZ); + } catch (java.io.FileNotFoundException e) { + sendJson(exchange, 404, errorJson(e.getMessage())); + return; + } catch (IllegalArgumentException e) { + sendJson(exchange, 400, errorJson(e.getMessage())); + return; + } + + // Filter to only blocks at the requested Y layer + List layerPlacements = new ArrayList<>(); + for (BlockPlacement bp : data.placements()) { + if (bp.pos().getY() == layerY) { + layerPlacements.add(bp); + } + } + + JsonObject result = runOnServerThread(() -> { + ServerLevel level = (ServerLevel) bot.level(); + int placed = 0; + for (BlockPlacement bp : layerPlacements) { + level.setBlock(bp.pos(), bp.state(), 2); + placed++; + } + JsonObject json = okJson(); + json.addProperty("placed", placed); + json.addProperty("layer_y", layerY); + json.addProperty("format", data.format()); + return json; + }); + sendJson(exchange, 200, result); + } catch (TimeoutException e) { + sendJson(exchange, 504, errorJson("Server thread timeout")); + } catch (Exception e) { + sendJson(exchange, 400, errorJson(e.getMessage())); + } + } + private void handleListSchematics(HttpExchange exchange) throws IOException { if (!requireGet(exchange)) return; try {