chore: add snarky remarks while building from a schematic

This commit is contained in:
2026-02-15 13:05:19 +01:00
parent 30289d591f
commit 786f259b8e
4 changed files with 350 additions and 104 deletions
+2 -1
View File
@@ -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"
]
}
}
+117
View File
@@ -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.
@@ -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<BlockPlacement> placements, String format) {}
private List<BlockPlacement> parseLitematicRegion(CompoundTag regionNBT, int originX, int originY, int originZ) {
List<BlockPlacement> placements = new ArrayList<>();
@@ -438,6 +442,69 @@ public class ClodHttpServer {
return placements;
}
private List<BlockPlacement> parseJsonSchematic(Path jsonPath, int originX, int originY, int originZ) throws IOException {
List<BlockPlacement> 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<BlockPlacement> 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<BlockPlacement> 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<BlockPlacement> 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<Integer, Integer> 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<BlockPlacement> 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 {