feat: clod can build from litematica schematics

This commit is contained in:
2026-02-15 12:10:29 +01:00
parent 033d2cbcf6
commit 30289d591f
4 changed files with 267 additions and 42 deletions
+4 -1
View File
@@ -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"
]
}
}
+10 -2
View File
@@ -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()
@@ -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> T runOnServerThreadLong(Supplier<T> task) throws Exception {
CompletableFuture<T> 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 <T extends Comparable<T>> BlockState setPropertyValue(BlockState state, Property<T> prop, String value) {
Optional<T> 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<BlockPlacement> parseLitematicRegion(CompoundTag regionNBT, int originX, int originY, int originZ) {
List<BlockPlacement> 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<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();
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 {