Initial minecraft-builder-mcp prototype and Gothic hall build
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import com.google.gson.*;
|
||||
import io.github.minecraftbuilder.core.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static io.github.minecraftbuilder.paper.RpcServer.Fault;
|
||||
|
||||
public final class BuilderPlugin extends JavaPlugin {
|
||||
private final Gson json = new Gson();
|
||||
private RpcServer http;
|
||||
private ExecutorService disk;
|
||||
private EditEngine engine;
|
||||
private World world;
|
||||
private BuildingWorld access;
|
||||
private SchematicAssets assets;
|
||||
private Region region;
|
||||
private String projectId, epoch, owner, adminToken, agentToken;
|
||||
private volatile boolean ioBusy, halted;
|
||||
private boolean writesPaused;
|
||||
private int maxBlocks;
|
||||
private final Map<String,JsonObject> messages = new LinkedHashMap<>();
|
||||
private final Map<String,Part> parts = new LinkedHashMap<>();
|
||||
private final Map<String,JsonObject> cameras = new LinkedHashMap<>();
|
||||
private final Set<String> captureIds = new HashSet<>();
|
||||
private final Map<String,Long> leased = new HashMap<>();
|
||||
private long lastPoll;
|
||||
private String chatClientId;
|
||||
private long cameraBusyUntil;
|
||||
private String activeCaptureId;
|
||||
private final HttpClient cameraHttp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
|
||||
record Part(String id,String name,String operationId,Set<BlockPos> positions,boolean locked) {}
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
saveDefaultConfig();
|
||||
for (String key : List.of("admin-token","agent-token","camera-token"))
|
||||
if (getConfig().getString(key,"").isBlank()) getConfig().set(key, randomToken());
|
||||
ComponentTokens.validate(getConfig().getString("admin-token"), getConfig().getString("agent-token"), getConfig().getString("camera-token"));
|
||||
if (getConfig().getString("world-epoch","").isBlank()) getConfig().set("world-epoch", UUID.randomUUID().toString());
|
||||
saveConfig();
|
||||
try { Files.setPosixFilePermissions(getDataFolder().toPath().resolve("config.yml"), java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); }
|
||||
catch (UnsupportedOperationException ignored) { }
|
||||
projectId=getConfig().getString("project-id","default"); epoch=getConfig().getString("world-epoch"); owner=getConfig().getString("owner-uuid","");
|
||||
adminToken=getConfig().getString("admin-token"); agentToken=getConfig().getString("agent-token");
|
||||
world=Bukkit.getWorld(getConfig().getString("world","world"));
|
||||
if (world==null) throw new IllegalStateException("Configured world is not loaded");
|
||||
region=new Region(world.getUID().toString(), configPos("region.min"), configPos("region.max"));
|
||||
maxBlocks=Math.min(4096,Math.max(1,getConfig().getInt("max-plan-blocks",4096)));
|
||||
access=new BuildingWorld(world);
|
||||
assets=new SchematicAssets(getDataFolder().toPath().resolve("schematics"));
|
||||
loadMetadata();
|
||||
disk=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"mcb-journal");t.setDaemon(true);return t;});
|
||||
Limits limits=new Limits(maxBlocks,512,Math.min(128,Math.max(1,getConfig().getInt("slice-blocks",128))),
|
||||
Math.min(5,Math.max(1,getConfig().getInt("slice-millis",5)))*1_000_000L,600_000,32);
|
||||
engine=new EditEngine(access,access,new ContextGuard(){
|
||||
public void check(Plan plan){guard(plan);}
|
||||
public void checkRecovery(Plan plan){guardContext(plan);}
|
||||
},new JsonJournal(getDataFolder().toPath().resolve("journal")),limits);
|
||||
Bukkit.getPluginManager().registerEvents(new Listener(){
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void placed(BlockPlaceEvent event){if(event.getBlock().getWorld().equals(world))engine.recordExternal(new BlockPos(event.getBlock().getX(),event.getBlock().getY(),event.getBlock().getZ()));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void broken(BlockBreakEvent event){if(event.getBlock().getWorld().equals(world))engine.recordExternal(new BlockPos(event.getBlock().getX(),event.getBlock().getY(),event.getBlock().getZ()));}
|
||||
},this);
|
||||
http=new RpcServer(getConfig().getInt("http-port",8765),adminToken,agentToken,this::rpc);
|
||||
Objects.requireNonNull(getCommand("ai")).setExecutor(this::command);
|
||||
Bukkit.getScheduler().runTaskTimer(this,this::tick,1,1);
|
||||
getLogger().info("Builder ready on loopback port "+http.port()+". Run /ai setup as the project owner.");
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("Builder disabled: "+e.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
}
|
||||
}
|
||||
@Override public void onDisable() {
|
||||
if(http!=null) http.close();
|
||||
if(disk!=null) { disk.shutdown(); try { if(!disk.awaitTermination(3,TimeUnit.SECONDS)) disk.shutdownNow(); } catch(InterruptedException e){Thread.currentThread().interrupt();} }
|
||||
}
|
||||
private BlockPos configPos(String key) { return new BlockPos(getConfig().getInt(key+".x"),getConfig().getInt(key+".y"),getConfig().getInt(key+".z")); }
|
||||
private static String randomToken() { byte[] bytes=new byte[32];new SecureRandom().nextBytes(bytes);return HexFormat.of().formatHex(bytes); }
|
||||
private <T>T main(Callable<T> work) throws Exception {
|
||||
if(Bukkit.isPrimaryThread()) return work.call();
|
||||
Future<T> task=Bukkit.getScheduler().callSyncMethod(this,work);
|
||||
try { return task.get(20,TimeUnit.SECONDS); }
|
||||
catch(TimeoutException e){task.cancel(false);throw e;}
|
||||
}
|
||||
private void guard(Plan p) {
|
||||
guardContext(p);
|
||||
if(halted) throw new Fault("recovery_required","Journal IO failed; restart and inspect recovery");
|
||||
if(writesPaused) throw new Fault("cancelled","Writing paused by the owner; send a new request or /ai resume");
|
||||
for(Change c:p.changes())if(!c.expected().equals(c.desired()))
|
||||
for(Part part:parts.values())if(part.locked()&&part.positions().contains(c.pos()))
|
||||
throw new Fault("permission_denied","Part is protected: "+part.name());
|
||||
}
|
||||
// Recovery inspection/abandonment never writes blocks. Retain its context
|
||||
// authorization without applying the pause or protected-part write veto.
|
||||
private void guardContext(Plan p) {
|
||||
if(!p.projectId().equals(projectId)||!p.worldEpoch().equals(epoch)||!p.region().worldId().equals(world.getUID().toString()))
|
||||
throw new Fault("version_mismatch","Project or world changed");
|
||||
if(!getConfig().getBoolean("allow-local-automation",false)) {
|
||||
Player player=owner.isBlank()?null:Bukkit.getPlayer(UUID.fromString(owner));
|
||||
if(player==null||!player.hasPermission("minecraftbuilder.use")) throw new Fault("permission_denied","Project owner must be online and authorised");
|
||||
}
|
||||
for(Change c:p.changes()) {
|
||||
if(!region.contains(c.pos()))throw new Fault("out_of_bounds","Plan exceeds current project area");
|
||||
}
|
||||
for(Plan.Dependency dependency:p.dependencies())if(!region.contains(dependency.pos()))throw new Fault("out_of_bounds","Dependency exceeds current project area");
|
||||
}
|
||||
private void scoped(JsonObject p,boolean admin) {
|
||||
if(!str(p,"project_id",projectId).equals(projectId))throw new Fault("permission_denied","Unknown project scope");
|
||||
String player=str(p,"player_id","");
|
||||
if(getConfig().getBoolean("allow-local-automation",false)&&player.equals("console"))return;
|
||||
if(owner.isBlank()||!player.equals(owner))throw new Fault("permission_denied","Only the bound project owner can use this capability; run /ai setup");
|
||||
Player online=Bukkit.getPlayer(UUID.fromString(owner));
|
||||
if(online==null||!online.hasPermission("minecraftbuilder.use"))throw new Fault("permission_denied","Owner is offline or permission was revoked");
|
||||
}
|
||||
private void available() { if(halted)throw new Fault("recovery_required","Journal unavailable");if(ioBusy)throw new Fault("busy","A journal step is in progress; retry"); }
|
||||
|
||||
private Object rpc(String method,JsonObject p,boolean admin)throws Exception {
|
||||
if(method.equals("chat_poll"))return main(()->pollChat(p));
|
||||
if(method.equals("chat_reply"))return main(()->reply(p));
|
||||
main(()->{scoped(p,admin);return null;});
|
||||
switch(method) {
|
||||
case "recovery_review":{
|
||||
if(!admin)throw new Fault("permission_denied","Recovery requires explicit administrator capability");
|
||||
return main(()->engine.reviewRecovery(required(p,"operation_id")));
|
||||
}
|
||||
case "recovery_abandon":{
|
||||
if(!admin)throw new Fault("permission_denied","Recovery requires explicit administrator capability");
|
||||
OperationView view=main(()->{available();OperationView v=engine.abandonRecovery(required(p,"operation_id"),required(p,"expected_digest"));ioBusy=true;return v;});
|
||||
try{engine.flushOperation(view.id());return status(engine.status(view.id()));}catch(Exception e){halted=true;throw e;}finally{main(()->{ioBusy=false;return null;});}
|
||||
}
|
||||
case "asset_list":return Map.of("assets",assets.list().stream().filter(a->a.name().toLowerCase(Locale.ROOT).contains(str(p,"query","").toLowerCase(Locale.ROOT))).toList());
|
||||
case "schematic_export":{
|
||||
Map<BlockPos,String> snapshot=main(()->{
|
||||
Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max")));
|
||||
if(!region.contains(area.min())||!region.contains(area.max()))throw new Fault("out_of_bounds","Export exceeds project region");
|
||||
if(area.volume()>maxBlocks)throw new Fault("budget_exceeded","Export at most "+maxBlocks+" blocks");
|
||||
org.bukkit.util.BoundingBox box=new org.bukkit.util.BoundingBox(area.min().x(),area.min().y(),area.min().z(),area.max().x()+1.0,area.max().y()+1.0,area.max().z()+1.0);
|
||||
if(world.getNearbyEntities(box).stream().anyMatch(e->!(e instanceof Player)))throw new Fault("unsupported_entity","Export region contains entities; this prototype exports blocks only");
|
||||
Map<BlockPos,String> data=new LinkedHashMap<>();
|
||||
for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){
|
||||
BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);if(!access.supports(state))throw new Fault("unsupported_block","Export contains unsupported block "+state);data.put(at,state);
|
||||
}return data;
|
||||
});
|
||||
return assets.exportSnapshot(required(p,"name"),snapshot,p.has("origin")?pos(p.getAsJsonObject("origin")):pos(p.getAsJsonObject("min")),main(()->Bukkit.getUnsafe().getDataVersion()));
|
||||
}
|
||||
case "schematic_import_prepare":{
|
||||
Map<BlockPos,String> data=new LinkedHashMap<>(assets.read(required(p,"asset_id"),pos(p.getAsJsonObject("target")),p.has("rotation")?integer(p,"rotation"):0,main(()->Bukkit.getUnsafe().getDataVersion())));
|
||||
Plan plan=main(()->{available();data.replaceAll((at,state)->access.canonical(state));for(BlockPos at:data.keySet())checkSurroundings(at,data);Plan v=engine.prepare(projectId,epoch,region,data,Set.of());ioBusy=true;return v;});
|
||||
try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});}
|
||||
}
|
||||
case "project_context": return main(this::context);
|
||||
case "region_inspect": return main(()->inspect(p));
|
||||
case "region_changes": return Map.of("status","resync_required","reason","Prototype uses fresh bounded reads; complete event delta journal is not implemented");
|
||||
case "build_prepare": {
|
||||
JsonObject recipe=p.getAsJsonObject("recipe");
|
||||
Map<BlockPos,String> desired=new LinkedHashMap<>(RecipeCompiler.compile(recipe,maxBlocks));
|
||||
Set<BlockPos> dependencies=new LinkedHashSet<>();
|
||||
if(p.has("dependencies"))for(JsonElement e:p.getAsJsonArray("dependencies"))dependencies.add(pos(e.getAsJsonObject()));
|
||||
if(dependencies.size()>512)throw new Fault("budget_exceeded","At most 512 explicit dependencies supported");
|
||||
Plan plan=main(()->{
|
||||
available();
|
||||
desired.replaceAll((k,v)->access.canonical(v));
|
||||
if(p.has("part_id")){
|
||||
Part part=parts.get(required(p,"part_id"));if(part==null)throw new Fault("not_found","Part not found");
|
||||
if(!part.positions().containsAll(desired.keySet()))throw new Fault("out_of_bounds","Patch exceeds the exact part mask; create a new part for an extension");
|
||||
}
|
||||
for(BlockPos at:desired.keySet())checkSurroundings(at,desired);
|
||||
Plan value=engine.prepare(projectId,epoch,region,desired,dependencies);ioBusy=true;return value;
|
||||
});
|
||||
try { engine.persistPlan(plan.id()); return planSummary(plan); }
|
||||
finally {main(()->{ioBusy=false;return null;});}
|
||||
}
|
||||
case "build_apply": {
|
||||
OperationView operation=main(()->{
|
||||
available();Plan plan=engine.plan(required(p,"plan_id"));
|
||||
if(!hash(plan).equals(required(p,"plan_hash")))throw new Fault("stale_snapshot","Plan hash does not match");
|
||||
guard(plan);OperationView value=engine.start(plan.id(),required(p,"idempotency_key"));ioBusy=true;return value;
|
||||
});
|
||||
try{engine.flushOperation(operation.id());return status(engine.status(operation.id()));}
|
||||
catch(Exception e){halted=true;throw e;}
|
||||
finally{main(()->{ioBusy=false;return null;});}
|
||||
}
|
||||
case "operation_status":return main(()->status(engine.status(required(p,"operation_id"))));
|
||||
case "operation_cancel":return main(()->status(engine.cancel(required(p,"operation_id"))));
|
||||
case "operation_undo_prepare":{
|
||||
Plan plan=main(()->{available();Plan value=engine.prepareUndo(required(p,"operation_id"));ioBusy=true;return value;});
|
||||
try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});}
|
||||
}
|
||||
case "part_define":{
|
||||
Object result=main(()->definePart(p));saveMetadata();return result;
|
||||
}
|
||||
case "part_get":return main(()->{
|
||||
Part part=parts.get(required(p,"part_id"));if(part==null)throw new Fault("not_found","Part not found");
|
||||
return Map.of("part_id",part.id(),"name",part.name(),"block_count",part.positions().size(),"protected",part.locked(),"operation_id",part.operationId(),"bounds",bounds(part.positions()));
|
||||
});
|
||||
case "camera_list":return main(()->Map.of("cameras",new ArrayList<>(cameras.values()),"configured",!getConfig().getString("camera-player-uuid","").isBlank()));
|
||||
case "camera_capture":return capture(p);
|
||||
default:throw new Fault("unsupported_method","Method not implemented: "+method);
|
||||
}
|
||||
}
|
||||
private void checkSurroundings(BlockPos at,Map<BlockPos,String> desired) {
|
||||
if(!region.contains(at))throw new Fault("out_of_bounds","Recipe exceeds project region");
|
||||
for(BlockPos d:List.of(new BlockPos(1,0,0),new BlockPos(-1,0,0),new BlockPos(0,1,0),new BlockPos(0,-1,0),new BlockPos(0,0,1),new BlockPos(0,0,-1))) {
|
||||
BlockPos neighbor=at.add(d);
|
||||
if(neighbor.y()<world.getMinHeight()||neighbor.y()>=world.getMaxHeight()||desired.containsKey(neighbor))continue;
|
||||
String state=access.getBlock(neighbor);
|
||||
if(!access.supports(state))throw new Fault("unsupported_block","Unsupported adjacent environment at "+neighbor+"; use a controlled construction area");
|
||||
}
|
||||
}
|
||||
private Object context() {
|
||||
return Map.ofEntries(Map.entry("schema_version",1),Map.entry("project_id",projectId),Map.entry("world_id",world.getUID().toString()),Map.entry("world_epoch",epoch),Map.entry("region",region),
|
||||
Map.entry("max_plan_blocks",maxBlocks),Map.entry("parts",parts.values().stream().limit(64).map(p->Map.of("part_id",p.id(),"name",p.name(),"protected",p.locked(),"block_count",p.positions().size())).toList()),
|
||||
Map.entry("parts_total",parts.size()),Map.entry("operations",engine.recentOperations(20).stream().map(v->Map.of("operation_id",v.id(),"plan_id",v.planId(),"status",v.status().name().toLowerCase(Locale.ROOT),"written",v.written(),"total_changes",v.totalChanges())).toList()),
|
||||
Map.entry("operations_total",engine.operationCount()),Map.entry("truncated",parts.size()>64||engine.operationCount()>20),
|
||||
Map.entry("supported_materials",BuildingWorld.supportedMaterials()),Map.entry("recipe",Map.of("version",1,"operations",List.of("box","line","cylinder","repeat"))),Map.entry("capabilities",List.of("region_inspect","build_prepare","build_apply","operation_status","operation_cancel","operation_undo_prepare","part_define","part_get","camera_list","camera_capture","asset_list","schematic_export","schematic_import_prepare")),
|
||||
Map.entry("limitations",List.of("One configured owner and project","Loaded chunks only","No automatic recipe merge","Complete delta journal is not implemented","Camera readiness is heuristic","Sponge schematic v2 only; no entities or block entities")));
|
||||
}
|
||||
private Object inspect(JsonObject p) {
|
||||
Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max")));
|
||||
if(!region.contains(area.min())||!region.contains(area.max()))throw new Fault("out_of_bounds","Read exceeds project area");
|
||||
if(area.volume()>4096)throw new Fault("budget_exceeded","Read at most 4096 blocks per request");
|
||||
Map<String,Integer> palette=new TreeMap<>();List<Object> blocks=new ArrayList<>();
|
||||
boolean exact=str(p,"detail","summary").equals("blocks");
|
||||
for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){
|
||||
BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);palette.merge(state,1,Integer::sum);if(exact)blocks.add(Map.of("pos",at,"state",state));
|
||||
}
|
||||
return Map.of("region",area,"palette",palette,"blocks",blocks,"sampled_at",System.currentTimeMillis(),"world_epoch",epoch,"truncated",false);
|
||||
}
|
||||
private Map<String,Object> planSummary(Plan plan) {
|
||||
return Map.of("plan_id",plan.id(),"plan_hash",hash(plan),"changed_blocks",plan.changes().stream().filter(c->!c.expected().equals(c.desired())).count(),"region",plan.region(),"expires_at",plan.expiresAtMillis());
|
||||
}
|
||||
private Map<String,Object> status(OperationView v) {
|
||||
return Map.ofEntries(Map.entry("operation_id",v.id()),Map.entry("plan_id",v.planId()),Map.entry("status",v.status().name().toLowerCase(Locale.ROOT)),Map.entry("written",v.written()),Map.entry("total_changes",v.totalChanges()),Map.entry("processed",v.processed()),Map.entry("conflicts",v.conflicts()),Map.entry("message",Objects.toString(v.message(),"")));
|
||||
}
|
||||
private String hash(Plan p) {
|
||||
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.toJson(p).getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new AssertionError(e);}
|
||||
}
|
||||
private void tick() {
|
||||
if(ioBusy||halted||engine==null)return;
|
||||
for(OperationView view:engine.pendingOperations()) {
|
||||
if(!view.status().terminal()&&lastPoll>0&&System.currentTimeMillis()-lastPoll>15_000&&!getConfig().getBoolean("allow-local-automation",false))engine.cancel(view.id());
|
||||
if(view.needsFlush()){flushAsync(view.id());return;}
|
||||
if(view.status().terminal())continue;
|
||||
try {
|
||||
Optional<SliceIntent> next=engine.stageSlice(view.id());
|
||||
if(next.isEmpty()){if(engine.status(view.id()).needsFlush())flushAsync(view.id());return;}
|
||||
ioBusy=true;SliceIntent intent=next.get();
|
||||
disk.submit(()->{
|
||||
try {
|
||||
engine.persistIntent(intent);
|
||||
main(()->{engine.commitSlice(intent);return null;});
|
||||
engine.flushOperation(view.id());
|
||||
}catch(Exception e){halted=true;getLogger().severe("Journal/apply stopped: "+e.getClass().getSimpleName()+"; inspect recovery after restart");}
|
||||
finally{ioBusy=false;}
|
||||
});return;
|
||||
}catch(Exception e){engine.cancel(view.id());getLogger().warning("Operation stopped: "+e.getMessage());return;}
|
||||
}
|
||||
}
|
||||
private void flushAsync(String id) {
|
||||
ioBusy=true;disk.submit(()->{try{engine.flushOperation(id);}catch(Exception e){halted=true;getLogger().severe("Journal flush failed; editing halted");}finally{ioBusy=false;}});
|
||||
}
|
||||
private Object definePart(JsonObject p) {
|
||||
available();OperationView v=engine.status(required(p,"operation_id"));
|
||||
if(v.status()!=OperationStatus.APPLIED)throw new Fault("busy","Part requires an applied operation");
|
||||
Set<BlockPos> positions=new HashSet<>();
|
||||
for(Receipt receipt:engine.receipts(v.id()))positions.add(receipt.change().pos());
|
||||
if(positions.isEmpty())throw new Fault("invalid_request","Operation has no owned blocks");
|
||||
if(parts.size()>=64)throw new Fault("budget_exceeded","Prototype supports 64 named parts per project");
|
||||
for(Part existing:parts.values())if(!Collections.disjoint(existing.positions(),positions))throw new Fault("conflict","Part overlaps "+existing.name());
|
||||
String name=required(p,"name");if(name.length()>64)throw new Fault("invalid_request","Part name too long");
|
||||
Part value=new Part(UUID.randomUUID().toString(),name,v.id(),Set.copyOf(positions),false);parts.put(value.id(),value);
|
||||
return Map.of("part_id",value.id(),"name",name,"block_count",positions.size());
|
||||
}
|
||||
private Object pollChat(JsonObject request) {
|
||||
String client=required(request,"client_id");
|
||||
if(chatClientId!=null&&!chatClientId.equals(client)&&!leased.isEmpty()) {
|
||||
for(String id:List.copyOf(leased.keySet())) {
|
||||
JsonObject old=messages.remove(id);
|
||||
if(old!=null){Player p=Bukkit.getPlayer(UUID.fromString(old.get("playerId").getAsString()));if(p!=null)p.sendMessage("[Builder] Bridge restarted. Previous request stopped; check the world before resubmitting.");}
|
||||
}
|
||||
leased.clear();writesPaused=true;
|
||||
for(OperationView v:engine.operations())if(!v.status().terminal())engine.cancel(v.id());
|
||||
}
|
||||
chatClientId=client;lastPoll=System.currentTimeMillis();List<JsonObject> pending=new ArrayList<>();
|
||||
for(var item:messages.entrySet())if(!leased.containsKey(item.getKey())){
|
||||
leased.put(item.getKey(),lastPoll);pending.add(item.getValue());if(pending.size()==8)break;
|
||||
}
|
||||
return Map.of("messages",pending);
|
||||
}
|
||||
private Object reply(JsonObject p) {
|
||||
String id=required(p,"id"),player=required(p,"playerId");JsonObject source=messages.get(id);
|
||||
if(source==null||!source.get("playerId").getAsString().equals(player))throw new Fault("permission_denied","Unknown or mismatched chat request");
|
||||
Player recipient=Bukkit.getPlayer(UUID.fromString(player));String text=str(p,"text","");
|
||||
if(recipient!=null&&!text.isBlank())recipient.sendMessage("[Builder] "+text.substring(0,Math.min(1600,text.length())));
|
||||
if(p.has("done")&&p.get("done").getAsBoolean()){messages.remove(id);leased.remove(id);}
|
||||
return Map.of("delivered",recipient!=null);
|
||||
}
|
||||
private boolean command(CommandSender sender,Command cmd,String label,String[] args) {
|
||||
try {
|
||||
if(!(sender instanceof Player player)){sender.sendMessage("Use RPC for local automation, or /ai in game.");return true;}
|
||||
if(!player.hasPermission("minecraftbuilder.use"))throw new Fault("permission_denied","Permission required");
|
||||
String sub=args.length==0?"status":args[0];
|
||||
if(sub.equals("setup")){
|
||||
if(!owner.isBlank()&&!owner.equals(player.getUniqueId().toString()))throw new Fault("permission_denied","Project already bound to another owner");
|
||||
owner=player.getUniqueId().toString();getConfig().set("owner-uuid",owner);saveConfig();player.sendMessage("[Builder] Project bound. Set area with /ai area minX minY minZ maxX maxY maxZ; configure Bridge using the private plugin config.");return true;
|
||||
}
|
||||
if(!player.getUniqueId().toString().equals(owner))throw new Fault("permission_denied","Run /ai setup as the project owner first");
|
||||
switch(sub){
|
||||
case "area" -> {
|
||||
available();
|
||||
if(args.length==2&&args[1].equals("here")){
|
||||
Location l=player.getLocation();args=new String[]{"area",Integer.toString(l.getBlockX()-24),Integer.toString(Math.max(world.getMinHeight(),l.getBlockY()-1)),Integer.toString(l.getBlockZ()-24),Integer.toString(l.getBlockX()+24),Integer.toString(Math.min(world.getMaxHeight()-1,l.getBlockY()+48)),Integer.toString(l.getBlockZ()+24)};
|
||||
}
|
||||
if(args.length!=7)throw new Fault("invalid_request","/ai area here OR /ai area minX minY minZ maxX maxY maxZ");
|
||||
if(engine.operations().stream().anyMatch(v->!v.status().terminal()))throw new Fault("busy","Stop active operations before changing the area");
|
||||
Region next=new Region(world.getUID().toString(),new BlockPos(Integer.parseInt(args[1]),Integer.parseInt(args[2]),Integer.parseInt(args[3])),new BlockPos(Integer.parseInt(args[4]),Integer.parseInt(args[5]),Integer.parseInt(args[6])));
|
||||
if(next.volume()>2_000_000||!player.getWorld().equals(world)||next.min().y()<world.getMinHeight()||next.max().y()>=world.getMaxHeight())throw new Fault("out_of_bounds","Area must be within the configured world and at most 2 million blocks");
|
||||
region=next;for(String side:List.of("min","max")){BlockPos at=side.equals("min")?region.min():region.max();getConfig().set("region."+side+".x",at.x());getConfig().set("region."+side+".y",at.y());getConfig().set("region."+side+".z",at.z());}saveConfig();player.sendMessage("[Builder] Area set.");
|
||||
}
|
||||
case "status" -> player.sendMessage("[Builder] project="+projectId+"; operations="+engine.operations().size()+"; bridge="+(System.currentTimeMillis()-lastPoll<15_000?"connected":"offline")+"; halted="+halted);
|
||||
case "stop" -> {
|
||||
writesPaused=true;for(OperationView v:engine.operations())if(!v.status().terminal())engine.cancel(v.id());
|
||||
messages.entrySet().removeIf(e->!leased.containsKey(e.getKey()));
|
||||
enqueue(player,"","cancel");player.sendMessage("[Builder] Stopping future writes and agent turn. New writes remain paused until your next request.");
|
||||
}
|
||||
case "resume" -> {writesPaused=false;player.sendMessage("[Builder] New plans enabled; cancelled operations are not replayed.");}
|
||||
case "protect" -> {
|
||||
if(args.length!=2)throw new Fault("invalid_request","/ai protect part-id");Part old=parts.get(args[1]);if(old==null)throw new Fault("not_found","Part not found");
|
||||
parts.put(old.id(),new Part(old.id(),old.name(),old.operationId(),old.positions(),true));disk.submit(()->{try{saveMetadata();}catch(IOException e){halted=true;}});player.sendMessage("[Builder] Part protected.");
|
||||
}
|
||||
case "camera" -> {
|
||||
if(args.length!=3||!args[1].equals("save"))throw new Fault("invalid_request","/ai camera save name");
|
||||
Location l=player.getLocation();if(!player.getWorld().equals(world)||!region.contains(new BlockPos(l.getBlockX(),l.getBlockY(),l.getBlockZ())))throw new Fault("out_of_bounds","Save a camera inside the configured world and project area");
|
||||
if(args[2].length()>64||cameras.size()>=64&&!cameras.containsKey(args[2]))throw new Fault("budget_exceeded","At most 64 cameras with names up to 64 characters");
|
||||
JsonObject pose=new JsonObject();pose.addProperty("camera_id",args[2]);pose.addProperty("x",l.getX());pose.addProperty("y",l.getY());pose.addProperty("z",l.getZ());pose.addProperty("yaw",l.getYaw());pose.addProperty("pitch",l.getPitch());
|
||||
cameras.put(args[2],pose);disk.submit(()->{try{saveMetadata();}catch(IOException e){halted=true;}});player.sendMessage("[Builder] Camera saved.");
|
||||
}
|
||||
default -> {if(System.currentTimeMillis()-lastPoll>15_000)throw new Fault("bridge_unavailable","Start the Bridge daemon first");enqueue(player,String.join(" ",args),"prompt");writesPaused=false;player.sendMessage("[Builder] Request queued.");}
|
||||
}
|
||||
}catch(Exception e){sender.sendMessage("[Builder] "+e.getMessage());}return true;
|
||||
}
|
||||
private void enqueue(Player player,String text,String type) {
|
||||
if(messages.size()>=32&&!type.equals("cancel"))throw new Fault("busy","Chat queue is full");
|
||||
if(text.length()>4000)throw new Fault("budget_exceeded","Message too long");
|
||||
JsonObject value=new JsonObject();String id=UUID.randomUUID().toString();value.addProperty("id",id);value.addProperty("playerId",player.getUniqueId().toString());value.addProperty("projectId",projectId);value.addProperty("text",text);value.addProperty("type",type);
|
||||
Location l=player.getLocation();value.add("playerPosition",json.toJsonTree(Map.of("x",l.getBlockX(),"y",l.getBlockY(),"z",l.getBlockZ(),"yaw",l.getYaw(),"pitch",l.getPitch())));value.addProperty("worldId",player.getWorld().getUID().toString());
|
||||
org.bukkit.block.Block target=player.getTargetBlockExact(64);
|
||||
if(target!=null)value.add("lookTarget",json.toJsonTree(Map.of("x",target.getX(),"y",target.getY(),"z",target.getZ())));
|
||||
messages.put(id,value);
|
||||
}
|
||||
private Object capture(JsonObject p)throws Exception {
|
||||
if(p.has("capture_id")){
|
||||
String id=required(p,"capture_id");if(!main(()->captureIds.contains(id)))throw new Fault("permission_denied","Unknown capture");
|
||||
JsonObject result=cameraRequest("GET","/v1/captures/"+id,null);
|
||||
if(!str(result,"status","pending").equals("pending"))main(()->{if(id.equals(activeCaptureId)){cameraBusyUntil=0;activeCaptureId=null;}return null;});
|
||||
return result;
|
||||
}
|
||||
JsonObject pose=main(()->{
|
||||
if(p.has("after_operation_id")&&engine.status(required(p,"after_operation_id")).status()!=OperationStatus.APPLIED)throw new Fault("capture_not_ready","Operation not yet applied");
|
||||
JsonObject result=p.has("pose")?p.getAsJsonObject("pose").deepCopy():cameras.get(required(p,"camera_id"));
|
||||
if(result==null)throw new Fault("not_found","Camera not found");result=result.deepCopy();
|
||||
result.remove("camera_id");
|
||||
for(String key:result.keySet())if(!Set.of("x","y","z","yaw","pitch","fov","width","height").contains(key))throw new Fault("invalid_request","Unknown camera field: "+key);
|
||||
double yaw=number(result,"yaw"),pitch=number(result,"pitch");
|
||||
if(yaw < -360 || yaw > 360 || pitch < -90 || pitch > 90)throw new Fault("invalid_request","Camera yaw/pitch out of range");
|
||||
if(result.has("fov")&&(integer(result,"fov")<30||integer(result,"fov")>110))throw new Fault("invalid_request","FOV must be an integer 30..110");
|
||||
if(result.has("width")&&(integer(result,"width")<320||integer(result,"width")>1920)||result.has("height")&&(integer(result,"height")<180||integer(result,"height")>1080))throw new Fault("invalid_request","Capture dimensions out of range");
|
||||
double x=number(result,"x"),y=number(result,"y"),z=number(result,"z");
|
||||
if(!region.contains(new BlockPos((int)Math.floor(x),(int)Math.floor(y),(int)Math.floor(z))))throw new Fault("out_of_bounds","Camera exceeds project area");
|
||||
result.addProperty("dimension",world.getKey().asString());if(p.has("after_operation_id"))result.addProperty("afterOperationId",required(p,"after_operation_id"));return result;
|
||||
});
|
||||
CompletableFuture<Boolean> teleport=main(()->{
|
||||
String uuid=getConfig().getString("camera-player-uuid","");if(uuid.isBlank())throw new Fault("camera_unavailable","Configure a spectator camera account");
|
||||
Player camera=Bukkit.getPlayer(UUID.fromString(uuid));if(camera==null||camera.getGameMode()!=GameMode.SPECTATOR)throw new Fault("camera_unavailable","Configured spectator camera must be online");
|
||||
if(cameraBusyUntil>System.currentTimeMillis())throw new Fault("busy","A capture is already in progress; poll it before moving the camera");
|
||||
cameraBusyUntil=System.currentTimeMillis()+45_000;
|
||||
return camera.teleportAsync(new Location(world,number(pose,"x"),number(pose,"y"),number(pose,"z"),(float)number(pose,"yaw"),(float)number(pose,"pitch")));
|
||||
});
|
||||
try{
|
||||
if(!teleport.get(15,TimeUnit.SECONDS))throw new Fault("camera_unavailable","Camera teleport was rejected");
|
||||
JsonObject response=cameraRequest("POST","/v1/capture",pose);
|
||||
if(response.has("captureId"))main(()->{if(captureIds.size()>256)captureIds.clear();activeCaptureId=response.get("captureId").getAsString();captureIds.add(activeCaptureId);return null;});
|
||||
return response;
|
||||
}catch(Exception e){main(()->{cameraBusyUntil=0;activeCaptureId=null;return null;});throw e;}
|
||||
}
|
||||
private JsonObject cameraRequest(String method,String path,JsonObject body)throws Exception {
|
||||
HttpRequest.Builder request=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+getConfig().getInt("camera-port",8766)+path)).timeout(Duration.ofSeconds(25)).header("Authorization","Bearer "+getConfig().getString("camera-token")).header("Content-Type","application/json");
|
||||
if(method.equals("POST"))request.POST(HttpRequest.BodyPublishers.ofString(json.toJson(body)));else request.GET();
|
||||
HttpResponse<InputStream> response=cameraHttp.send(request.build(),HttpResponse.BodyHandlers.ofInputStream());
|
||||
try(InputStream in=response.body()){
|
||||
byte[] bytes=in.readNBytes(12*1024*1024+1);if(bytes.length>12*1024*1024)throw new Fault("budget_exceeded","Camera response too large");
|
||||
if(response.statusCode()>=400)throw new Fault("camera_unavailable","Camera service returned HTTP "+response.statusCode());
|
||||
return JsonParser.parseString(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
private synchronized void saveMetadata()throws IOException {
|
||||
JsonObject data;
|
||||
try{data=main(()->{JsonObject value=new JsonObject();value.add("parts",json.toJsonTree(parts.values()));value.add("cameras",json.toJsonTree(cameras));return value;});}
|
||||
catch(Exception e){throw new IOException("Cannot snapshot metadata",e);}
|
||||
Path path=getDataFolder().toPath().resolve("metadata.json"),temp=path.resolveSibling("metadata.tmp");
|
||||
Files.writeString(temp,json.toJson(data));Files.move(temp,path,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
private void loadMetadata()throws IOException {
|
||||
Path path=getDataFolder().toPath().resolve("metadata.json");if(!Files.exists(path))return;
|
||||
JsonObject data=JsonParser.parseString(Files.readString(path)).getAsJsonObject();
|
||||
for(JsonElement e:data.getAsJsonArray("parts")){Part part=json.fromJson(e,Part.class);parts.put(part.id(),part);}
|
||||
for(var e:data.getAsJsonObject("cameras").entrySet())cameras.put(e.getKey(),e.getValue().getAsJsonObject());
|
||||
}
|
||||
private static String required(JsonObject p,String key){String value=str(p,key,"");if(value.isBlank()||value.length()>256)throw new Fault("invalid_request","Required bounded string: "+key);return value;}
|
||||
private static String str(JsonObject p,String key,String fallback){return p.has(key)&&!p.get(key).isJsonNull()?p.get(key).getAsString():fallback;}
|
||||
private static BlockPos pos(JsonObject p){return new BlockPos(integer(p,"x"),integer(p,"y"),integer(p,"z"));}
|
||||
private Region bounds(Set<BlockPos> positions){
|
||||
return new Region(world.getUID().toString(),new BlockPos(positions.stream().mapToInt(BlockPos::x).min().orElseThrow(),positions.stream().mapToInt(BlockPos::y).min().orElseThrow(),positions.stream().mapToInt(BlockPos::z).min().orElseThrow()),new BlockPos(positions.stream().mapToInt(BlockPos::x).max().orElseThrow(),positions.stream().mapToInt(BlockPos::y).max().orElseThrow(),positions.stream().mapToInt(BlockPos::z).max().orElseThrow()));
|
||||
}
|
||||
private static int integer(JsonObject p,String key){try{return p.get(key).getAsBigDecimal().intValueExact();}catch(Exception e){throw new Fault("invalid_request","Integer coordinate required: "+key);}}
|
||||
private static double number(JsonObject p,String key){double v=p.get(key).getAsDouble();if(!Double.isFinite(v))throw new Fault("invalid_request","Finite number required");return v;}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import io.github.minecraftbuilder.core.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import java.util.*;
|
||||
|
||||
/** Small tested policy; unsupported existing contents are protected too. */
|
||||
final class BuildingWorld implements WorldAccess, BlockPolicy {
|
||||
private final World world;
|
||||
private final Map<String, BlockData> parsed = new HashMap<>();
|
||||
private static final Set<String> MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone",
|
||||
"stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone",
|
||||
"granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite",
|
||||
"deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles",
|
||||
"bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone",
|
||||
"red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta",
|
||||
"white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian",
|
||||
"dirt", "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks",
|
||||
"oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log",
|
||||
"stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs",
|
||||
"stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab");
|
||||
BuildingWorld(World world) { this.world = world; }
|
||||
static List<String> supportedMaterials() { return MATERIALS.stream().sorted().map(s->"minecraft:"+s).toList(); }
|
||||
BlockData data(String state) { return parsed.computeIfAbsent(state, Bukkit::createBlockData).clone(); }
|
||||
String canonical(String state) { if (!supports(state)) throw new RpcServer.Fault("unsupported_block", "Unsupported block: " + state); return data(state).getAsString(); }
|
||||
public boolean supports(String state) {
|
||||
try {
|
||||
BlockData data = data(state);
|
||||
return MATERIALS.contains(data.getMaterial().getKey().getKey())
|
||||
&& !(data instanceof org.bukkit.block.data.Waterlogged w && w.isWaterlogged());
|
||||
} catch (IllegalArgumentException e) { return false; }
|
||||
}
|
||||
private void ready(BlockPos p) {
|
||||
if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("World access outside server thread");
|
||||
if (p.y() < world.getMinHeight() || p.y() >= world.getMaxHeight()) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world height");
|
||||
if (!world.isChunkLoaded(p.x() >> 4, p.z() >> 4)) throw new RpcServer.Fault("chunk_not_loaded", "Visit/load the target chunks before editing");
|
||||
if (!world.getWorldBorder().isInside(new Location(world,p.x()+0.5,p.y(),p.z()+0.5))) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world border");
|
||||
}
|
||||
public String getBlock(BlockPos p) { ready(p); return world.getBlockAt(p.x(),p.y(),p.z()).getBlockData().getAsString(); }
|
||||
public void setBlock(BlockPos p, String state) {
|
||||
ready(p);
|
||||
// A neighbour may have changed since prepare. Never remove supports next to
|
||||
// dynamic/unsupported blocks merely because the target itself still matches.
|
||||
for (BlockPos d : List.of(new BlockPos(1,0,0),new BlockPos(-1,0,0),new BlockPos(0,1,0),new BlockPos(0,-1,0),new BlockPos(0,0,1),new BlockPos(0,0,-1))) {
|
||||
BlockPos n=p.add(d);
|
||||
if(n.y()>=world.getMinHeight()&&n.y()<world.getMaxHeight()&&!supports(getBlock(n)))
|
||||
throw new RpcServer.Fault("unsupported_block","Adjacent environment changed at "+n);
|
||||
}
|
||||
world.getBlockAt(p.x(),p.y(),p.z()).setBlockData(data(state), false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
/** Validate before a secret reaches HTTP header builders, whose exception messages can echo values. */
|
||||
final class ComponentTokens {
|
||||
private static final String ERROR = "Invalid component tokens: configure three distinct values of 32..512 safe ASCII characters";
|
||||
|
||||
private ComponentTokens() { }
|
||||
|
||||
static void validate(String admin, String agent, String camera) {
|
||||
if (!safe(admin) || !safe(agent) || !safe(camera)
|
||||
|| admin.equals(agent) || admin.equals(camera) || agent.equals(camera))
|
||||
throw new IllegalArgumentException(ERROR);
|
||||
}
|
||||
|
||||
private static boolean safe(String value) {
|
||||
return value != null && value.matches("[A-Za-z0-9._~-]{32,512}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.sun.net.httpserver.*;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Loopback transport; separate agent/admin capabilities, bounded ingress and queues. */
|
||||
public final class RpcServer implements AutoCloseable {
|
||||
public interface Handler { Object call(String method, JsonObject params, boolean admin) throws Exception; }
|
||||
public static final class Fault extends RuntimeException {
|
||||
public final String code;
|
||||
public Fault(String code, String message) { super(message); this.code = code; }
|
||||
}
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor;
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final int MAX_BODY = 1_048_576;
|
||||
|
||||
public RpcServer(int port, String adminToken, String agentToken, Handler handler) throws IOException {
|
||||
if (adminToken.length() < 32 || agentToken.length() < 32 || adminToken.equals(agentToken))
|
||||
throw new IllegalArgumentException("Distinct tokens of at least 32 characters required");
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), 16);
|
||||
executor = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(32),
|
||||
r -> { Thread t = new Thread(r, "mcb-rpc"); t.setDaemon(true); return t; }, new ThreadPoolExecutor.AbortPolicy());
|
||||
server.setExecutor(executor);
|
||||
server.createContext("/health", exchange -> {
|
||||
if (!exchange.getRequestMethod().equals("GET") || !exchange.getRequestURI().getPath().equals("/health")) {
|
||||
respond(exchange, 404, Map.of("error", "not_found")); return;
|
||||
}
|
||||
respond(exchange, 200, Map.of("service", "minecraft-builder-mcp", "version", "0.1.0", "status", "ready"));
|
||||
});
|
||||
server.createContext("/v1/rpc", exchange -> {
|
||||
try {
|
||||
if (!exchange.getRequestURI().getPath().equals("/v1/rpc") || !exchange.getRequestMethod().equals("POST")) {
|
||||
respond(exchange, 405, Map.of("ok",false,"error", Map.of("code","method_not_allowed","message","POST /v1/rpc required"))); return;
|
||||
}
|
||||
if (exchange.getRequestHeaders().containsKey("Origin")) throw new Fault("permission_denied", "Browser origins are not permitted");
|
||||
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
boolean admin = equal(authorization, "Bearer " + adminToken);
|
||||
if (!admin && !equal(authorization, "Bearer " + agentToken)) throw new Fault("permission_denied", "Invalid capability token");
|
||||
byte[] bytes = exchange.getRequestBody().readNBytes(MAX_BODY + 1);
|
||||
if (bytes.length > MAX_BODY) throw new Fault("budget_exceeded", "Request exceeds 1 MiB");
|
||||
JsonObject request = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
String method = request.get("method").getAsString();
|
||||
if (method.startsWith("chat_") && !admin) throw new Fault("permission_denied", "Chat routing requires the administrator capability");
|
||||
JsonObject params = request.has("params") ? request.getAsJsonObject("params") : new JsonObject();
|
||||
Object result = handler.call(method, params, admin);
|
||||
respond(exchange, 200, Map.of("ok", true, "result", result));
|
||||
} catch (Exception failure) {
|
||||
Throwable e = failure;
|
||||
while ((e instanceof ExecutionException || e instanceof CompletionException) && e.getCause() != null) e = e.getCause();
|
||||
String code = e instanceof Fault f ? f.code : e instanceof TimeoutException ? "timeout" : "invalid_request";
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
respond(exchange, code.equals("permission_denied") ? 403 : 400,
|
||||
Map.of("ok", false, "error", Map.of("code", code, "message", message.substring(0, Math.min(600, message.length())))));
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
static boolean equal(String actual, String expected) {
|
||||
return actual != null && MessageDigest.isEqual(actual.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
private static void respond(HttpExchange e, int status, Object value) throws IOException {
|
||||
byte[] bytes = JSON.toJson(value).getBytes(StandardCharsets.UTF_8);
|
||||
e.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
e.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
e.sendResponseHeaders(status, bytes.length);
|
||||
try (OutputStream out = e.getResponseBody()) { out.write(bytes); }
|
||||
finally { e.close(); }
|
||||
}
|
||||
public int port() { return server.getAddress().getPort(); }
|
||||
public void close() { server.stop(0); executor.shutdownNow(); }
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import io.github.minecraftbuilder.core.BlockPos;
|
||||
import java.io.*;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* Strict, dependency-free subset of Sponge Schematic v2 (gzip, big-endian NBT, palette varints).
|
||||
* Spec: https://github.com/SpongePowered/Schematic-Specification/blob/master/versions/schematic-2.md
|
||||
* IO methods run off the world thread. Snapshot input must already exclude all unsupported world data.
|
||||
* This codec deliberately rejects entities, block entities, biomes, unknown top-level tags and versions.
|
||||
* No DataFixer conversion is attempted: returned states must be canonicalized/validated by the server.
|
||||
*/
|
||||
public final class SchematicAssets {
|
||||
public static final int MAX_BLOCKS = 4096, MAX_ASSETS = 64;
|
||||
private static final int MAX_COMPRESSED = 1_048_576, MAX_NBT = 4_194_304;
|
||||
private static final Set<String> ROOT_FIELDS = Set.of("Version", "DataVersion", "Width", "Height", "Length",
|
||||
"Offset", "PaletteMax", "Palette", "BlockData", "BlockEntities", "Entities", "Metadata");
|
||||
// Deliberately mirrors the prototype's small server policy. The server validates states again on import.
|
||||
private static final Set<String> MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone",
|
||||
"stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone",
|
||||
"granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite",
|
||||
"deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles",
|
||||
"bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone",
|
||||
"red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta",
|
||||
"white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian", "dirt",
|
||||
"grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks",
|
||||
"oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log",
|
||||
"stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs",
|
||||
"stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab");
|
||||
private final Path root;
|
||||
|
||||
public record Asset(String assetId, String name, int width, int height, int length, int blockCount,
|
||||
int dataVersion, BlockPos offset, String sha256, long bytes) { }
|
||||
private record Decoded(String name, int width, int height, int length, int dataVersion,
|
||||
BlockPos offset, List<String> blocks) { }
|
||||
private record Tag(int type, Object value) { }
|
||||
private record TagList(int elementType, List<Tag> values) { }
|
||||
|
||||
public SchematicAssets(Path root) throws IOException {
|
||||
this.root = root.toAbsolutePath().normalize();
|
||||
rejectSymlinkParents();
|
||||
Files.createDirectories(this.root);
|
||||
if (!Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset root must be a directory");
|
||||
}
|
||||
|
||||
/** Export all cells of a dense box. origin is the clipboard anchor, not necessarily the minimum. */
|
||||
public synchronized Asset exportSnapshot(String name, Map<BlockPos, String> blocks, BlockPos origin, int dataVersion) throws IOException {
|
||||
requireName(name);
|
||||
Objects.requireNonNull(blocks); Objects.requireNonNull(origin);
|
||||
if (blocks.isEmpty() || blocks.size() > MAX_BLOCKS) throw new IOException("Snapshot must contain 1..4096 blocks");
|
||||
if (dataVersion < 0) throw new IOException("Invalid Minecraft DataVersion");
|
||||
rejectSymlinkParents();
|
||||
if (assetPaths().size() >= MAX_ASSETS) throw new IOException("Asset library is full (64 files maximum)");
|
||||
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
for (BlockPos at : blocks.keySet()) {
|
||||
if (at == null) throw new IOException("Null snapshot position");
|
||||
minX = Math.min(minX, at.x()); minY = Math.min(minY, at.y()); minZ = Math.min(minZ, at.z());
|
||||
maxX = Math.max(maxX, at.x()); maxY = Math.max(maxY, at.y()); maxZ = Math.max(maxZ, at.z());
|
||||
}
|
||||
int width = extent(minX, maxX), height = extent(minY, maxY), length = extent(minZ, maxZ);
|
||||
int volume = volume(width, height, length);
|
||||
if (blocks.size() != volume) throw new IOException("Snapshot must include every bounding-box cell, including air");
|
||||
BlockPos offset;
|
||||
try { offset = new BlockPos(Math.subtractExact(minX, origin.x()), Math.subtractExact(minY, origin.y()), Math.subtractExact(minZ, origin.z())); }
|
||||
catch (ArithmeticException e) { throw new IOException("Clipboard offset overflows integer coordinates", e); }
|
||||
LinkedHashMap<String, Integer> palette = new LinkedHashMap<>();
|
||||
ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||
for (int y = 0; y < height; y++) for (int z = 0; z < length; z++) for (int x = 0; x < width; x++) {
|
||||
String state = rotateState(blocks.get(new BlockPos(minX + x, minY + y, minZ + z)), 0);
|
||||
int index = palette.computeIfAbsent(state, ignored -> palette.size());
|
||||
writeVarInt(data, index);
|
||||
}
|
||||
byte[] encoded = encode(name, dataVersion, width, height, length, offset, palette, data.toByteArray());
|
||||
if (encoded.length > MAX_COMPRESSED) throw new IOException("Compressed asset exceeds 1 MiB");
|
||||
String id = UUID.randomUUID().toString();
|
||||
Path destination = path(id), temporary = Files.createTempFile(root, ".asset-", ".tmp");
|
||||
try {
|
||||
Files.write(temporary, encoded, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); }
|
||||
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
|
||||
try (FileChannel directory = FileChannel.open(root, StandardOpenOption.READ)) { directory.force(true); }
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return metadata(id, decode(encoded), encoded);
|
||||
}
|
||||
|
||||
/** Imported files may be provisioned locally as [A-Za-z0-9][A-Za-z0-9_-]{0,63}.schem. */
|
||||
public synchronized List<Asset> list() throws IOException {
|
||||
List<Asset> result = new ArrayList<>();
|
||||
for (Path file : assetPaths()) {
|
||||
String id = file.getFileName().toString().replaceFirst("\\.schem$", "");
|
||||
byte[] bytes = load(id);
|
||||
result.add(metadata(id, decode(bytes), bytes));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** Bounded strict inspection of one asset without exposing a filesystem path. */
|
||||
public synchronized Asset metadata(String assetId) throws IOException {
|
||||
byte[] bytes = load(assetId);
|
||||
return metadata(assetId, decode(bytes), bytes);
|
||||
}
|
||||
|
||||
/** rotation90 is degrees: 0/90/180/270 clockwise viewed from above. Rotate about target anchor. */
|
||||
public synchronized Map<BlockPos, String> read(String assetId, BlockPos target, int rotation90) throws IOException {
|
||||
return read(assetId, target, rotation90, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
/** Checks the same decoded input used for placement, even if a file changed after metadata(). */
|
||||
public synchronized Map<BlockPos, String> read(String assetId, BlockPos target, int rotation90, int maxDataVersion) throws IOException {
|
||||
if (!Set.of(0, 90, 180, 270).contains(rotation90)) throw new IOException("Rotation must be 0, 90, 180 or 270 degrees");
|
||||
if (maxDataVersion < 0) throw new IOException("Invalid target Minecraft DataVersion");
|
||||
Objects.requireNonNull(target);
|
||||
Decoded value = decode(load(assetId));
|
||||
if (value.dataVersion > maxDataVersion) throw new IOException("Schematic DataVersion is newer than the target server");
|
||||
Map<BlockPos, String> result = new LinkedHashMap<>();
|
||||
try {
|
||||
int index = 0;
|
||||
for (int y = 0; y < value.height; y++) for (int z = 0; z < value.length; z++) for (int x = 0; x < value.width; x++) {
|
||||
int dx = Math.addExact(x, value.offset.x()), dy = Math.addExact(y, value.offset.y()), dz = Math.addExact(z, value.offset.z());
|
||||
for (int turn = 0; turn < rotation90 / 90; turn++) { int oldX = dx; dx = Math.negateExact(dz); dz = oldX; }
|
||||
BlockPos at = target.add(new BlockPos(dx, dy, dz));
|
||||
result.put(at, rotateState(value.blocks.get(index++), rotation90));
|
||||
}
|
||||
} catch (ArithmeticException e) { throw new IOException("Placement overflows integer coordinates", e); }
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
private static Asset metadata(String id, Decoded data, byte[] bytes) {
|
||||
try {
|
||||
return new Asset(id, data.name, data.width, data.height, data.length, data.blocks.size(), data.dataVersion,
|
||||
data.offset, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), bytes.length);
|
||||
} catch (NoSuchAlgorithmException e) { throw new AssertionError(e); }
|
||||
}
|
||||
private Path path(String id) throws IOException {
|
||||
if (id == null || !id.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) throw new IOException("Invalid asset ID; paths are not accepted");
|
||||
return root.resolve(id + ".schem");
|
||||
}
|
||||
private void rejectSymlinkParents() throws IOException {
|
||||
for (Path at = root; at != null; at = at.getParent())
|
||||
if (Files.isSymbolicLink(at)) throw new IOException("Symlinks are not allowed in asset directory path");
|
||||
}
|
||||
private List<Path> assetPaths() throws IOException {
|
||||
rejectSymlinkParents();
|
||||
List<Path> files = new ArrayList<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(root, "*.schem")) {
|
||||
for (Path file : stream) {
|
||||
String name = file.getFileName().toString();
|
||||
path(name.substring(0, name.length() - 6));
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset must be a regular non-symlink file");
|
||||
if (files.size() >= MAX_ASSETS) throw new IOException("Asset library exceeds 64 files");
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
files.sort(Comparator.comparing(file -> file.getFileName().toString()));
|
||||
return files;
|
||||
}
|
||||
private byte[] load(String id) throws IOException {
|
||||
rejectSymlinkParents();
|
||||
Path file = path(id);
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset not found or is not a regular file");
|
||||
try (InputStream in = Files.newInputStream(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] bytes = in.readNBytes(MAX_COMPRESSED + 1);
|
||||
if (bytes.length > MAX_COMPRESSED) throw new IOException("Compressed asset exceeds 1 MiB");
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
private static int extent(int min, int max) throws IOException {
|
||||
long value = (long) max - min + 1;
|
||||
if (value < 1 || value > MAX_BLOCKS) throw new IOException("Schematic dimension exceeds limits");
|
||||
return (int) value;
|
||||
}
|
||||
private static int volume(int width, int height, int length) throws IOException {
|
||||
long volume = (long) width * height * length;
|
||||
if (width < 1 || height < 1 || length < 1 || volume > MAX_BLOCKS) throw new IOException("Schematic volume must be 1..4096");
|
||||
return (int) volume;
|
||||
}
|
||||
private static void requireName(String name) throws IOException {
|
||||
if (name == null || name.isBlank() || name.length() > 64 || name.chars().anyMatch(Character::isISOControl))
|
||||
throw new IOException("Asset name must contain 1..64 printable characters");
|
||||
}
|
||||
|
||||
/** Only recognised schemas rotate: no guessing about unknown direction-like properties. */
|
||||
static String rotateState(String state, int degrees) throws IOException {
|
||||
if (state == null || state.length() > 512 || !state.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?"))
|
||||
throw new IOException("Invalid vanilla block state");
|
||||
int bracket = state.indexOf('[');
|
||||
String id = state.substring(10, bracket < 0 ? state.length() : bracket);
|
||||
if (!MATERIALS.contains(id)) throw new IOException("Unsupported schematic block: minecraft:" + id);
|
||||
TreeMap<String, String> properties = new TreeMap<>();
|
||||
if (bracket >= 0) for (String property : state.substring(bracket + 1, state.length() - 1).split(",")) {
|
||||
String[] pair = property.split("=", -1);
|
||||
if (pair.length != 2 || properties.put(pair[0], pair[1]) != null) throw new IOException("Invalid or duplicate block property");
|
||||
}
|
||||
Set<String> allowed = id.endsWith("_stairs") ? Set.of("facing", "half", "shape", "waterlogged")
|
||||
: id.endsWith("_slab") ? Set.of("type", "waterlogged")
|
||||
: id.endsWith("_log") || id.equals("quartz_pillar") || id.equals("deepslate") ? Set.of("axis")
|
||||
: id.equals("grass_block") ? Set.of("snowy") : Set.of();
|
||||
if (!allowed.containsAll(properties.keySet())) throw new IOException("Unsupported block properties for minecraft:" + id);
|
||||
for (var property : properties.entrySet()) {
|
||||
Set<String> values = switch (property.getKey()) {
|
||||
case "facing" -> Set.of("north", "east", "south", "west");
|
||||
case "axis" -> Set.of("x", "y", "z");
|
||||
case "half" -> Set.of("top", "bottom");
|
||||
case "shape" -> Set.of("straight", "inner_left", "inner_right", "outer_left", "outer_right");
|
||||
case "type" -> Set.of("top", "bottom", "double");
|
||||
case "waterlogged" -> Set.of("false");
|
||||
case "snowy" -> Set.of("true", "false");
|
||||
default -> Set.of();
|
||||
};
|
||||
if (!values.contains(property.getValue())) throw new IOException("Unsupported block property value");
|
||||
}
|
||||
if (degrees != 0 && id.endsWith("_stairs") && !properties.containsKey("facing"))
|
||||
throw new IOException("Rotation requires explicit stairs facing");
|
||||
if (degrees != 0 && allowed.contains("axis") && !properties.containsKey("axis"))
|
||||
throw new IOException("Rotation requires explicit block axis");
|
||||
if (properties.containsKey("facing")) {
|
||||
List<String> faces = List.of("north", "east", "south", "west");
|
||||
properties.put("facing", faces.get((faces.indexOf(properties.get("facing")) + degrees / 90) % 4));
|
||||
}
|
||||
if (degrees % 180 != 0 && properties.containsKey("axis") && !properties.get("axis").equals("y"))
|
||||
properties.put("axis", properties.get("axis").equals("x") ? "z" : "x");
|
||||
return "minecraft:" + id + (properties.isEmpty() ? "" : "[" + String.join(",", properties.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList()) + "]");
|
||||
}
|
||||
|
||||
private static byte[] encode(String name, int version, int width, int height, int length, BlockPos offset,
|
||||
LinkedHashMap<String, Integer> palette, byte[] blockData) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(new GZIPOutputStream(bytes))) {
|
||||
header(out, 10, "Schematic");
|
||||
header(out, 3, "Version"); out.writeInt(2);
|
||||
header(out, 3, "DataVersion"); out.writeInt(version);
|
||||
header(out, 2, "Width"); out.writeShort(width);
|
||||
header(out, 2, "Height"); out.writeShort(height);
|
||||
header(out, 2, "Length"); out.writeShort(length);
|
||||
header(out, 11, "Offset"); out.writeInt(3); out.writeInt(offset.x()); out.writeInt(offset.y()); out.writeInt(offset.z());
|
||||
header(out, 3, "PaletteMax"); out.writeInt(palette.size());
|
||||
header(out, 10, "Palette");
|
||||
for (var entry : palette.entrySet()) { header(out, 3, entry.getKey()); out.writeInt(entry.getValue()); }
|
||||
out.writeByte(0);
|
||||
header(out, 7, "BlockData"); out.writeInt(blockData.length); out.write(blockData);
|
||||
header(out, 9, "BlockEntities"); out.writeByte(10); out.writeInt(0);
|
||||
header(out, 9, "Entities"); out.writeByte(10); out.writeInt(0);
|
||||
header(out, 10, "Metadata"); header(out, 8, "Name"); out.writeUTF(name);
|
||||
header(out, 8, "Author"); out.writeUTF("minecraft-builder-mcp");
|
||||
header(out, 4, "Date"); out.writeLong(System.currentTimeMillis());
|
||||
out.writeByte(0); out.writeByte(0);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
private static void header(DataOutputStream out, int type, String name) throws IOException { out.writeByte(type); out.writeUTF(name); }
|
||||
private static void writeVarInt(OutputStream out, int value) throws IOException {
|
||||
do { int next = value & 127; value >>>= 7; out.write(next | (value != 0 ? 128 : 0)); } while (value != 0);
|
||||
}
|
||||
private static Decoded decode(byte[] compressed) throws IOException {
|
||||
byte[] raw;
|
||||
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
|
||||
raw = gzip.readNBytes(MAX_NBT + 1);
|
||||
if (raw.length > MAX_NBT) throw new IOException("Inflated schematic exceeds 4 MiB");
|
||||
}
|
||||
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw))) {
|
||||
if (in.readUnsignedByte() != 10) throw new IOException("Schematic root must be TAG_Compound");
|
||||
String rootName = boundedUtf(in);
|
||||
if (!rootName.isEmpty() && !rootName.equals("Schematic")) throw new IOException("Unexpected schematic root name");
|
||||
Map<String, Tag> tags = compound(in, 0, new int[]{0});
|
||||
if (in.read() != -1) throw new IOException("Trailing NBT data is not accepted");
|
||||
if (!ROOT_FIELDS.containsAll(tags.keySet())) throw new IOException("Unsupported schematic fields (biomes/custom data are not imported)");
|
||||
if (integer(tags, "Version") != 2) throw new IOException("Only Sponge schematic version 2 is supported");
|
||||
int dataVersion = integer(tags, "DataVersion");
|
||||
if (dataVersion < 0) throw new IOException("Invalid Minecraft DataVersion");
|
||||
int width = Short.toUnsignedInt((Short) required(tags, "Width", 2).value);
|
||||
int height = Short.toUnsignedInt((Short) required(tags, "Height", 2).value);
|
||||
int length = Short.toUnsignedInt((Short) required(tags, "Length", 2).value);
|
||||
int volume = volume(width, height, length);
|
||||
BlockPos offset = new BlockPos(0, 0, 0);
|
||||
if (tags.containsKey("Offset")) {
|
||||
int[] values = (int[]) required(tags, "Offset", 11).value;
|
||||
if (values.length != 3) throw new IOException("Offset must have exactly 3 integers");
|
||||
offset = new BlockPos(values[0], values[1], values[2]);
|
||||
}
|
||||
for (String name : List.of("Entities", "BlockEntities")) if (tags.containsKey(name)) {
|
||||
TagList list = (TagList) required(tags, name, 9).value;
|
||||
if (!list.values.isEmpty()) throw new IOException(name + " are unsupported; import rejected without dropping data");
|
||||
if (list.elementType != 0 && list.elementType != 10) throw new IOException("Invalid " + name + " list type");
|
||||
}
|
||||
Map<String, Tag> paletteTags = map(required(tags, "Palette", 10));
|
||||
if (paletteTags.isEmpty() || paletteTags.size() > MAX_BLOCKS) throw new IOException("Invalid palette size");
|
||||
int paletteMax = tags.containsKey("PaletteMax") ? integer(tags, "PaletteMax") : MAX_BLOCKS;
|
||||
if (paletteMax < 1 || paletteMax > MAX_BLOCKS) throw new IOException("Invalid PaletteMax");
|
||||
Map<Integer, String> palette = new HashMap<>();
|
||||
for (var entry : paletteTags.entrySet()) {
|
||||
if (entry.getValue().type != 3) throw new IOException("Palette indices must be integers");
|
||||
int id = (Integer) entry.getValue().value;
|
||||
String state = rotateState(entry.getKey(), 0);
|
||||
if (id < 0 || id >= paletteMax || palette.put(id, state) != null) throw new IOException("Invalid or duplicate palette index");
|
||||
}
|
||||
byte[] blockData = (byte[]) required(tags, "BlockData", 7).value;
|
||||
ByteArrayInputStream data = new ByteArrayInputStream(blockData);
|
||||
List<String> blocks = new ArrayList<>(volume);
|
||||
for (int i = 0; i < volume; i++) {
|
||||
int id = readVarInt(data);
|
||||
String state = palette.get(id);
|
||||
if (state == null) throw new IOException("BlockData refers to missing palette index");
|
||||
blocks.add(state);
|
||||
}
|
||||
if (data.read() != -1) throw new IOException("BlockData has extra entries");
|
||||
String name = "Imported schematic";
|
||||
if (tags.containsKey("Metadata")) {
|
||||
Map<String, Tag> metadata = map(required(tags, "Metadata", 10));
|
||||
if (metadata.containsKey("RequiredMods")) {
|
||||
TagList mods = (TagList) required(metadata, "RequiredMods", 9).value;
|
||||
if (!mods.values.isEmpty()) throw new IOException("Schematics requiring mods are unsupported");
|
||||
}
|
||||
if (metadata.containsKey("Name")) { name = (String) required(metadata, "Name", 8).value; requireName(name); }
|
||||
}
|
||||
return new Decoded(name, width, height, length, dataVersion, offset, List.copyOf(blocks));
|
||||
} catch (IllegalArgumentException | ClassCastException e) { throw new IOException("Malformed schematic", e); }
|
||||
}
|
||||
private static int readVarInt(InputStream in) throws IOException {
|
||||
int value = 0;
|
||||
for (int index = 0; index < 5; index++) {
|
||||
int next = in.read();
|
||||
if (next < 0) throw new EOFException("Truncated block-data varint");
|
||||
if (index == 4 && (next & 0xf0) != 0) throw new IOException("Block-data varint exceeds positive int");
|
||||
value |= (next & 127) << (index * 7);
|
||||
if ((next & 128) == 0) {
|
||||
if (value < 0 || index > 0 && (next & 127) == 0) throw new IOException("Negative or noncanonical block-data varint");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new IOException("Block-data varint is too long");
|
||||
}
|
||||
private static Tag required(Map<String, Tag> tags, String key, int type) throws IOException {
|
||||
Tag value = tags.get(key);
|
||||
if (value == null || value.type != type) throw new IOException("Missing or mistyped schematic field: " + key);
|
||||
return value;
|
||||
}
|
||||
private static int integer(Map<String, Tag> tags, String key) throws IOException { return (Integer) required(tags, key, 3).value; }
|
||||
@SuppressWarnings("unchecked") private static Map<String, Tag> map(Tag tag) { return (Map<String, Tag>) tag.value; }
|
||||
private static String boundedUtf(DataInputStream in) throws IOException {
|
||||
int length = in.readUnsignedShort();
|
||||
if (length > 4096) throw new IOException("NBT string exceeds 4096 bytes");
|
||||
byte[] bytes = in.readNBytes(length);
|
||||
if (bytes.length != length) throw new EOFException("Truncated NBT string");
|
||||
// NBT Java strings use DataInput modified UTF-8, including supplementary characters.
|
||||
ByteArrayOutputStream framed = new ByteArrayOutputStream(length + 2);
|
||||
DataOutputStream out = new DataOutputStream(framed); out.writeShort(length); out.write(bytes);
|
||||
return new DataInputStream(new ByteArrayInputStream(framed.toByteArray())).readUTF();
|
||||
}
|
||||
private static Map<String, Tag> compound(DataInputStream in, int depth, int[] nodes) throws IOException {
|
||||
if (depth > 16) throw new IOException("NBT nesting exceeds 16 levels");
|
||||
Map<String, Tag> result = new LinkedHashMap<>();
|
||||
while (true) {
|
||||
int type = in.readUnsignedByte();
|
||||
if (type == 0) return result;
|
||||
String name = boundedUtf(in);
|
||||
if (result.containsKey(name)) throw new IOException("Duplicate NBT tag name");
|
||||
result.put(name, payload(in, type, depth + 1, nodes));
|
||||
}
|
||||
}
|
||||
private static int count(DataInputStream in, int itemBytes) throws IOException {
|
||||
int count = in.readInt();
|
||||
if (count < 0 || count > MAX_NBT / itemBytes || (long) count * itemBytes > in.available())
|
||||
throw new IOException("NBT array/list size exceeds remaining bounded input");
|
||||
return count;
|
||||
}
|
||||
private static Tag payload(DataInputStream in, int type, int depth, int[] nodes) throws IOException {
|
||||
if (depth > 16 || ++nodes[0] > 20_000) throw new IOException("NBT structure exceeds depth/node budget");
|
||||
Object value = switch (type) {
|
||||
case 1 -> in.readByte(); case 2 -> in.readShort(); case 3 -> in.readInt(); case 4 -> in.readLong();
|
||||
case 5 -> in.readFloat(); case 6 -> in.readDouble();
|
||||
case 7 -> { int size = count(in, 1); byte[] bytes = in.readNBytes(size); if (bytes.length != size) throw new EOFException(); yield bytes; }
|
||||
case 8 -> boundedUtf(in);
|
||||
case 9 -> {
|
||||
int elementType = in.readUnsignedByte(), size = count(in, 1);
|
||||
if (elementType < 0 || elementType > 12 || (elementType == 0 && size != 0)) throw new IOException("Invalid NBT list type");
|
||||
if (size > 20_000) throw new IOException("NBT list exceeds node budget");
|
||||
List<Tag> values = new ArrayList<>(size);
|
||||
for (int i = 0; i < size; i++) values.add(payload(in, elementType, depth + 1, nodes));
|
||||
yield new TagList(elementType, List.copyOf(values));
|
||||
}
|
||||
case 10 -> compound(in, depth, nodes);
|
||||
case 11 -> { int size = count(in, 4); int[] values = new int[size]; for (int i = 0; i < size; i++) values[i] = in.readInt(); yield values; }
|
||||
case 12 -> { int size = count(in, 8); long[] values = new long[size]; for (int i = 0; i < size; i++) values[i] = in.readLong(); yield values; }
|
||||
default -> throw new IOException("Unknown NBT tag type");
|
||||
};
|
||||
return new Tag(type, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
http-port: 8765
|
||||
# Empty values are generated on first enable, never printed to chat or logs.
|
||||
admin-token: ''
|
||||
agent-token: ''
|
||||
project-id: default
|
||||
owner-uuid: ''
|
||||
world: world
|
||||
world-epoch: ''
|
||||
region:
|
||||
min: {x: -64, y: 0, z: -64}
|
||||
max: {x: 64, y: 128, z: 64}
|
||||
# Enable only in an isolated local development world for the smoke test.
|
||||
allow-local-automation: false
|
||||
max-plan-blocks: 4096
|
||||
slice-blocks: 128
|
||||
slice-millis: 5
|
||||
camera-player-uuid: ''
|
||||
camera-port: 8766
|
||||
camera-token: ''
|
||||
@@ -0,0 +1,14 @@
|
||||
name: MinecraftBuilderMCP
|
||||
version: '0.1.0'
|
||||
main: io.github.minecraftbuilder.paper.BuilderPlugin
|
||||
api-version: '26.2'
|
||||
description: Scoped world editing and camera bridge for AI-assisted building
|
||||
commands:
|
||||
ai:
|
||||
description: Minecraft Builder agent and editor controls
|
||||
usage: /ai setup | area | status | stop | camera | <message>
|
||||
permission: minecraftbuilder.use
|
||||
permissions:
|
||||
minecraftbuilder.use:
|
||||
description: Access the configured builder project
|
||||
default: op
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ComponentTokensTest {
|
||||
private static final String ADMIN = "a".repeat(32), AGENT = "b".repeat(32), CAMERA = "c".repeat(32);
|
||||
|
||||
@Test void acceptsDistinctBoundedSafeBearerTokens() {
|
||||
assertDoesNotThrow(() -> ComponentTokens.validate(ADMIN, AGENT, CAMERA));
|
||||
assertDoesNotThrow(() -> ComponentTokens.validate("a".repeat(512), "b".repeat(512), "c".repeat(506) + ".-_~09"));
|
||||
}
|
||||
|
||||
@Test void rejectsHeaderInjectionWhitespaceUnicodeLengthAndNullWithoutEchoingSecret() {
|
||||
for (String invalid : new String[]{null, "", "short", "x".repeat(513), "dummy-private-token-that-must-not-leak\n",
|
||||
"dummy-private-token-that-must-not-leak\r\nInjected: yes", "x".repeat(31) + " ", "x".repeat(31) + "ж"}) {
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, invalid));
|
||||
assertEquals("Invalid component tokens: configure three distinct values of 32..512 safe ASCII characters", error.getMessage());
|
||||
assertFalse(error.getMessage().contains("dummy-private"));
|
||||
assertFalse(error.getMessage().contains(ADMIN));
|
||||
assertFalse(error.getMessage().contains(AGENT));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate("x\n".repeat(32), AGENT, CAMERA));
|
||||
assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, "x\n".repeat(32), CAMERA));
|
||||
}
|
||||
|
||||
@Test void rejectsSharedCapabilitiesForEveryPair() {
|
||||
assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, ADMIN, CAMERA));
|
||||
assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, ADMIN));
|
||||
assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, AGENT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.*;
|
||||
import java.net.http.*;
|
||||
import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RpcServerTest {
|
||||
private static final String ADMIN = "a".repeat(48), AGENT = "b".repeat(48);
|
||||
@Test void capabilitiesAreSeparatedAndIngressIsBounded() throws Exception {
|
||||
try (RpcServer server = new RpcServer(0,ADMIN,AGENT,(m,p,a)->Map.of("method",m))) {
|
||||
HttpClient client=HttpClient.newHttpClient();
|
||||
URI endpoint=URI.create("http://127.0.0.1:"+server.port()+"/v1/rpc");
|
||||
assertEquals(403,send(client,endpoint,"bad","{\"method\":\"project_context\"}").statusCode());
|
||||
assertEquals(403,send(client,endpoint,AGENT,"{\"method\":\"chat_poll\"}").statusCode());
|
||||
assertEquals(200,send(client,endpoint,AGENT,"{\"method\":\"project_context\"}").statusCode());
|
||||
assertEquals(200,send(client,endpoint,ADMIN,"{\"method\":\"chat_poll\"}").statusCode());
|
||||
assertTrue(send(client,endpoint,ADMIN,"x".repeat(1_048_577)).body().contains("budget_exceeded"));
|
||||
assertEquals(400,send(client,endpoint,ADMIN,"not-json").statusCode());
|
||||
}
|
||||
}
|
||||
private HttpResponse<String> send(HttpClient client,URI uri,String token,String body) throws Exception {
|
||||
return client.send(HttpRequest.newBuilder(uri).header("Authorization","Bearer "+token).POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package io.github.minecraftbuilder.paper;
|
||||
|
||||
import io.github.minecraftbuilder.core.BlockPos;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SchematicAssetsTest {
|
||||
@TempDir Path root;
|
||||
private static final BlockPos ZERO = new BlockPos(0, 0, 0);
|
||||
private static final String STONE = "minecraft:stone";
|
||||
private static final String STAIRS = "minecraft:oak_stairs[facing=north,half=bottom,shape=inner_left,waterlogged=false]";
|
||||
|
||||
@Test void roundTripKeepsEveryDenseCellAndOriginOffset() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
Map<BlockPos, String> blocks = new LinkedHashMap<>();
|
||||
blocks.put(new BlockPos(-2, 70, 4), STONE);
|
||||
blocks.put(new BlockPos(-1, 70, 4), "minecraft:air");
|
||||
blocks.put(new BlockPos(-2, 71, 4), "minecraft:oak_log[axis=x]");
|
||||
blocks.put(new BlockPos(-1, 71, 4), STAIRS);
|
||||
BlockPos anchor = new BlockPos(-1, 69, 3);
|
||||
var asset = assets.exportSnapshot("Башня", blocks, anchor, 5000);
|
||||
assertEquals(new BlockPos(-1, 1, 1), asset.offset());
|
||||
assertEquals(4, asset.blockCount());
|
||||
assertEquals(5000, asset.dataVersion());
|
||||
assertEquals(64, asset.sha256().length());
|
||||
assertEquals(blocks, assets.read(asset.assetId(), anchor, 0));
|
||||
assertEquals(List.of(asset), assets.list());
|
||||
try (DataInputStream nbt = new DataInputStream(new GZIPInputStream(Files.newInputStream(root.resolve(asset.assetId() + ".schem"))))) {
|
||||
assertEquals(10, nbt.readUnsignedByte());
|
||||
assertEquals("Schematic", nbt.readUTF());
|
||||
assertEquals(3, nbt.readUnsignedByte()); assertEquals("Version", nbt.readUTF()); assertEquals(2, nbt.readInt());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rotationMovesOffsetAndTransformsStairsLogsSlabsWithoutMirroring() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
Map<BlockPos, String> blocks = Map.of(new BlockPos(1, 0, 0), STAIRS, new BlockPos(2, 0, 0), "minecraft:oak_log[axis=x]",
|
||||
new BlockPos(3, 0, 0), "minecraft:oak_slab[type=top,waterlogged=false]");
|
||||
var asset = assets.exportSnapshot("Rotation", blocks, ZERO, 5000);
|
||||
Map<BlockPos, String> rotated = assets.read(asset.assetId(), new BlockPos(10, 64, 20), 90);
|
||||
assertEquals("minecraft:oak_stairs[facing=east,half=bottom,shape=inner_left,waterlogged=false]", rotated.get(new BlockPos(10, 64, 21)));
|
||||
assertEquals("minecraft:oak_log[axis=z]", rotated.get(new BlockPos(10, 64, 22)));
|
||||
assertEquals("minecraft:oak_slab[type=top,waterlogged=false]", rotated.get(new BlockPos(10, 64, 23)));
|
||||
assertEquals("minecraft:oak_log[axis=x]", assets.read(asset.assetId(), ZERO, 180).get(new BlockPos(-2, 0, 0)));
|
||||
assertEquals("minecraft:oak_stairs[facing=west,half=bottom,shape=inner_left,waterlogged=false]", assets.read(asset.assetId(), ZERO, 270).get(new BlockPos(0, 0, -1)));
|
||||
}
|
||||
|
||||
@Test void independentlyWrittenFixtureUsesXThenZThenYVarintOrder() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
Files.write(root.resolve("external.schem"), fixture(2, 1, 2, List.of(STONE, "minecraft:air"), new byte[]{0, 1, 1, 0}, out -> {}));
|
||||
assertEquals(Map.of(ZERO, STONE, new BlockPos(1, 0, 0), "minecraft:air", new BlockPos(0, 0, 1), "minecraft:air", new BlockPos(1, 0, 1), STONE), assets.read("external", ZERO, 0));
|
||||
}
|
||||
|
||||
@Test void paletteIndicesAbove127RoundTripAsMultibyteVarints() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
List<String> palette = new ArrayList<>();
|
||||
for (String block : List.of("oak_stairs", "spruce_stairs", "cobblestone_stairs", "stone_brick_stairs"))
|
||||
for (String facing : List.of("north", "east", "south", "west"))
|
||||
for (String half : List.of("bottom", "top"))
|
||||
for (String shape : List.of("straight", "inner_left", "inner_right", "outer_left", "outer_right"))
|
||||
palette.add("minecraft:" + block + "[facing=" + facing + ",half=" + half + ",shape=" + shape + ",waterlogged=false]");
|
||||
ByteArrayOutputStream indices = new ByteArrayOutputStream();
|
||||
Map<BlockPos, String> expected = new LinkedHashMap<>();
|
||||
for (int i = 0; i < palette.size(); i++) {
|
||||
if (i < 128) indices.write(i); else { indices.write((i & 127) | 128); indices.write(i >>> 7); }
|
||||
expected.put(new BlockPos(i, 0, 0), palette.get(i));
|
||||
}
|
||||
Files.write(root.resolve("varints.schem"), fixture(palette.size(), 1, 1, palette, indices.toByteArray(), out -> {}));
|
||||
assertEquals(expected, assets.read("varints", ZERO, 0));
|
||||
var exported = assets.exportSnapshot("Large palette", expected, ZERO, 5000);
|
||||
assertEquals(expected, assets.read(exported.assetId(), ZERO, 0));
|
||||
}
|
||||
|
||||
@Test void rejectsSparseSnapshotUnsupportedBlocksAndUnrepresentableOrigin() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
assertThrows(IOException.class, () -> assets.exportSnapshot("Gap", Map.of(ZERO, STONE, new BlockPos(2, 0, 0), STONE), ZERO, 5000));
|
||||
assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest"), ZERO, 5000));
|
||||
assertThrows(IOException.class, () -> assets.exportSnapshot("Offset", Map.of(new BlockPos(Integer.MIN_VALUE, 0, 0), STONE), new BlockPos(Integer.MAX_VALUE, 0, 0), 5000));
|
||||
assertEquals(0, assets.list().size());
|
||||
}
|
||||
|
||||
@Test void rejectsEntitiesAndBlockEntitiesRatherThanDroppingThem() throws Exception {
|
||||
for (String field : List.of("Entities", "BlockEntities")) {
|
||||
Files.write(root.resolve("entity.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {
|
||||
tag(out, 9, field); out.writeByte(10); out.writeInt(1); tag(out, 8, "Id"); out.writeUTF("minecraft:pig"); out.writeByte(0);
|
||||
}));
|
||||
IOException error = assertThrows(IOException.class, () -> new SchematicAssets(root).read("entity", ZERO, 0));
|
||||
assertTrue(error.getMessage().contains("unsupported"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsBiomeAndUnknownTopLevelData() throws Exception {
|
||||
Files.write(root.resolve("biome.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 7, "BiomeData"); out.writeInt(1); out.writeByte(0); }));
|
||||
assertThrows(IOException.class, () -> new SchematicAssets(root).read("biome", ZERO, 0));
|
||||
}
|
||||
|
||||
@Test void rejectsBadPaletteMissingExtraAndMalformedVarints() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
for (byte[] data : List.of(new byte[]{2}, new byte[0], new byte[]{0, 0}, new byte[]{(byte) 128}, new byte[]{(byte) 128, 0}, new byte[]{(byte) 255, (byte) 255, (byte) 255, (byte) 255, 127})) {
|
||||
Files.write(root.resolve("bad.schem"), fixture(1, 1, 1, List.of(STONE), data, out -> {}));
|
||||
assertThrows(IOException.class, () -> assets.read("bad", ZERO, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsDuplicateTagsUnsupportedVersionAndHugeVolume() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
Files.write(root.resolve("duplicate.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 3, "Version"); out.writeInt(3); }));
|
||||
assertThrows(IOException.class, () -> assets.read("duplicate", ZERO, 0));
|
||||
Files.write(root.resolve("large.schem"), fixture(4097, 1, 1, List.of(STONE), new byte[]{0}, out -> {}));
|
||||
assertThrows(IOException.class, () -> assets.read("large", ZERO, 0));
|
||||
byte[] future = fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {});
|
||||
byte[] raw = new GZIPInputStream(new ByteArrayInputStream(future)).readAllBytes();
|
||||
// Locate integer following the independent fixture's Version field and replace 2 with 3.
|
||||
try (ByteArrayInputStream bytes = new ByteArrayInputStream(raw); DataInputStream in = new DataInputStream(bytes)) {
|
||||
in.readByte(); in.readUTF(); in.readByte(); in.readUTF(); int at = raw.length - bytes.available(); raw[at + 3] = 3;
|
||||
}
|
||||
Files.write(root.resolve("future.schem"), gzip(raw));
|
||||
assertThrows(IOException.class, () -> assets.read("future", ZERO, 0));
|
||||
}
|
||||
|
||||
@Test void rejectsGzipBombTruncationExcessiveDepthAndHostileArrayLength() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
Files.write(root.resolve("bomb.schem"), gzip(new byte[4_194_305]));
|
||||
assertThrows(IOException.class, () -> assets.read("bomb", ZERO, 0));
|
||||
Files.write(root.resolve("short.schem"), new byte[]{31, (byte) 139});
|
||||
assertThrows(IOException.class, () -> assets.read("short", ZERO, 0));
|
||||
Files.write(root.resolve("array.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 11, "Offset"); out.writeInt(Integer.MAX_VALUE); }));
|
||||
assertThrows(IOException.class, () -> assets.read("array", ZERO, 0));
|
||||
Files.write(root.resolve("deep.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {
|
||||
tag(out, 10, "Metadata"); for (int i = 0; i < 30; i++) tag(out, 10, "deep"); for (int i = 0; i < 31; i++) out.writeByte(0);
|
||||
}));
|
||||
assertThrows(IOException.class, () -> assets.read("deep", ZERO, 0));
|
||||
}
|
||||
|
||||
@Test void rejectsPathsSymlinksAndCoordinateOverflow() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
assertThrows(IOException.class, () -> assets.read("../outside", ZERO, 0));
|
||||
Path external = Files.createTempFile("mcb-schematic-test-", ".schem");
|
||||
try {
|
||||
Files.write(external, fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {}));
|
||||
Files.createSymbolicLink(root.resolve("linked.schem"), external);
|
||||
assertThrows(IOException.class, () -> assets.read("linked", ZERO, 0));
|
||||
Files.delete(root.resolve("linked.schem"));
|
||||
} finally { Files.deleteIfExists(external); }
|
||||
var asset = assets.exportSnapshot("Offset", Map.of(new BlockPos(1, 0, 0), STONE), ZERO, 5000);
|
||||
assertThrows(IOException.class, () -> assets.read(asset.assetId(), new BlockPos(Integer.MAX_VALUE, 0, 0), 0));
|
||||
assertThrows(IOException.class, () -> assets.read(asset.assetId(), ZERO, 45));
|
||||
}
|
||||
|
||||
@Test void rejectsUnspecifiedRotationalPropertiesAndUnsupportedStates() throws Exception {
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs", 90));
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log", 90));
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log[axis=x,axis=z]", 0));
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs[facing=up]", 0));
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0));
|
||||
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:stone[rotation=4]", 90));
|
||||
}
|
||||
|
||||
@Test void metadataAndPlacementRejectFutureDataVersionOnTheActualRead() throws Exception {
|
||||
SchematicAssets assets = new SchematicAssets(root);
|
||||
var asset = assets.exportSnapshot("Versioned", Map.of(ZERO, STONE), ZERO, 5000);
|
||||
assertEquals(asset, assets.metadata(asset.assetId()));
|
||||
assertThrows(IOException.class, () -> assets.read(asset.assetId(), ZERO, 0, 4999));
|
||||
assertEquals(Map.of(ZERO, STONE), assets.read(asset.assetId(), ZERO, 0, 5000));
|
||||
assertEquals(Map.of(ZERO, STONE), assets.read(asset.assetId(), ZERO, 0, 5001));
|
||||
}
|
||||
|
||||
private interface Extra { void write(DataOutputStream out) throws IOException; }
|
||||
/** Independent spec fixture writer, intentionally does not call codec encode. */
|
||||
private static byte[] fixture(int width, int height, int length, List<String> palette, byte[] data, Extra extra) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(bytes)) {
|
||||
tag(out, 10, "Schematic"); tag(out, 3, "Version"); out.writeInt(2); tag(out, 3, "DataVersion"); out.writeInt(5000);
|
||||
tag(out, 2, "Width"); out.writeShort(width); tag(out, 2, "Height"); out.writeShort(height); tag(out, 2, "Length"); out.writeShort(length);
|
||||
tag(out, 3, "PaletteMax"); out.writeInt(palette.size()); tag(out, 10, "Palette");
|
||||
for (int i = 0; i < palette.size(); i++) { tag(out, 3, palette.get(i)); out.writeInt(i); }
|
||||
out.writeByte(0); tag(out, 7, "BlockData"); out.writeInt(data.length); out.write(data); extra.write(out); out.writeByte(0);
|
||||
}
|
||||
return gzip(bytes.toByteArray());
|
||||
}
|
||||
private static void tag(DataOutputStream out, int type, String name) throws IOException { out.writeByte(type); out.writeUTF(name); }
|
||||
private static byte[] gzip(byte[] bytes) throws IOException {
|
||||
ByteArrayOutputStream compressed = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { gzip.write(bytes); }
|
||||
return compressed.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user