Expand material support and checkpoint the completed building toolkit

Expose the runtime block/item registry through compact material search and
single-material descriptions. Preserve private block-entity data through
checked edits and durable undo without sending payloads to model context.

Include the completed terrain tools, isolated world/map plugin, station lift,
ACP streaming and guidance fixes, local camera auto-connect, construction
scripts, and their public documentation, references and verification records.
Active station decoration and private runtime data remain outside this commit.

Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build,
and isolated Paper verification of all 1,196 block defaults plus 5,392
independent property cases for placement, same-material edits and restoration.
This commit is contained in:
Emil
2026-09-13 16:59:13 +03:00
parent 0290559abf
commit 89c53d7058
179 changed files with 37719 additions and 162 deletions
@@ -0,0 +1,50 @@
package io.github.minecraftbuilder.paper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
/** Private journal representation. MCP responses expose only block data and an opaque digest. */
final class BlockSnapshots {
private static final String PREFIX = "\u0000mcb-block-v1:";
static final int MAX_SNAPSHOT_BYTES = 1024 * 1024;
record Value(String state, String nbt) { }
static boolean captured(String value) { return value != null && value.startsWith(PREFIX); }
static String encode(String state, String nbt) {
Objects.requireNonNull(state); Objects.requireNonNull(nbt);
if (state.isBlank() || state.length() > 1024 || state.indexOf('\n') >= 0 || state.indexOf('\u0000') >= 0)
throw new IllegalArgumentException("Invalid captured block data");
String result = PREFIX + state + "\n" + nbt;
requireBounded(result);
return result;
}
static Value decode(String value) {
Objects.requireNonNull(value);
if (!captured(value)) return new Value(value, null);
requireBounded(value);
int split = value.indexOf('\n', PREFIX.length());
if (split <= PREFIX.length() || split - PREFIX.length() > 1024 || split == value.length() - 1)
throw new IllegalArgumentException("Invalid stored block snapshot");
return new Value(value.substring(PREFIX.length(), split), value.substring(split + 1));
}
static String state(String value) { return decode(value).state(); }
static String snapshotId(String value) {
if (!captured(value)) return null;
decode(value);
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
static Map<String, String> publicView(String value) {
String id = snapshotId(value);
return id == null ? Map.of("state", state(value)) : Map.of("state", state(value), "snapshot_id", id);
}
private static void requireBounded(String value) {
if (value.length() > MAX_SNAPSHOT_BYTES || value.getBytes(StandardCharsets.UTF_8).length > MAX_SNAPSHOT_BYTES)
throw new IllegalArgumentException("snapshot_budget_exceeded: block entity exceeds the 1 MiB snapshot limit");
}
private BlockSnapshots() { }
}
@@ -0,0 +1,44 @@
package io.github.minecraftbuilder.paper;
import io.github.minecraftbuilder.core.TerrainBrush;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.*;
import javax.imageio.ImageIO;
/** Exact sampled columns, with a shared elevation scale for before/after panels. */
public final class BrushPreview {
private BrushPreview() { }
public static Map<String,Object> render(TerrainBrush.Result brush) throws IOException {
int rows=brush.before().length,cols=brush.before()[0].length;
int low=Integer.MAX_VALUE,high=Integer.MIN_VALUE;
for(int z=0;z<rows;z++)for(int x=0;x<cols;x++){
low=Math.min(low,Math.min(brush.before()[z][x],brush.after()[z][x]));
high=Math.max(high,Math.max(brush.before()[z][x],brush.after()[z][x]));
}
BufferedImage image=new BufferedImage(780,300,BufferedImage.TYPE_INT_RGB);Graphics2D g=image.createGraphics();
try {
g.setColor(new Color(0x172126));g.fillRect(0,0,780,300);g.setColor(new Color(0xECF0E9));g.setFont(new Font(Font.SANS_SERIF,Font.BOLD,16));
String[] labels={"BEFORE","AFTER","HEIGHT CHANGE"};
for(int panel=0;panel<3;panel++){
int left=panel*260+10;g.drawString(labels[panel],left,24);
double cell=Math.min(240.0/cols,240.0/rows);
for(int z=0;z<rows;z++)for(int x=0;x<cols;x++){
int old=brush.before()[z][x],next=brush.after()[z][x],rgb;
if(panel==2){int delta=next-old;rgb=delta==0?0x34464A:delta>0?0x89CC70:0xE5A35B;}
else{double t=((panel==0?old:next)-low)/(double)Math.max(1,high-low);rgb=((int)(45+160*t)<<16)|((int)(83+120*t)<<8)|(int)(65+107*t);}
g.setColor(new Color(rgb));int x0=left+(int)(x*cell),z0=36+(int)(z*cell);
g.fillRect(x0,z0,(int)((x+1)*cell)-(int)(x*cell),(int)((z+1)*cell)-(int)(z*cell));
}
g.setColor(new Color(0xECF0E9));
}
g.setFont(new Font(Font.SANS_SERIF,Font.PLAIN,12));g.drawString("N (-Z) up | Y "+low+" .. "+high+" | Green: raised Orange: lowered Grey: unchanged",10,289);
}finally{g.dispose();}
ByteArrayOutputStream out=new ByteArrayOutputStream();ImageIO.write(image,"png",out);
return Map.ofEntries(Map.entry("status","completed"),Map.entry("kind","terrain_brush_preview"),Map.entry("source","live_snapshot"),
Map.entry("world_edited",false),Map.entry("scan_bounds",brush.bounds()),Map.entry("changed_columns",brush.changedColumns()),
Map.entry("raised_volume",brush.raisedBlocks()),Map.entry("lowered_volume",brush.loweredBlocks()),Map.entry("dependency_blocks",brush.dependencies().size()),
Map.entry("height_min",low),Map.entry("height_max",high),Map.entry("mimeType","image/png"),Map.entry("imageBase64",Base64.getEncoder().encodeToString(out.toByteArray())));
}
}
@@ -30,12 +30,14 @@ public final class BuilderPlugin extends JavaPlugin {
private EditEngine engine;
private World world;
private BuildingWorld access;
private MaterialCatalog materials;
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,TerrainRecipe> terrains = Collections.synchronizedMap(new LinkedHashMap<>());
private final Map<String,JsonObject> messages = new LinkedHashMap<>();
private final Map<String,Part> parts = new LinkedHashMap<>();
private final Map<String,JsonObject> cameras = new LinkedHashMap<>();
@@ -65,10 +67,11 @@ public final class BuilderPlugin extends JavaPlugin {
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"));
materials=new MaterialCatalog();
assets=new SchematicAssets(getDataFolder().toPath().resolve("schematics"),BuilderPlugin::rotateSchematicState);
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))),
Limits limits=new Limits(maxBlocks,4096,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);}
@@ -157,7 +160,10 @@ public final class BuilderPlugin extends JavaPlugin {
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);
BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);
if(world.getBlockAt(x,y,z).getState() instanceof org.bukkit.block.TileState)
throw new Fault("unsupported_block_entity","Schematic export cannot retain block-entity data at "+at+"; no asset was written");
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()));
@@ -168,8 +174,62 @@ public final class BuilderPlugin extends JavaPlugin {
try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});}
}
case "project_context": return main(this::context);
case "material_search": return main(()->materials.search(str(p,"query",""),str(p,"kind","block"),p.has("limit")?integer(p,"limit"):null,p.has("cursor")?str(p,"cursor",""):null));
case "material_describe": return main(()->materials.describe(required(p,"id")));
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 "terrain_brush_prepare": {
TerrainBrush.Spec brush=TerrainBrush.parse(p.getAsJsonObject("brush"));
record PreparedBrush(TerrainBrush.Result result,Plan plan) {}
PreparedBrush prepared=main(()->{
available();Region scan=brush.bounds();
if(!region.contains(scan.min())||!region.contains(scan.max()))throw new Fault("out_of_bounds","Brush scan including halo exceeds project area");
Map<BlockPos,String> snapshot=new LinkedHashMap<>();
for(int y=scan.min().y();y<=scan.max().y();y++)for(int z=scan.min().z();z<=scan.max().z();z++)for(int x=scan.min().x();x<=scan.max().x();x++){
BlockPos at=new BlockPos(x,y,z);snapshot.put(at,access.getBlock(at));
}
TerrainBrush.Result result=TerrainBrush.compile(brush,snapshot,maxBlocks);
if(result.desired().isEmpty())return new PreparedBrush(result,null);
Map<BlockPos,String> desired=new LinkedHashMap<>(result.desired());desired.replaceAll((at,state)->access.canonical(state));
for(BlockPos at:desired.keySet())checkSurroundings(at,desired);
Plan plan=engine.prepare(projectId,epoch,region,desired,result.dependencies());ioBusy=true;
return new PreparedBrush(result,plan);
});
if(prepared.plan()==null){Map<String,Object> preview=new LinkedHashMap<>(BrushPreview.render(prepared.result()));preview.put("plan_state","empty");preview.put("changed_blocks",0);return preview;}
try{
engine.persistPlan(prepared.plan().id());Map<String,Object> preview=new LinkedHashMap<>(BrushPreview.render(prepared.result()));
preview.putAll(planSummary(prepared.plan()));preview.put("plan_state","prepared");return preview;
}finally{main(()->{ioBusy=false;return null;});}
}
case "terrain_preview": {
TerrainRecipe terrain=new TerrainRecipe(p.getAsJsonObject("recipe"));
Object preview=TerrainPreview.render(terrain,p.has("resolution")?integer(p,"resolution"):128,maxBlocks);
synchronized(terrains){terrains.put(terrain.id(),terrain);while(terrains.size()>32)terrains.remove(terrains.keySet().iterator().next());}
return preview;
}
case "terrain_prepare": {
String terrainId=required(p,"terrain_id");TerrainRecipe terrain=terrains.get(terrainId);
if(terrain==null)throw new Fault("not_found","Terrain recipe expired or server restarted; call terrain_preview again with the saved recipe");
int tileIndex=integer(p,"tile_index");TerrainRecipe.Tile tile=terrain.tile(tileIndex,maxBlocks);
Map<BlockPos,String> desired=new LinkedHashMap<>(tile.blocks());
Plan plan=main(()->{
available();
if(!region.contains(tile.bounds().min())||!region.contains(tile.bounds().max()))throw new Fault("out_of_bounds","Terrain tile exceeds current project area");
desired.replaceAll((at,state)->access.canonical(state));
boolean changed=false;
for(BlockPos at:desired.keySet()){
String before=access.getBlock(at);changed|=!before.equals(desired.get(at));
if(!TerrainRecipe.replaceable(before))throw new Fault("protected_terrain","Tile contains a building or non-terrain block at "+at+"; preserve it explicitly or choose another tile");
checkSurroundings(at,desired);
}
if(!changed)return null;
Plan value=engine.prepare(projectId,epoch,region,desired,Set.of());ioBusy=true;return value;
});
if(plan==null)return Map.of("status","empty","terrain_id",terrainId,"tile_index",tileIndex,"tile_bounds",tile.bounds(),"changed_blocks",0,"reason","No changed target blocks (unchanged or preserved)");
try{engine.persistPlan(plan.id());Map<String,Object> summary=new LinkedHashMap<>(planSummary(plan));
summary.put("terrain_id",terrainId);summary.put("tile_index",tileIndex);summary.put("tile_bounds",tile.bounds());return summary;
}finally{main(()->{ioBusy=false;return null;});}
}
case "build_prepare": {
JsonObject recipe=p.getAsJsonObject("recipe");
Map<BlockPos,String> desired=new LinkedHashMap<>(RecipeCompiler.compile(recipe,maxBlocks));
@@ -184,6 +244,7 @@ public final class BuilderPlugin extends JavaPlugin {
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);
if(p.has("expected_blocks"))ExpectedBlocks.check(p.getAsJsonArray("expected_blocks"),desired.keySet(),access::canonical,access::getBlock,at->BuildingWorld.snapshotId(access.captureBlock(at)));
Plan value=engine.prepare(projectId,epoch,region,desired,dependencies);ioBusy=true;return value;
});
try { engine.persistPlan(plan.id()); return planSummary(plan); }
@@ -228,11 +289,12 @@ public final class BuilderPlugin extends JavaPlugin {
}
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("max_plan_blocks",maxBlocks),Map.entry("checked_expected_blocks",true),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")));
Map.entry("terrain",Map.of("version",1,"features",List.of("hill","ridge","plateau","channel","basin","terrace"),"modes",List.of("sculpt","fill","cut"),"max_cached_recipes",32,"fluid_placement",false,"brush_actions",List.of("raise","lower","flatten","smooth"),"brush_max_scan_blocks",4096)),Map.entry("material_catalog",materials.summary()),Map.entry("recipe",Map.of("version",1,"operations",List.of("box","line","cylinder","repeat"))),Map.entry("capabilities",List.of("material_search","material_describe","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","terrain_preview","terrain_prepare","terrain_brush_prepare")),
Map.entry("block_data",Map.of("all_registered_block_states",true,"fluid_placement",true,"block_entity_snapshots",true,"raw_nbt_editing",false,"item_inventory_editing",false)),
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","Schematic v2: entity and block-entity payloads rejected","Later world simulation is not a journaled direct edit")));
}
private Object inspect(JsonObject p) {
Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max")));
@@ -240,8 +302,14 @@ public final class BuilderPlugin extends JavaPlugin {
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");
long snapshotBytes=0;
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));
BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);palette.merge(state,1,Integer::sum);
if(exact){
String captured=access.captureBlock(at);snapshotBytes+=captured.getBytes(StandardCharsets.UTF_8).length;
if(snapshotBytes>8_388_608)throw new Fault("budget_exceeded","Block-entity snapshots exceed 8 MiB; inspect a smaller area");
Map<String,Object> value=new LinkedHashMap<>(BuildingWorld.publicSnapshot(captured));value.put("pos",at);blocks.add(value);
}
}
return Map.of("region",area,"palette",palette,"blocks",blocks,"sampled_at",System.currentTimeMillis(),"world_epoch",epoch,"truncated",false);
}
@@ -249,7 +317,22 @@ public final class BuilderPlugin extends JavaPlugin {
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(),"")));
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().stream().map(BuilderPlugin::publicConflict).toList()),Map.entry("message",Objects.toString(v.message(),"")));
}
private static Map<String,Object> publicConflict(Conflict conflict) {
Map<String,Object> result=new LinkedHashMap<>();result.put("pos",conflict.pos());result.put("reason",conflict.reason());
for(var entry:Map.of("expected",conflict.expected(),"current",conflict.current(),"desired",conflict.desired()).entrySet()){
var snapshot=BuildingWorld.publicSnapshot(entry.getValue());result.put(entry.getKey(),snapshot.get("state"));
if(snapshot.containsKey("snapshot_id"))result.put(entry.getKey()+"_snapshot_id",snapshot.get("snapshot_id"));
}
return result;
}
private static String rotateSchematicState(String state,int degrees)throws IOException {
try{
var data=Bukkit.createBlockData(state);
data.rotate(switch(degrees){case 0->org.bukkit.block.structure.StructureRotation.NONE;case 90->org.bukkit.block.structure.StructureRotation.CLOCKWISE_90;case 180->org.bukkit.block.structure.StructureRotation.CLOCKWISE_180;case 270->org.bukkit.block.structure.StructureRotation.COUNTERCLOCKWISE_90;default->throw new IllegalArgumentException("Unsupported rotation");});
return data.getAsString();
}catch(IllegalArgumentException failure){throw new IOException("Invalid runtime schematic block state",failure);}
}
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);}
@@ -2,38 +2,38 @@ package io.github.minecraftbuilder.paper;
import io.github.minecraftbuilder.core.*;
import org.bukkit.*;
import org.bukkit.block.BlockState;
import org.bukkit.block.data.BlockData;
import java.util.*;
/** Small tested policy; unsupported existing contents are protected too. */
/** Live registry block data with private, lossless block-entity snapshots for guarded edits and undo. */
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",
"lantern", "iron_chain", "iron_bars", "stone_brick_wall", "oak_leaves", "moss_block",
"gray_stained_glass", "brown_stained_glass", "glowstone", "gold_block");
private final PaperBlockStateCodec snapshots = new PaperBlockStateCodec();
private final Map<String, BlockData> parsed = new LinkedHashMap<>(128, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, BlockData> entry) { return size() > 4096; }
};
BuildingWorld(World world) { this.world = world; }
static List<String> supportedMaterials() { return MATERIALS.stream().sorted().map(s->"minecraft:"+s).toList(); }
static List<String> supportedMaterials() { return Registry.BLOCK.stream().map(type -> type.getKey().toString()).sorted().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())
&& !(data instanceof org.bukkit.block.data.type.Leaves leaves && !leaves.isPersistent());
} catch (IllegalArgumentException e) { return false; }
String canonical(String state) {
// Opaque journal payloads are internal: model-supplied data is always parsed as BlockData.
try { return data(state).getAsString(); }
catch (IllegalArgumentException e) { throw new RpcServer.Fault("unsupported_block", "Invalid registered block or block properties"); }
}
public boolean supports(String state) {
try { return supportsData(data(BlockSnapshots.state(state))); }
catch (IllegalArgumentException | NullPointerException e) { return false; }
}
static boolean supportsData(BlockData data) {
// A parsed BlockData already represents a registered block, unlike item-only Materials.
return data != null && data.getMaterial() != null && !data.getMaterial().isLegacy();
}
static Map<String, String> publicSnapshot(String value) { return BlockSnapshots.publicView(value); }
static String snapshotId(String value) { return BlockSnapshots.snapshotId(value); }
static String canonicalSnapshotState(String value) { return BlockSnapshots.state(value); }
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");
@@ -41,15 +41,26 @@ final class BuildingWorld implements WorldAccess, BlockPolicy {
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) {
public String captureBlock(BlockPos p) {
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);
return snapshots.capture(world.getBlockAt(p.x(),p.y(),p.z()).getState());
}
public String prepareBlock(BlockPos p, String desired, String capturedBefore) {
if (BlockSnapshots.captured(desired)) return desired; // Exact private undo snapshot, never merge it.
BlockData after = data(desired);
BlockSnapshots.Value before = BlockSnapshots.decode(capturedBefore);
if (before.nbt() != null && data(before.state()).getMaterial() == after.getMaterial())
return BlockSnapshots.encode(after.getAsString(), snapshots.prepareData(before.nbt(), data(before.state()), after));
return snapshots.capture(after.createBlockState());
}
public void setBlock(BlockPos p, String state) {
setCapturedBlock(p, prepareBlock(p, canonical(state), captureBlock(p)));
}
public void setCapturedBlock(BlockPos p, String capturedState) {
ready(p);
BlockSnapshots.Value value = BlockSnapshots.decode(capturedState);
BlockState state = data(value.state()).createBlockState().copy(new Location(world, p.x(), p.y(), p.z()));
if (value.nbt() != null) snapshots.restoreData(state, value.nbt());
snapshots.place(state);
}
}
@@ -0,0 +1,54 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.*;
import io.github.minecraftbuilder.core.BlockPos;
import java.util.*;
import java.util.function.Function;
/** Optional caller snapshot, checked in the same server task that creates the plan. */
final class ExpectedBlocks {
private ExpectedBlocks() { }
static void check(JsonArray entries, Set<BlockPos> desired, Function<String,String> canonical,
Function<BlockPos,String> read) {
check(entries, desired, canonical, read, ignored -> null);
}
static void check(JsonArray entries, Set<BlockPos> desired, Function<String,String> canonical,
Function<BlockPos,String> read, Function<BlockPos,String> snapshotId) {
if (entries.size() != desired.size() || entries.size() > 4096)
throw new RpcServer.Fault("invalid_request", "expected_blocks must cover every desired position exactly once");
Map<BlockPos,String> expected = new LinkedHashMap<>();
Map<BlockPos,String> snapshots = new HashMap<>();
for (JsonElement entry : entries) {
JsonObject value = entry.getAsJsonObject();
JsonObject p = value.getAsJsonObject("pos");
BlockPos at = new BlockPos(integer(p,"x"), integer(p,"y"), integer(p,"z"));
if (!desired.contains(at) || expected.containsKey(at))
throw new RpcServer.Fault("invalid_request", "Unexpected or duplicate expected_blocks position");
expected.put(at, canonical.apply(value.get("state").getAsString()));
if (value.has("snapshot_id")) {
JsonElement id=value.get("snapshot_id");
if(!id.isJsonPrimitive() || !id.getAsJsonPrimitive().isString() || !id.getAsString().matches("[a-f0-9]{64}"))
throw new RpcServer.Fault("invalid_request", "snapshot_id must be a SHA-256 digest");
snapshots.put(at,id.getAsString());
}
}
for (var entry : expected.entrySet()) {
if (!entry.getValue().equals(read.apply(entry.getKey())))
throw new RpcServer.Fault("stale_snapshot", "Caller snapshot changed at " + entry.getKey());
String currentId=snapshotId.apply(entry.getKey()), expectedId=snapshots.get(entry.getKey());
if(currentId!=null && expectedId==null)
throw new RpcServer.Fault("invalid_request", "expected_blocks needs snapshot_id for block-entity data at " + entry.getKey() + "; inspect this block again");
if(expectedId!=null && !expectedId.equals(currentId))
throw new RpcServer.Fault("stale_snapshot", "Caller block-entity snapshot changed at " + entry.getKey());
}
}
private static int integer(JsonObject value, String name) {
try { return value.get(name).getAsBigDecimal().intValueExact(); }
catch (ArithmeticException | NumberFormatException ex) {
throw new RpcServer.Fault("invalid_request", "Expected position coordinates must be integers");
}
}
}
@@ -0,0 +1,202 @@
package io.github.minecraftbuilder.paper;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.block.TileState;
import org.bukkit.block.data.*;
import org.bukkit.block.data.type.Bed;
import org.bukkit.block.data.type.Door;
import org.bukkit.block.data.type.Leaves;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.function.Function;
/** Runtime registry discovery. Full registries and state permutations never enter an RPC response. */
final class MaterialCatalog {
static final int DEFAULT_LIMIT = 16, MAX_LIMIT = 32;
private static final int MAX_QUERY = 96, MAX_ID = 128, MAX_CURSOR = 100;
private static final int MAX_PROPERTIES = 64, MAX_PROPERTY_VALUES = 256, MAX_DESCRIPTION_CHARS = 16_384;
private final List<Entry> entries;
private final Map<String, Entry> byId;
private final Function<String, StateDescription> describeBlock;
private final String version;
record Entry(String id, boolean block, boolean item) {
Map<String, Object> compact() { return Map.of("id", id, "block", block, "item", item); }
}
record StateDescription(String defaultState, Map<String, List<String>> properties, List<String> behavior) {}
/** Construct on the server thread after Bukkit registries have loaded. */
MaterialCatalog() {
this(Bukkit.getMinecraftVersion() + "/" + Bukkit.getBukkitVersion(), runtimeEntries(), MaterialCatalog::runtimeDescription);
}
MaterialCatalog(String runtimeVersion, Collection<Entry> source, Function<String, StateDescription> describeBlock) {
this.entries = source.stream().sorted(Comparator.comparing(Entry::id)).toList();
Map<String, Entry> index = new HashMap<>();
StringBuilder fingerprint = new StringBuilder("material-catalog-v1\n").append(runtimeVersion).append('\n');
for (Entry entry : entries) {
if (index.put(entry.id(), entry) != null) throw new IllegalArgumentException("Duplicate material ID: " + entry.id());
fingerprint.append(entry.id()).append(':').append(entry.block()).append(':').append(entry.item()).append('\n');
}
this.byId = Map.copyOf(index);
this.describeBlock = Objects.requireNonNull(describeBlock);
this.version = digest(fingerprint.toString());
}
Map<String, Object> summary() {
return Map.of("version", version, "materials", entries.size(),
"blocks", entries.stream().filter(Entry::block).count(),
"items", entries.stream().filter(Entry::item).count(),
"search_default_limit", DEFAULT_LIMIT, "search_max_limit", MAX_LIMIT);
}
Map<String, Object> search(String query, String kind, Integer requestedLimit, String cursor) {
query = normalizeQuery(query);
kind = kind == null ? "block" : kind;
if (!Set.of("block", "item", "all").contains(kind))
throw fault("invalid_material_query", "kind must be block, item, or all");
int limit = requestedLimit == null ? DEFAULT_LIMIT : requestedLimit;
if (limit < 1 || limit > MAX_LIMIT)
throw fault("invalid_material_query", "limit must be between 1 and " + MAX_LIMIT);
String scope = digest(kind + "\n" + query);
int offset = decodeCursor(cursor, scope);
String[] tokens = query.isEmpty() ? new String[0] : query.split(" ");
List<Entry> matching = new ArrayList<>();
for (Entry entry : entries) {
if (kind.equals("block") && !entry.block() || kind.equals("item") && !entry.item()) continue;
if (Arrays.stream(tokens).allMatch(token -> entry.id().contains(token))) matching.add(entry);
}
if (offset > matching.size()) throw fault("invalid_cursor", "Cursor exceeds the matching catalog");
int end = Math.min(matching.size(), offset + limit);
Map<String, Object> result = new LinkedHashMap<>();
result.put("catalog_version", version);
result.put("query", query);
result.put("kind", kind);
result.put("total", matching.size());
result.put("results", matching.subList(offset, end).stream().map(Entry::compact).toList());
if (end < matching.size()) result.put("next_cursor", version + ":" + scope + ":" + end);
return result;
}
Map<String, Object> describe(String id) {
id = normalizeId(id);
Entry entry = byId.get(id);
if (entry == null) throw fault("invalid_material", "Unknown runtime material: " + id);
Map<String, Object> result = new LinkedHashMap<>(entry.compact());
result.put("catalog_version", version);
result.put("placeable", entry.block());
if (entry.block()) {
StateDescription state = describeBlock.apply(id);
if (state.defaultState().length() > 1024)
throw fault("material_properties_unavailable", "Runtime default block state exceeds the protocol limit");
result.put("default_state", state.defaultState());
result.put("properties", state.properties());
if (!state.behavior().isEmpty()) result.put("behavior", state.behavior());
}
return result;
}
private int decodeCursor(String cursor, String scope) {
if (cursor == null || cursor.isEmpty()) return 0;
if (cursor.length() > MAX_CURSOR || !cursor.matches("[0-9a-f]{24}:[0-9a-f]{24}:[0-9]{1,9}"))
throw fault("invalid_cursor", "Malformed material catalog cursor");
String[] parts = cursor.split(":");
if (!parts[0].equals(version)) throw fault("stale_cursor", "Material catalog changed; restart the search");
if (!parts[1].equals(scope)) throw fault("invalid_cursor", "Cursor belongs to another query or kind");
return Integer.parseInt(parts[2]);
}
private static String normalizeQuery(String query) {
if (query == null) return "";
if (query.length() > MAX_QUERY || query.chars().anyMatch(Character::isISOControl))
throw fault("invalid_material_query", "query must contain at most 96 printable characters");
return query.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
private static String normalizeId(String id) {
if (id == null || id.length() > MAX_ID || !id.matches("(?:minecraft:)?[a-z0-9_./-]+"))
throw fault("invalid_material", "Expected one exact Minecraft material ID without properties or NBT");
return id.contains(":") ? id : "minecraft:" + id;
}
private static List<Entry> runtimeEntries() {
List<Entry> entries = new ArrayList<>();
for (Material material : Material.values()) {
if (material.isLegacy()) continue;
boolean block = material.isBlock(), item = material.isItem();
if (!block && !item) continue;
if (block) {
// A registry mismatch must fail visibly, never silently shrink the advertised catalog.
try { Bukkit.createBlockData(material); }
catch (RuntimeException error) {
throw new IllegalStateException("Cannot read runtime block registry entry " + material.getKey(), error);
}
}
entries.add(new Entry(material.getKey().toString(), block, item));
}
return entries;
}
private static StateDescription runtimeDescription(String id) {
BlockData data = Bukkit.createBlockData(id);
try {
// Paper's public API exposes only createBlockDataStates(), which materializes all
// combinations. Read the existing StateHolder's property domains instead. These
// public mapped methods are verified against the pinned Paper 26.2 runtime.
Object state = data.getClass().getMethod("getState").invoke(data);
List<String> behavior = new ArrayList<>();
if (data.getMaterial().hasGravity()) behavior.add("gravity");
if (data.getMaterial() == Material.WATER || data.getMaterial() == Material.LAVA) behavior.add("fluid");
if (data instanceof Waterlogged) behavior.add("waterloggable");
if (data instanceof Door || data instanceof Bed) behavior.add("multi_block");
if (data instanceof Bisected) behavior.add("half_property");
if (data instanceof FaceAttachable || data instanceof Attachable) behavior.add("attachment_sensitive");
if (data instanceof Leaves) behavior.add("leaf_decay");
if (data.createBlockState() instanceof TileState) behavior.add("block_entity");
return new StateDescription(data.getAsString(), readProperties(state), List.copyOf(behavior));
} catch (ReflectiveOperationException | LinkageError error) {
throw fault("material_properties_unavailable", "Runtime property introspection is unavailable for " + id);
}
}
/** Domain-only adapter, kept separate so tests can cover all value types without a running server. */
static Map<String, List<String>> readProperties(Object state) throws ReflectiveOperationException {
Object value = state.getClass().getMethod("getProperties").invoke(state);
if (!(value instanceof Collection<?> properties) || properties.size() > MAX_PROPERTIES)
throw fault("material_properties_unavailable", "Runtime property count exceeds the response limit");
Map<String, List<String>> result = new TreeMap<>();
int characters = 0;
for (Object property : properties) {
String name = (String) property.getClass().getMethod("getName").invoke(property);
Object possible = property.getClass().getMethod("getPossibleValues").invoke(property);
if (!(possible instanceof Collection<?> values) || values.isEmpty() || values.size() > MAX_PROPERTY_VALUES)
throw fault("material_properties_unavailable", "Runtime property domain exceeds the response limit");
var serializeValue = property.getClass().getMethod("getName", Comparable.class);
List<String> serialized = new ArrayList<>();
characters += name.length();
for (Object candidate : values) {
String text = (String) serializeValue.invoke(property, candidate);
characters += text.length();
if (characters > MAX_DESCRIPTION_CHARS)
throw fault("material_properties_unavailable", "Runtime property description exceeds the response limit");
serialized.add(text);
}
if (result.put(name, List.copyOf(serialized)) != null)
throw fault("material_properties_unavailable", "Runtime returned duplicate property names");
}
return Collections.unmodifiableMap(result);
}
private static String digest(String input) {
try {
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes, 0, 12);
} catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
private static RpcServer.Fault fault(String code, String message) { return new RpcServer.Fault(code, message); }
}
@@ -0,0 +1,103 @@
package io.github.minecraftbuilder.paper;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.bukkit.block.BlockState;
import org.bukkit.block.TileState;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.StructureBlock;
/**
* Narrow, version-checked Paper 26.2 adapter for data the public BlockData API does not serialize.
* Full snapshot NBT preserves inventories, signs, decorations, entity data and plugin PDC. It never
* accepts NBT from RPC callers. Unavailable internals fail closed before editing instead of silently
* degrading undo to block data. Reflection keeps the plugin API-only build.
*/
final class PaperBlockStateCodec {
private final Class<?> tileClass;
private final Method saveNbt, getBlockEntity, getRegistryAccess, loadData, parseCompound, remove, putString, place;
private final Method getWorldHandle, getPosition, getLiveBlockEntity, setLiveBlockEntity;
private final int placeFlags;
PaperBlockStateCodec() {
try {
tileClass = Class.forName("org.bukkit.craftbukkit.block.CraftBlockEntityState");
Class<?> compound = Class.forName("net.minecraft.nbt.CompoundTag");
Class<?> parser = Class.forName("net.minecraft.nbt.TagParser");
Class<?> block = Class.forName("net.minecraft.world.level.block.Block");
Class<?> craftState = Class.forName("org.bukkit.craftbukkit.block.CraftBlockState");
Class<?> entity = Class.forName("net.minecraft.world.level.block.entity.BlockEntity");
Class<?> position = Class.forName("net.minecraft.core.BlockPos");
Class<?> level = Class.forName("net.minecraft.world.level.Level");
saveNbt = entity.getMethod("saveWithFullMetadata", Class.forName("net.minecraft.core.HolderLookup$Provider"));
getBlockEntity = tileClass.getMethod("getBlockEntity");
getRegistryAccess = tileClass.getMethod("getRegistryAccess");
loadData = tileClass.getMethod("loadData", compound);
parseCompound = parser.getMethod("parseCompoundFully", String.class);
remove = compound.getMethod("remove", String.class);
putString = compound.getMethod("putString", String.class, String.class);
place = craftState.getMethod("place", int.class);
getWorldHandle = craftState.getMethod("getWorldHandle");
getPosition = craftState.getMethod("getPosition");
getLiveBlockEntity = level.getMethod("getBlockEntity", position);
setLiveBlockEntity = level.getMethod("setBlockEntity", entity);
// setBlockData(false) omits SKIP_BLOCK_ENTITY_SIDEEFFECTS and can spill containers.
placeFlags = block.getField("UPDATE_CLIENTS").getInt(null)
| block.getField("UPDATE_SKIP_ALL_SIDEEFFECTS").getInt(null);
if (placeFlags != 818) throw new ReflectiveOperationException("Unexpected Paper placement flags");
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("This Paper version does not provide the required lossless block snapshot API", e);
}
}
String capture(BlockState state) {
String data = state.getBlockData().getAsString();
if (!(state instanceof TileState)) return data;
if (!tileClass.isInstance(state)) throw new IllegalStateException("Unknown block-entity snapshot implementation");
// The captured BlockState is freshly read or an unplaced default; its underlying entity
// already contains every value. Craft.getSnapshotNBT calls applyTo during a read and some
// tile kinds (Structure) can mutate the world or dereference an unplaced snapshot's level.
Object tag = call(saveNbt, call(getBlockEntity, state), call(getRegistryAccess, state));
// Coordinates live in Change; all entity data stays. Unplaced defaults compare with placed data.
call(remove, tag, "x"); call(remove, tag, "y"); call(remove, tag, "z");
// StringTagVisitor sorts compound keys recursively in pinned Paper 26.2.
return BlockSnapshots.encode(data, tag.toString());
}
void restoreData(BlockState state, String nbt) {
if (!(state instanceof TileState) || !tileClass.isInstance(state))
throw new IllegalArgumentException("Stored block entity does not match its block type");
call(loadData, state, call(parseCompound, null, nbt));
}
String prepareData(String nbt, BlockData before, BlockData after) {
// Structure mode exists in both block data and entity NBT. Loading old NBT otherwise
// rewrites the new mode back to its previous value. Preserve every unrelated field.
if (before instanceof StructureBlock oldStructure && after instanceof StructureBlock newStructure
&& oldStructure.getMode() != newStructure.getMode()) {
Object tag = call(parseCompound, null, nbt);
call(putString, tag, "mode", newStructure.getMode().name());
return tag.toString();
}
return nbt;
}
void place(BlockState state) {
if (!state.isPlaced()) throw new IllegalArgumentException("Snapshot restore requires a world location");
// A same-state call may return false although the TileState override copies changed NBT.
// EditEngine verifies the complete captured value immediately after every write.
call(place, state, placeFlags);
if (state instanceof TileState) {
Object level = call(getWorldHandle, state);
Object position = call(getPosition, state);
if (call(getLiveBlockEntity, level, position) == null) {
// MOVING_PISTON permits a block without an automatically created entity. The
// copied state owns a detached entity at the target position with restored NBT.
call(setLiveBlockEntity, level, call(getBlockEntity, state));
call(place, state, placeFlags); // Apply/mark changed and send the entity update.
}
}
}
private static Object call(Method method, Object receiver, Object... args) {
try { return method.invoke(receiver, args); }
catch (IllegalAccessException | InvocationTargetException e) {
// Never echo arbitrary sign, book, command or inventory contents from parser exceptions.
throw new IllegalStateException("Paper block snapshot operation failed: " + method.getName());
}
}
}
@@ -23,19 +23,11 @@ public final class SchematicAssets {
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;
private final StateTransformer states;
@FunctionalInterface
public interface StateTransformer { String transform(String state, int degrees) throws IOException; }
public record Asset(String assetId, String name, int width, int height, int length, int blockCount,
int dataVersion, BlockPos offset, String sha256, long bytes) { }
@@ -44,7 +36,11 @@ public final class SchematicAssets {
private record Tag(int type, Object value) { }
private record TagList(int elementType, List<Tag> values) { }
public SchematicAssets(Path root) throws IOException {
public SchematicAssets(Path root) throws IOException { this(root, SchematicAssets::rotateState); }
public SchematicAssets(Path root, StateTransformer states) throws IOException {
Objects.requireNonNull(states);
this.states = (state,degrees) -> states.transform(rotateState(state,0),degrees);
this.root = root.toAbsolutePath().normalize();
rejectSymlinkParents();
Files.createDirectories(this.root);
@@ -75,7 +71,7 @@ public final class SchematicAssets {
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);
String state = states.transform(blocks.get(new BlockPos(minX + x, minY + y, minZ + z)), 0);
int index = palette.computeIfAbsent(state, ignored -> palette.size());
writeVarInt(data, index);
}
@@ -128,7 +124,7 @@ public final class SchematicAssets {
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));
result.put(at, states.transform(value.blocks.get(index++), rotation90));
}
} catch (ArithmeticException e) { throw new IOException("Placement overflows integer coordinates", e); }
return Collections.unmodifiableMap(result);
@@ -188,42 +184,30 @@ public final class SchematicAssets {
throw new IOException("Asset name must contain 1..64 printable characters");
}
/** Only recognised schemas rotate: no guessing about unknown direction-like properties. */
/** Offline syntax codec; runtime integration injects registry validation and native rotation. */
static String rotateState(String state, int degrees) throws IOException {
if (state == null || state.length() > 512 || !state.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?"))
if (state == null || state.length() > 1024 || !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 (!Set.of(0,90,180,270).contains(degrees)) throw new IOException("Invalid rotation");
if (degrees == 0) return "minecraft:" + id + (properties.isEmpty() ? "" : "[" + String.join(",", properties.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList()) + "]");
Set<String> allowed = Set.of("facing","axis","half","shape","type","waterlogged","snowy");
if (!allowed.containsAll(properties.keySet())) throw new IOException("Runtime block rotation required for these properties");
if(properties.containsKey("shape") && !Set.of("straight","inner_left","inner_right","outer_left","outer_right").contains(properties.get("shape")))
throw new IOException("Runtime block rotation required for this shape");
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"))
if (degrees != 0 && (id.endsWith("_log") || id.equals("quartz_pillar") || id.equals("deepslate")) && !properties.containsKey("axis"))
throw new IOException("Rotation requires explicit block axis");
if (properties.containsKey("facing")) {
if (properties.containsKey("facing") && !Set.of("up","down").contains(properties.get("facing"))) {
List<String> faces = List.of("north", "east", "south", "west");
if (!faces.contains(properties.get("facing"))) throw new IOException("Invalid facing");
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"))
@@ -260,7 +244,7 @@ public final class SchematicAssets {
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 {
private Decoded decode(byte[] compressed) throws IOException {
byte[] raw;
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
raw = gzip.readNBytes(MAX_NBT + 1);
@@ -299,7 +283,7 @@ public final class SchematicAssets {
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);
String state = states.transform(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;
@@ -0,0 +1,52 @@
package io.github.minecraftbuilder.paper;
import io.github.minecraftbuilder.core.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.*;
import javax.imageio.ImageIO;
/** Diagram of the requested field, never presented as a capture or a live-world comparison. */
public final class TerrainPreview {
private TerrainPreview() { }
public static Map<String,Object> render(TerrainRecipe recipe,int resolution,int maxBlocks) throws IOException {
if(resolution<32||resolution>256)throw new IllegalArgumentException("Resolution must be 32..256");
int w=Math.min(resolution,recipe.width()),h=Math.min(resolution,recipe.length());
int[][] heights=new int[h][w];int low=Integer.MAX_VALUE,high=Integer.MIN_VALUE,clipped=0;
for(int z=0;z<h;z++)for(int x=0;x<w;x++){
int value=recipe.surfaceHeight(coordinate(x,w,recipe.bounds().min().x(),recipe.width()),coordinate(z,h,recipe.bounds().min().z(),recipe.length()));
heights[z][x]=value;low=Math.min(low,value);high=Math.max(high,value);
if(value<recipe.bounds().min().y()||value>recipe.bounds().max().y())clipped++;
}
BufferedImage image=new BufferedImage(w*2,h*2,BufferedImage.TYPE_INT_RGB);
for(int z=0;z<h;z++)for(int x=0;x<w;x++){
double t=(heights[z][x]-low)/(double)Math.max(1,high-low);
double slope=(heights[z][Math.max(0,x-1)]-heights[z][Math.min(w-1,x+1)])+(heights[Math.max(0,z-1)][x]-heights[Math.min(h-1,z+1)][x]);
double shade=Math.max(0.55,Math.min(1.25,0.9+slope*0.035));
int red=(int)((48+155*t)*shade),green=(int)((80+115*t)*shade),blue=(int)((65+107*t)*shade);
int rgb=(Math.min(255,red)<<16)|(Math.min(255,green)<<8)|Math.min(255,blue);
BlockPos at=new BlockPos(coordinate(x,w,recipe.bounds().min().x(),recipe.width()),Math.max(recipe.bounds().min().y(),Math.min(recipe.bounds().max().y(),heights[z][x])),coordinate(z,h,recipe.bounds().min().z(),recipe.length()));
if(recipe.preserved(at))rgb=0xD696CD;
for(int dz=0;dz<2;dz++)for(int dx=0;dx<2;dx++)image.setRGB(x*2+dx,z*2+dz,rgb);
}
ByteArrayOutputStream bytes=new ByteArrayOutputStream();ImageIO.write(image,"png",bytes);
return Map.ofEntries(Map.entry("status","completed"),Map.entry("kind","terrain_heightmap_preview"),
Map.entry("terrain_id",recipe.id()),Map.entry("bounds",recipe.bounds()),Map.entry("mode",recipe.mode()),
Map.entry("tile_edge",recipe.tileEdge(maxBlocks)),Map.entry("tile_count",recipe.tileCount(maxBlocks)),
Map.entry("tile_order","x_then_z_then_y"),Map.entry("tile_budget",maxBlocks),
Map.entry("sampled_height_min",low),Map.entry("sampled_height_max",high),Map.entry("clipped_samples",clipped),
Map.entry("sample_grid",Map.of("width",w,"height",h)),Map.entry("orientation","north (-Z) up; east (+X) right"),
Map.entry("legend","Dark green = low; pale stone = high; pink = preserved at sampled surface"),
Map.entry("world_verified",false),Map.entry("note","Target surface only; no live world reads. Bounds clip writes, not height calculations. Cache holds 32 recipes until restart; save the recipe to regenerate the same ID."),
Map.entry("mimeType","image/png"),Map.entry("imageBase64",Base64.getEncoder().encodeToString(bytes.toByteArray())));
}
private static int coordinate(int i,int count,int min,int length){return min+(int)((long)i*(length-1)/Math.max(1,count-1));}
/** Offline preview using exactly the server compiler; args: recipe.json output.png metadata.json. */
public static void main(String[] args) throws Exception {
if(args.length!=3)throw new IllegalArgumentException("Usage: TerrainPreview recipe.json output.png metadata.json");
TerrainRecipe recipe=new TerrainRecipe(com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(java.nio.file.Path.of(args[0]))).getAsJsonObject());
Map<String,Object> result=new LinkedHashMap<>(render(recipe,256,4096));
java.nio.file.Files.write(java.nio.file.Path.of(args[1]),Base64.getDecoder().decode((String)result.remove("imageBase64")));
java.nio.file.Files.writeString(java.nio.file.Path.of(args[2]),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(result));
}
}
@@ -0,0 +1,38 @@
package io.github.minecraftbuilder.paper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
final class BlockSnapshotsTest {
@Test void payloadSurvivesExactlyButPublicViewOnlyContainsStateAndDigest() {
String state = "minecraft:chest[facing=north,type=single,waterlogged=false]";
String nbt = "{Items:[{id:\"minecraft:written_book\",components:{text:\"private secret\"}}],id:\"minecraft:chest\"}";
String encoded = BlockSnapshots.encode(state, nbt);
assertEquals(state, BlockSnapshots.state(encoded));
assertEquals(nbt, BlockSnapshots.decode(encoded).nbt());
var publicValue = BlockSnapshots.publicView(encoded);
assertEquals(2, publicValue.size());
assertEquals(state, publicValue.get("state"));
assertTrue(publicValue.get("snapshot_id").matches("[0-9a-f]{64}"));
assertFalse(publicValue.toString().contains("private secret"));
assertNotEquals(BlockSnapshots.snapshotId(encoded), BlockSnapshots.snapshotId(BlockSnapshots.encode(state, nbt + " ")));
assertEquals(publicValue, BlockSnapshots.publicView(encoded));
}
@Test void oldPlainJournalStatesStayUnchanged() {
String plain = "minecraft:stone";
assertEquals(plain, BlockSnapshots.decode(plain).state());
assertNull(BlockSnapshots.decode(plain).nbt());
assertNull(BlockSnapshots.snapshotId(plain));
assertEquals(java.util.Map.of("state", plain), BlockSnapshots.publicView(plain));
}
@Test void entityBudgetCountsUtf8BytesAndErrorsNeverIncludePrivateText() {
String huge = "секрет".repeat(BlockSnapshots.MAX_SNAPSHOT_BYTES / 7);
var error = assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest", huge));
assertTrue(error.getMessage().contains("snapshot_budget_exceeded"));
assertFalse(error.getMessage().contains("секрет"));
}
@Test void malformedKnownSnapshotAndAmbiguousStateAreRejected() {
assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.decode("\u0000mcb-block-v1:minecraft:chest"));
assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest\n{}", "{}"));
}
}
@@ -0,0 +1,20 @@
package io.github.minecraftbuilder.paper;
import io.github.minecraftbuilder.core.*;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
class BrushPreviewTest {
@Test void nativePreviewShowsActualBeforeAfterWithoutWorldWriteClaim() throws Exception {
var r=new TerrainBrush.Result(Map.of(new BlockPos(0,2,0),"minecraft:stone"),Set.of(new BlockPos(0,1,0)),
new Region("world",new BlockPos(0,0,0),new BlockPos(1,3,1)),new int[][]{{1,1},{1,1}},new int[][]{{2,1},{0,1}},2,1,1);
var result=BrushPreview.render(r);assertEquals("live_snapshot",result.get("source"));assertEquals(false,result.get("world_edited"));
var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64"))));
assertEquals(780,image.getWidth());assertEquals(300,image.getHeight());
assertNotEquals(image.getRGB(10,40),image.getRGB(270,40));
assertEquals(0x89CC70,image.getRGB(530,40)&0xFFFFFF);assertEquals(0xE5A35B,image.getRGB(530,160)&0xFFFFFF);
}
}
@@ -0,0 +1,46 @@
package io.github.minecraftbuilder.paper;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Waterlogged;
import org.bukkit.block.data.type.Leaves;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import static org.junit.jupiter.api.Assertions.*;
/** Parsed BlockData stubs test policy; the live registry integration test validates item rejection. */
final class BuildingWorldPolicyTest {
private static BlockData state(Material material, Class<? extends BlockData> type,
boolean waterlogged, boolean persistent) {
return (BlockData) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
(proxy, method, arguments) -> switch (method.getName()) {
case "getMaterial" -> material;
case "isWaterlogged" -> waterlogged;
case "isPersistent" -> persistent;
default -> throw new UnsupportedOperationException(method.getName());
});
}
@Test void leafAndWaterloggedStatesAreNoLongerArtificiallyRestricted() {
for (Material material : new Material[]{Material.SPRUCE_LEAVES, Material.OAK_LEAVES})
for (boolean wet : new boolean[]{false,true})
for (boolean persistent : new boolean[]{false,true})
assertTrue(BuildingWorld.supportsData(state(material, Leaves.class, wet, persistent)));
for (Material material : new Material[]{Material.SPRUCE_FENCE, Material.CUT_SANDSTONE_SLAB,
Material.BARRIER, Material.CHEST})
assertTrue(BuildingWorld.supportsData(state(material, Waterlogged.class, true, true)));
}
@Test void fluidsGrowthAdministrativeAndBlockEntityTypesAreAccepted() {
for (Material material : new Material[]{Material.WATER, Material.LAVA, Material.OAK_SAPLING,
Material.PEONY, Material.CUT_COPPER, Material.STRUCTURE_VOID, Material.LIGHT,
Material.STRUCTURE_BLOCK, Material.JIGSAW, Material.COMMAND_BLOCK,
Material.CHAIN_COMMAND_BLOCK, Material.REPEATING_COMMAND_BLOCK, Material.TNT,
Material.CHEST, Material.OAK_SIGN, Material.DECORATED_POT, Material.BEEHIVE,
Material.TRIAL_SPAWNER, Material.VAULT})
assertTrue(BuildingWorld.supportsData(state(material, BlockData.class, false, true)), material.name());
}
@Test void missingAndLegacyDataDoNotBypassTheParserPolicy() {
assertFalse(BuildingWorld.supportsData(null));
assertFalse(BuildingWorld.supportsData(state(null, BlockData.class, false, true)));
assertFalse(BuildingWorld.supportsData(state(Material.LEGACY_STONE, BlockData.class, false, true)));
}
}
@@ -0,0 +1,54 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.*;
import io.github.minecraftbuilder.core.BlockPos;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class ExpectedBlocksTest {
private final BlockPos a = new BlockPos(0,64,0), b = new BlockPos(1,64,0);
private JsonArray entries(String... states) {
JsonArray result=new JsonArray();
for(int x=0;x<states.length;x++) {
JsonObject e=new JsonObject(),p=new JsonObject();
p.addProperty("x",x);p.addProperty("y",64);p.addProperty("z",0);
e.add("pos",p);e.addProperty("state",states[x]);result.add(e);
}
return result;
}
@Test void rejectsManualChangeBetweenCallerReadAndPreparation() {
var snapshot=entries("minecraft:grass_block","minecraft:air");
var live=new HashMap<>(Map.of(a,"minecraft:grass_block",b,"minecraft:air"));
ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get);
live.put(b,"minecraft:gold_block");
var error=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get));
assertTrue(error.getMessage().contains("Caller snapshot changed"));
assertEquals("minecraft:gold_block",live.get(b));
}
@Test void rejectsIncompleteDuplicateAndOutsideSnapshots() {
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air"),Set.of(a,b),s->s,p->"air"));
var duplicate=entries("air","air");duplicate.set(1,duplicate.get(0));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(duplicate,Set.of(a,b),s->s,p->"air"));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air","air"),Set.of(a,new BlockPos(2,64,0)),s->s,p->"air"));
}
@Test void canonicalizesExpectedStatesAndRejectsFractionalCoordinates() {
ExpectedBlocks.check(entries("grass_block"),Set.of(a),s->"minecraft:"+s,p->"minecraft:grass_block");
var fractional=entries("air");fractional.get(0).getAsJsonObject().getAsJsonObject("pos").addProperty("x",.5);
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(fractional,Set.of(a),s->s,p->"air"));
}
@Test void detectsContentsChangeEvenWhenBlockStateIsUnchanged() {
var snapshot=entries("minecraft:chest");
snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","a".repeat(64));
ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64));
var changed=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"b".repeat(64)));
assertTrue(changed.getMessage().contains("block-entity snapshot changed"));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->null));
}
@Test void requiresEntityDigestAndRejectsMalformedDigest() {
var snapshot=entries("minecraft:chest");
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64)));
snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","not-a-hash");
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64)));
}
}
@@ -0,0 +1,179 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class MaterialCatalogTest {
private static List<MaterialCatalog.Entry> fixtures() {
List<MaterialCatalog.Entry> entries = new ArrayList<>();
IntStream.range(0, 70).forEach(i -> entries.add(new MaterialCatalog.Entry("minecraft:block_%02d".formatted(i), true, true)));
entries.add(new MaterialCatalog.Entry("minecraft:water", true, false));
entries.add(new MaterialCatalog.Entry("minecraft:iron_pickaxe", false, true));
entries.add(new MaterialCatalog.Entry("minecraft:waxed_oxidized_copper_stairs", true, true));
entries.add(new MaterialCatalog.Entry("minecraft:oxidized_copper_bulb", true, true));
entries.add(new MaterialCatalog.Entry("minecraft:cut_copper_stairs", true, true));
return entries;
}
private MaterialCatalog catalog() {
return new MaterialCatalog("26.2-test", fixtures(), id ->
new MaterialCatalog.StateDescription(id + "[waterlogged=false]", Map.of("waterlogged", List.of("true", "false")), List.of("waterloggable")));
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> results(Map<String, Object> page) {
return (List<Map<String, Object>>) page.get("results");
}
@Test void summaryIsTinyAndContainsCountsRatherThanIds() {
var summary = catalog().summary();
assertEquals(75, summary.get("materials"));
assertEquals(74L, summary.get("blocks"));
assertEquals(74L, summary.get("items"));
assertEquals(16, summary.get("search_default_limit"));
assertEquals(32, summary.get("search_max_limit"));
String json = new Gson().toJson(summary);
assertTrue(json.length() < 200);
assertFalse(json.contains("minecraft:"));
}
@Test void searchDefaultsToBlocksAndSixteenCompactResults() {
var page = catalog().search(null, null, null, null);
assertEquals("block", page.get("kind"));
assertEquals(74, page.get("total"));
assertEquals(16, results(page).size());
assertTrue(results(page).stream().allMatch(entry -> entry.keySet().equals(Set.of("id", "block", "item"))));
assertTrue(((String) page.get("next_cursor")).length() < 100);
}
@Test void allPagesAreStableBoundedAndDoNotDuplicateOrLoseEntries() {
var catalog = catalog();
String cursor = null;
List<String> found = new ArrayList<>();
int pages = 0;
do {
var page = catalog.search("", "all", 32, cursor);
assertTrue(results(page).size() <= 32);
for (var result : results(page)) found.add((String) result.get("id"));
cursor = (String) page.get("next_cursor");
pages++;
} while (cursor != null);
assertEquals(3, pages);
assertEquals(75, found.size());
assertEquals(75, new HashSet<>(found).size());
assertEquals(found.stream().sorted().toList(), found);
}
@Test void tokenSearchRequiresEveryTokenAndIgnoresCase() {
var page = catalog().search(" COPPER stair ", "block", 32, null);
assertEquals("copper stair", page.get("query"));
assertEquals(2, results(page).size());
assertTrue(results(page).stream().allMatch(entry -> ((String) entry.get("id")).contains("copper_stairs")));
assertEquals(0, catalog().search("copper unknown", "all", 32, null).get("total"));
}
@Test void itemKindIncludesInventoryBlocksButExcludesFluids() {
var catalog = catalog();
assertEquals(0, catalog.search("water", "item", 16, null).get("total"));
assertEquals(1, catalog.search("water", "block", 16, null).get("total"));
assertEquals(1, catalog.search("pickaxe", "item", 16, null).get("total"));
assertEquals(0, catalog.search("pickaxe", "block", 16, null).get("total"));
}
@Test void rejectsCursorWhenCatalogOrSearchChanges() {
var catalog = catalog();
String cursor = (String) catalog.search("block", "all", 16, null).get("next_cursor");
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class,
() -> catalog.search("water", "all", 16, cursor)).code);
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class,
() -> catalog.search("block", "item", 16, cursor)).code);
var changed = new MaterialCatalog("26.3-test", fixtures(), id -> null);
assertEquals("stale_cursor", assertThrows(RpcServer.Fault.class,
() -> changed.search("block", "all", 16, cursor)).code);
var reordered = new ArrayList<>(fixtures());
Collections.reverse(reordered);
assertEquals(catalog.summary().get("version"), new MaterialCatalog("26.2-test", reordered, id -> null).summary().get("version"));
}
@Test void rejectsMalformedAndOversizedArguments() {
var catalog = catalog();
for (int limit : List.of(-1, 0, 33, Integer.MAX_VALUE))
assertEquals("invalid_material_query", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", limit, null)).code);
assertThrows(RpcServer.Fault.class, () -> catalog.search("", "nonsense", null, null));
assertThrows(RpcServer.Fault.class, () -> catalog.search("x".repeat(97), "all", null, null));
assertEquals(0, catalog.search("x".repeat(96), "all", null, null).get("total"));
assertThrows(RpcServer.Fault.class, () -> catalog.search("x\ny", "all", null, null));
for (String cursor : List.of("bad", "x".repeat(101), "::", "0".repeat(24) + ":" + "0".repeat(24) + ":2147483647"))
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", null, cursor)).code);
String valid = (String) catalog.search("", "all", 16, null).get("next_cursor");
String beyond = valid.substring(0, valid.lastIndexOf(':') + 1) + "999999999";
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", 16, beyond)).code);
}
@Test void describeLoadsOnlyTheExactBlockRequested() {
AtomicInteger calls = new AtomicInteger();
var catalog = new MaterialCatalog("test", fixtures(), id -> {
calls.incrementAndGet();
return new MaterialCatalog.StateDescription(id, Map.of(), List.of());
});
catalog.summary();
catalog.search("", "all", 16, null);
assertEquals(0, calls.get());
var description = catalog.describe("water");
assertEquals("minecraft:water", description.get("default_state"));
assertEquals(true, description.get("placeable"));
assertEquals(1, calls.get());
var item = catalog.describe("minecraft:iron_pickaxe");
assertEquals(false, item.get("placeable"));
assertEquals(true, item.get("item"));
assertFalse(item.containsKey("default_state"));
assertFalse(item.containsKey("properties"));
assertEquals(1, calls.get());
}
@Test void rejectsUnknownLegacyAndNonExactIds() {
for (String id : List.of("minecraft:missing", "minecraft:legacy_stone", "stone[axis=y]", "stone{}", "STONE", "mod:stone", "", "x".repeat(129)))
assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(id)).code);
assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(null)).code);
}
@Test void describesBooleanIntegerAndEnumDomainsWithoutAStateProduct() throws Exception {
var state = new FakeState(List.of(
new FakeProperty("waterlogged", List.of(false, true)),
new FakeProperty("age", List.of(0, 1, 2, 3)),
new FakeProperty("facing", List.of(Facing.N, Facing.S))));
var properties = MaterialCatalog.readProperties(state);
assertEquals(List.of("false", "true"), properties.get("waterlogged"));
assertEquals(List.of("0", "1", "2", "3"), properties.get("age"));
assertEquals(List.of("north", "south"), properties.get("facing"));
assertEquals(List.of("age", "facing", "waterlogged"), new ArrayList<>(properties.keySet()));
assertEquals(8, properties.values().stream().mapToInt(List::size).sum());
assertEquals(0, MaterialCatalog.readProperties(new FakeState(List.of())).size());
}
@Test void enormousOrDuplicatePropertyDomainsFailRatherThanTruncate() {
var enormous = new FakeState(List.of(new FakeProperty("age", IntStream.range(0, 257).boxed().toList())));
assertEquals("material_properties_unavailable", assertThrows(RpcServer.Fault.class,
() -> MaterialCatalog.readProperties(enormous)).code);
var duplicate = new FakeState(List.of(new FakeProperty("age", List.of(0)), new FakeProperty("age", List.of(0))));
assertThrows(RpcServer.Fault.class, () -> MaterialCatalog.readProperties(duplicate));
}
enum Facing { N, S }
public record FakeState(Collection<FakeProperty> properties) {
public Collection<FakeProperty> getProperties() { return properties; }
}
public record FakeProperty(String name, Collection<?> values) {
public String getName() { return name; }
public Collection<?> getPossibleValues() { return values; }
public String getName(Comparable<?> value) {
return value instanceof Facing facing ? (facing == Facing.N ? "north" : "south") : value.toString();
}
}
}
@@ -76,10 +76,10 @@ class SchematicAssetsTest {
assertEquals(expected, assets.read(exported.assetId(), ZERO, 0));
}
@Test void rejectsSparseSnapshotUnsupportedBlocksAndUnrepresentableOrigin() throws Exception {
@Test void rejectsSparseSnapshotMalformedBlocksAndUnrepresentableOrigin() 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("Chest", Map.of(ZERO, "minecraft:chest{Items:[]}"), 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());
}
@@ -156,10 +156,22 @@ class SchematicAssetsTest {
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));
assertEquals("minecraft:oak_slab[type=top,waterlogged=true]", SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:stone[rotation=4]", 90));
}
@Test void delegatesNewMaterialValidationAndRotationToRuntimeCodec() throws Exception {
List<Integer> rotations=new ArrayList<>();
SchematicAssets dynamic=new SchematicAssets(root.resolve("dynamic"),(state,degrees)->{
rotations.add(degrees);
if(!state.startsWith("minecraft:cherry_trapdoor"))throw new IOException("Unknown runtime block");
return degrees==90?state.replace("facing=north","facing=east"):state;
});
String trapdoor="minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]";
var asset=dynamic.exportSnapshot("New palette",Map.of(ZERO,trapdoor),ZERO,5000);
assertEquals(trapdoor.replace("facing=north","facing=east"),dynamic.read(asset.assetId(),ZERO,90).get(ZERO));
assertTrue(rotations.contains(90));
assertThrows(IOException.class,()->dynamic.exportSnapshot("Invalid",Map.of(ZERO,"minecraft:unknown_block"),ZERO,5000));
}
@Test void metadataAndPlacementRejectFutureDataVersionOnTheActualRead() throws Exception {
SchematicAssets assets = new SchematicAssets(root);
@@ -0,0 +1,24 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.JsonParser;
import io.github.minecraftbuilder.core.TerrainRecipe;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
class TerrainPreviewTest {
@Test void previewIsBoundedNativePngAndReportsClippingWithoutClaimingWorldVerification() throws Exception {
var recipe=new TerrainRecipe(JsonParser.parseString("""
{"version":1,"min":{"x":0,"y":0,"z":0},"max":{"x":7,"y":7,"z":7},
"base_height":20,"seed":1,"mode":"sculpt","noise":{"amplitude":0,"scale":8},
"palette":{"rock":"minecraft:stone","soil":"minecraft:dirt","surface":"minecraft:grass_block","soil_depth":2},"features":[],"preserve":[]}
""").getAsJsonObject());
var result=TerrainPreview.render(recipe,32,4096);
assertEquals(false,result.get("world_verified"));assertEquals(64,result.get("clipped_samples"));assertEquals(1,result.get("tile_count"));
var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64"))));
assertEquals(16,image.getWidth());assertEquals(16,image.getHeight());
assertThrows(IllegalArgumentException.class,()->TerrainPreview.render(recipe,1024,4096));
}
}