chore: add snarky remarks while building from a schematic
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user