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:
@@ -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); }
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user