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:
@@ -29,6 +29,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* No world writes occur before a durable intent, nor before the post-IO live recheck.
|
||||
*/
|
||||
public final class EditEngine {
|
||||
/** Bounds durable snapshot memory before any plan persistence or world mutation. */
|
||||
static final long MAX_PLAN_SNAPSHOT_BYTES = 8L * 1024 * 1024;
|
||||
private final WorldAccess world;
|
||||
private final BlockPolicy policy;
|
||||
private final ContextGuard guard;
|
||||
@@ -160,14 +162,19 @@ public final class EditEngine {
|
||||
for (BlockPos position : dependencyPositions) requireInside(region, position);
|
||||
List<Change> changes = new ArrayList<>();
|
||||
Map<BlockPos, String> captured = new HashMap<>();
|
||||
long snapshotBytes = 0;
|
||||
for (var entry : new TreeMap<>(desired).entrySet()) {
|
||||
String before = read(entry.getKey());
|
||||
requireSupported(before); captured.put(entry.getKey(), before);
|
||||
changes.add(new Change(entry.getKey(), before, entry.getValue()));
|
||||
String after = Objects.requireNonNull(world.prepareBlock(entry.getKey(), entry.getValue(), before));
|
||||
requireSupported(after);
|
||||
snapshotBytes = addSnapshotBytes(snapshotBytes, before, after);
|
||||
changes.add(new Change(entry.getKey(), before, after));
|
||||
}
|
||||
List<Plan.Dependency> dependencies = new ArrayList<>();
|
||||
for (BlockPos position : dependencyPositions.stream().sorted().toList()) {
|
||||
String before = captured.containsKey(position) ? captured.get(position) : read(position);
|
||||
snapshotBytes = addSnapshotBytes(snapshotBytes, before);
|
||||
requireSupported(before); dependencies.add(new Plan.Dependency(position, before));
|
||||
}
|
||||
Plan plan = new Plan(id, projectId, worldEpoch, region, changes, dependencies, now,
|
||||
@@ -321,7 +328,7 @@ public final class EditEngine {
|
||||
if (current.equals(change.desired())) { op.skipped++; op.cursor++; }
|
||||
else {
|
||||
try {
|
||||
world.setBlock(change.pos(), change.desired());
|
||||
world.setCapturedBlock(change.pos(), change.desired());
|
||||
String actual = read(change.pos());
|
||||
if (!actual.equals(change.desired())) {
|
||||
recovery(op, "Write result is uncertain at " + change.pos()); break;
|
||||
@@ -616,9 +623,17 @@ public final class EditEngine {
|
||||
if (op == null) throw new IllegalArgumentException("Unknown operation");
|
||||
return op;
|
||||
}
|
||||
private String read(BlockPos pos) { return Objects.requireNonNull(world.getBlock(pos), "World returned null state"); }
|
||||
private String read(BlockPos pos) { return Objects.requireNonNull(world.captureBlock(pos), "World returned null snapshot"); }
|
||||
private static long addSnapshotBytes(long used, String... values) {
|
||||
for (String value : values) {
|
||||
used += value.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (used > MAX_PLAN_SNAPSHOT_BYTES)
|
||||
throw new IllegalArgumentException("snapshot_budget_exceeded: split the edit into smaller plans (8 MiB snapshot limit)");
|
||||
}
|
||||
return used;
|
||||
}
|
||||
private void requireSupported(String state) {
|
||||
if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state: " + state);
|
||||
if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state or stored snapshot");
|
||||
}
|
||||
private static void requireInside(Region region, BlockPos pos) {
|
||||
if (pos == null || !region.contains(pos)) throw new IllegalArgumentException("Position outside authorized region: " + pos);
|
||||
@@ -632,11 +647,17 @@ public final class EditEngine {
|
||||
if (plan.changes().size() > limits.maxChanges() || plan.dependencies().size() > limits.maxReadDependencies())
|
||||
throw new IOException("Stored plan exceeds configured limits");
|
||||
Set<BlockPos> positions = new HashSet<>();
|
||||
long snapshotBytes = 0;
|
||||
for (Change change : plan.changes()) {
|
||||
if (!plan.region().contains(change.pos()) || !positions.add(change.pos()))
|
||||
throw new IOException("Stored plan contains invalid/duplicate positions");
|
||||
try { snapshotBytes = addSnapshotBytes(snapshotBytes, change.expected(), change.desired()); }
|
||||
catch (IllegalArgumentException e) { throw new IOException("Stored plan exceeds snapshot budget", e); }
|
||||
}
|
||||
for (Plan.Dependency dependency : plan.dependencies())
|
||||
for (Plan.Dependency dependency : plan.dependencies()) {
|
||||
if (!plan.region().contains(dependency.pos())) throw new IOException("Stored dependency is outside region");
|
||||
try { snapshotBytes = addSnapshotBytes(snapshotBytes, dependency.expected()); }
|
||||
catch (IllegalArgumentException e) { throw new IOException("Stored plan exceeds snapshot budget", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public final class RecipeCompiler {
|
||||
}
|
||||
private static String block(JsonObject object) {
|
||||
String block = string(object, "block");
|
||||
if (block.length() > 512 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?"))
|
||||
if (block.length() > 1024 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?"))
|
||||
throw new IllegalArgumentException("Expected a Minecraft block state string");
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Relative brush over one bounded immutable snapshot. Does not access or mutate a world. */
|
||||
public final class TerrainBrush {
|
||||
public record Spec(Region bounds, int centerX, int centerZ, int radius, String action,
|
||||
int amount, int targetHeight, double strength, double falloff, int smoothRadius,
|
||||
List<Region> preserve) {
|
||||
public Spec {
|
||||
Objects.requireNonNull(bounds);preserve=List.copyOf(preserve);
|
||||
if(bounds.volume()>4096 || bounds.volume()<1)throw bad("Brush scan window must contain at most 4096 cells");
|
||||
if(radius<1||radius>16||smoothRadius<1||smoothRadius>3)throw bad("Radius must be 1..16; smoothing radius 1..3");
|
||||
if(!Set.of("raise","lower","flatten","smooth").contains(action))throw bad("Unknown brush action");
|
||||
if(amount<1||amount>32||!Double.isFinite(strength)||strength<0||strength>1||!Double.isFinite(falloff)||falloff<0||falloff>1)throw bad("Invalid brush strength, falloff or amount");
|
||||
int halo=action.equals("smooth")?smoothRadius:0;
|
||||
if((long)centerX-radius-halo<bounds.min().x()||(long)centerX+radius+halo>bounds.max().x()
|
||||
||(long)centerZ-radius-halo<bounds.min().z()||(long)centerZ+radius+halo>bounds.max().z())throw bad("Scan window must include the whole brush and smoothing halo");
|
||||
if(preserve.size()>64)throw bad("At most 64 preserve boxes");
|
||||
for(Region p:preserve)if(!bounds.contains(p.min())||!bounds.contains(p.max()))throw bad("Preserve box exceeds scan window");
|
||||
}
|
||||
}
|
||||
public record Result(Map<BlockPos,String> desired, Set<BlockPos> dependencies, Region bounds,
|
||||
int[][] before, int[][] after, int changedColumns, int raisedBlocks, int loweredBlocks) { }
|
||||
private TerrainBrush() { }
|
||||
public static Spec parse(JsonObject j) {
|
||||
fields(j,"min","max","center","radius","action","amount","height","strength","falloff","smooth_radius","preserve");
|
||||
Region bounds=new Region("brush",pos(obj(j,"min")),pos(obj(j,"max")));
|
||||
JsonObject center=obj(j,"center");fields(center,"x","z");String action=text(j,"action");
|
||||
if((action.equals("raise")||action.equals("lower"))&&!j.has("amount"))throw bad("raise/lower require amount");
|
||||
if(action.equals("flatten")&&!j.has("height"))throw bad("flatten requires height");
|
||||
if(j.has("amount")&&!Set.of("raise","lower").contains(action))throw bad("amount is only for raise/lower");
|
||||
if(j.has("height")&&!action.equals("flatten"))throw bad("height is only for flatten");
|
||||
if(j.has("smooth_radius")&&!action.equals("smooth"))throw bad("smooth_radius is only for smooth");
|
||||
List<Region> preserves=new ArrayList<>();
|
||||
if(j.has("preserve")){
|
||||
if(!j.get("preserve").isJsonArray()||j.getAsJsonArray("preserve").size()>64)throw bad("Invalid preserve boxes");
|
||||
for(JsonElement e:j.getAsJsonArray("preserve")){if(!e.isJsonObject())throw bad("Invalid preserve box");JsonObject p=e.getAsJsonObject();fields(p,"min","max");preserves.add(new Region("brush",pos(obj(p,"min")),pos(obj(p,"max"))));}
|
||||
}
|
||||
return new Spec(bounds,integer(center,"x"),integer(center,"z"),integer(j,"radius"),action,
|
||||
j.has("amount")?integer(j,"amount"):1,j.has("height")?integer(j,"height"):0,
|
||||
j.has("strength")?number(j,"strength"):1,j.has("falloff")?number(j,"falloff"):0.5,
|
||||
j.has("smooth_radius")?integer(j,"smooth_radius"):1,preserves);
|
||||
}
|
||||
public static Result compile(Spec spec,Map<BlockPos,String> snapshot,int budget) {
|
||||
Region b=spec.bounds();if(snapshot.size()!=b.volume())throw bad("Snapshot must cover the entire scan window");
|
||||
int width=b.max().x()-b.min().x()+1,length=b.max().z()-b.min().z()+1;
|
||||
int[][] before=new int[length][width],after=new int[length][width];
|
||||
for(int z=b.min().z();z<=b.max().z();z++)for(int x=b.min().x();x<=b.max().x();x++){
|
||||
int top=Integer.MIN_VALUE;
|
||||
for(int y=b.max().y();y>=b.min().y();y--){
|
||||
String state=snapshot.get(new BlockPos(x,y,z));if(state==null)throw bad("Missing snapshot cell");
|
||||
if(!TerrainRecipe.replaceable(state))throw bad("Brush scan contains a non-terrain block at "+new BlockPos(x,y,z));
|
||||
if(!air(state)&&top==Integer.MIN_VALUE)top=y;
|
||||
}
|
||||
if(top==Integer.MIN_VALUE)throw bad("No terrain surface in column "+x+","+z+"; extend scan downward");
|
||||
if(top==b.max().y())throw bad("Surface touches scan ceiling at "+x+","+z+"; include air above terrain");
|
||||
before[z-b.min().z()][x-b.min().x()]=top;after[z-b.min().z()][x-b.min().x()]=top;
|
||||
}
|
||||
Map<BlockPos,String> desired=new LinkedHashMap<>();int columns=0,raised=0,lowered=0;
|
||||
for(int z=b.min().z();z<=b.max().z();z++)for(int x=b.min().x();x<=b.max().x();x++){
|
||||
double distance=Math.hypot((long)x-spec.centerX(),(long)z-spec.centerZ());
|
||||
if(distance>spec.radius())continue;
|
||||
double weight=weight(distance,spec.radius(),spec.falloff())*spec.strength();
|
||||
int old=before[z-b.min().z()][x-b.min().x()];double target;
|
||||
switch(spec.action()){
|
||||
case "raise" -> target=old+spec.amount();
|
||||
case "lower" -> target=old-spec.amount();
|
||||
case "flatten" -> target=spec.targetHeight();
|
||||
case "smooth" -> {
|
||||
long sum=0;int count=0,r=spec.smoothRadius();
|
||||
for(int dz=-r;dz<=r;dz++)for(int dx=-r;dx<=r;dx++){
|
||||
sum+=before[z+dz-b.min().z()][x+dx-b.min().x()];count++;
|
||||
}
|
||||
target=(double)sum/count;
|
||||
}
|
||||
default -> throw new AssertionError();
|
||||
}
|
||||
// Symmetric rounding of displacement: lowering and raising have equal strength.
|
||||
double delta=(target-old)*weight;int next=old+(int)(Math.copySign(Math.floor(Math.abs(delta)+0.5),delta));
|
||||
if(next==old)continue;
|
||||
if(next<b.min().y()||next>=b.max().y())throw bad("Target surface leaves scan window; extend vertical bounds (keep air above)");
|
||||
int from=Math.min(old,next),to=Math.max(old,next);
|
||||
boolean protectedColumn=false;
|
||||
for(int y=from;y<=to;y++){BlockPos at=new BlockPos(x,y,z);if(spec.preserve().stream().anyMatch(p->p.contains(at))){protectedColumn=true;break;}}
|
||||
if(protectedColumn)continue; // Preserve the whole edit column instead of tearing a hole in the terrain.
|
||||
String top=snapshot.get(new BlockPos(x,old,z));
|
||||
if(next>old){
|
||||
String under=snapshot.get(new BlockPos(x,old-1,z));
|
||||
String fill=under!=null&&!air(under)?under:subsoil(top);
|
||||
// Move the top surface up, using existing subsoil rather than burying grass layers.
|
||||
for(int y=old;y<next;y++)desired.put(new BlockPos(x,y,z),fill);
|
||||
desired.put(new BlockPos(x,next,z),top);raised+=next-old;
|
||||
}else{
|
||||
// Do not fill a cave when cutting: a new cap must sit on existing solid terrain.
|
||||
if(air(snapshot.get(new BlockPos(x,next,z))))throw bad("Cut would expose a void; choose a shallower cut or another area");
|
||||
for(int y=next+1;y<=old;y++)desired.put(new BlockPos(x,y,z),"minecraft:air");
|
||||
desired.put(new BlockPos(x,next,z),top);lowered+=old-next;
|
||||
}
|
||||
after[z-b.min().z()][x-b.min().x()]=next;columns++;
|
||||
}
|
||||
desired.entrySet().removeIf(e->e.getValue().equals(snapshot.get(e.getKey())));
|
||||
if(desired.size()>budget)throw bad("Brush exceeds plan budget; use a smaller radius or amount");
|
||||
return new Result(Collections.unmodifiableMap(desired),Set.copyOf(snapshot.keySet()),b,before,after,columns,raised,lowered);
|
||||
}
|
||||
private static boolean air(String s){return s!=null&&Set.of("minecraft:air","minecraft:cave_air","minecraft:void_air").contains(s);}
|
||||
private static String subsoil(String top){return top.startsWith("minecraft:grass_block")||top.equals("minecraft:moss_block")?"minecraft:dirt":top;}
|
||||
private static double weight(double d,int radius,double falloff){if(falloff==0)return 1;double core=radius*(1-falloff);if(d<=core)return 1;double t=Math.min(1,(d-core)/(radius-core));return 1-t*t*(3-2*t);}
|
||||
private static IllegalArgumentException bad(String m){return new IllegalArgumentException(m);}
|
||||
private static JsonObject obj(JsonObject j,String k){if(j==null||!j.has(k)||!j.get(k).isJsonObject())throw bad("Expected object: "+k);return j.getAsJsonObject(k);}
|
||||
private static String text(JsonObject j,String k){if(!j.has(k)||!j.get(k).isJsonPrimitive()||!j.getAsJsonPrimitive(k).isString())throw bad("Expected text: "+k);return j.get(k).getAsString();}
|
||||
private static double number(JsonObject j,String k){if(!j.has(k)||!j.get(k).isJsonPrimitive()||!j.getAsJsonPrimitive(k).isNumber())throw bad("Expected number: "+k);double n=j.get(k).getAsDouble();if(!Double.isFinite(n))throw bad("Nonfinite number");return n;}
|
||||
private static int integer(JsonObject j,String k){number(j,k);try{return j.get(k).getAsBigDecimal().intValueExact();}catch(Exception e){throw bad("Expected 32-bit integer: "+k);}}
|
||||
private static BlockPos pos(JsonObject j){fields(j,"x","y","z");int x=integer(j,"x"),y=integer(j,"y"),z=integer(j,"z");if(Math.abs((long)x)>30_000_000||Math.abs((long)z)>30_000_000||y< -4096||y>4096)throw bad("Coordinate out of range");return new BlockPos(x,y,z);}
|
||||
private static void fields(JsonObject j,String... keys){if(j==null)throw bad("Expected brush object");Set<String> allowed=Set.of(keys);for(String k:j.keySet())if(!allowed.contains(k))throw bad("Unknown brush field: "+k);}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Pure world-coordinate height field. Tiling never changes noise, features or surface layers. */
|
||||
public final class TerrainRecipe {
|
||||
public static final Set<String> MATERIALS = Set.of("stone", "andesite", "granite", "diorite",
|
||||
"deepslate", "cobbled_deepslate", "dirt", "grass_block", "moss_block", "sandstone", "terracotta");
|
||||
private final Region bounds;
|
||||
private final int base, seed, depth;
|
||||
private final double amplitude, scale;
|
||||
private final String mode, rock, soil, surface, id;
|
||||
private final List<Feature> features;
|
||||
private final List<Region> preserves;
|
||||
private record Point(double x, double z) { }
|
||||
private record Feature(String type, Point a, Point b, double radius, double falloff,
|
||||
double height, List<Point> points) { }
|
||||
public record Tile(Region bounds, Map<BlockPos,String> blocks) { }
|
||||
|
||||
public TerrainRecipe(JsonObject json) {
|
||||
fields(json,"version","min","max","base_height","seed","noise","mode","palette","features","preserve");
|
||||
if (integer(json,"version",1,1)!=1) throw invalid("Unsupported terrain version");
|
||||
bounds=new Region("terrain",pos(object(json,"min")),pos(object(json,"max")));
|
||||
if (width()>2048 || length()>2048 || height()>384 || (long)width()*length()>1_048_576
|
||||
|| bounds.volume()>134_217_728) throw invalid("Terrain envelope exceeds 2048 per side, 384 height, 1M columns or 128M voxels");
|
||||
base=integer(json,"base_height",-4096,4096); seed=integer(json,"seed",Integer.MIN_VALUE,Integer.MAX_VALUE);
|
||||
mode=text(json,"mode"); if(!Set.of("sculpt","fill","cut").contains(mode))throw invalid("Mode must be sculpt, fill or cut");
|
||||
JsonObject noise=object(json,"noise");fields(noise,"amplitude","scale");
|
||||
amplitude=number(noise,"amplitude",0,256);scale=number(noise,"scale",1,4096);
|
||||
JsonObject palette=object(json,"palette");fields(palette,"rock","soil","surface","soil_depth");
|
||||
rock=material(palette,"rock");soil=material(palette,"soil");surface=material(palette,"surface");depth=integer(palette,"soil_depth",0,16);
|
||||
List<Feature> parsed=new ArrayList<>();
|
||||
for(JsonElement e:array(json,"features",64)) {
|
||||
if(!e.isJsonObject())throw invalid("Feature must be an object");
|
||||
JsonObject f=e.getAsJsonObject();String type=text(f,"type");
|
||||
switch(type) {
|
||||
case "hill", "basin" -> {
|
||||
fields(f,"type","center","radius","height","falloff");
|
||||
Point c=point(object(f,"center"));double r=number(f,"radius",1,2048);
|
||||
parsed.add(new Feature(type,c,null,r,number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.of()));
|
||||
}
|
||||
case "plateau" -> {
|
||||
fields(f,"type","min","max","height","falloff");Point a=point(object(f,"min")),b=point(object(f,"max"));
|
||||
if(a.x>b.x||a.z>b.z)throw invalid("Invalid plateau bounds");
|
||||
parsed.add(new Feature(type,a,b,0,number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.of()));
|
||||
}
|
||||
case "ridge", "channel" -> {
|
||||
fields(f,"type","points","width","falloff","height");List<Point> points=new ArrayList<>();
|
||||
for(JsonElement p:array(f,"points",32)) {if(!p.isJsonObject())throw invalid("Expected path point");points.add(point(p.getAsJsonObject()));}
|
||||
if(points.size()<2)throw invalid("A path needs 2..32 points");
|
||||
parsed.add(new Feature(type,null,null,number(f,"width",1,1024),number(f,"falloff",1,2048),number(f,"height",-4096,4096),List.copyOf(points)));
|
||||
}
|
||||
case "terrace" -> {
|
||||
fields(f,"type","step","strength");
|
||||
parsed.add(new Feature(type,null,null,number(f,"step",1,128),0,number(f,"strength",0,1),List.of()));
|
||||
}
|
||||
default -> throw invalid("Unsupported terrain feature: "+type);
|
||||
}
|
||||
}
|
||||
features=List.copyOf(parsed);List<Region> excluded=new ArrayList<>();
|
||||
for(JsonElement e:array(json,"preserve",64)) {
|
||||
if(!e.isJsonObject())throw invalid("Preserve box must be an object");JsonObject box=e.getAsJsonObject();fields(box,"min","max");
|
||||
Region r=new Region("terrain",pos(object(box,"min")),pos(object(box,"max")));
|
||||
if(!bounds.contains(r.min())||!bounds.contains(r.max()))throw invalid("Preserve box exceeds envelope");excluded.add(r);
|
||||
}
|
||||
preserves=List.copyOf(excluded);
|
||||
try { id=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(json).toString().getBytes(StandardCharsets.UTF_8))); }
|
||||
catch(java.security.NoSuchAlgorithmException e){throw new AssertionError(e);}
|
||||
}
|
||||
public Region bounds(){return bounds;}
|
||||
public String id(){return id;}
|
||||
public String mode(){return mode;}
|
||||
public int width(){return bounds.max().x()-bounds.min().x()+1;}
|
||||
public int length(){return bounds.max().z()-bounds.min().z()+1;}
|
||||
public int height(){return bounds.max().y()-bounds.min().y()+1;}
|
||||
public boolean preserved(BlockPos at){return preserves.stream().anyMatch(r->r.contains(at));}
|
||||
public static boolean replaceable(String state) {
|
||||
String name=state.split("\\[",2)[0];return name.equals("minecraft:air") || name.equals("minecraft:cave_air")
|
||||
|| name.equals("minecraft:void_air") || (name.startsWith("minecraft:") && MATERIALS.contains(name.substring(10)));
|
||||
}
|
||||
/** Unclamped surface. Layers use this height even across vertical tile boundaries. */
|
||||
public int surfaceHeight(int x,int z) {
|
||||
double h=base+amplitude*(noise(x/scale,z/scale)+0.5*noise(x/(scale/2),z/(scale/2))+0.25*noise(x/(scale/4),z/(scale/4)))/1.75;
|
||||
for(Feature f:features) {
|
||||
if(f.type.equals("terrace")){h=lerp(h,Math.floor(h/f.radius)*f.radius,f.height);continue;}
|
||||
double d;
|
||||
if(f.type.equals("plateau"))d=Math.hypot(Math.max(Math.max(f.a.x-x,0),x-f.b.x),Math.max(Math.max(f.a.z-z,0),z-f.b.z));
|
||||
else if(f.type.equals("hill")||f.type.equals("basin"))d=Math.max(0,Math.hypot(x-f.a.x,z-f.a.z)-f.radius);
|
||||
else {d=Double.POSITIVE_INFINITY;for(int i=1;i<f.points.size();i++)d=Math.min(d,segment(x,z,f.points.get(i-1),f.points.get(i)));d=Math.max(0,d-f.radius);}
|
||||
double w=1-smooth(Math.min(1,d/f.falloff));
|
||||
switch(f.type){
|
||||
case "hill","ridge" -> h+=f.height*w;
|
||||
case "basin","channel" -> h=lerp(h,Math.min(h,f.height),w);
|
||||
case "plateau" -> h=lerp(h,f.height,w);
|
||||
default -> throw new AssertionError(f.type);
|
||||
}
|
||||
}
|
||||
return (int)Math.floor(h);
|
||||
}
|
||||
private double noise(double x,double z){int ix=(int)Math.floor(x),iz=(int)Math.floor(z);return lerp(lerp(hash(ix,iz),hash(ix+1,iz),smooth(x-ix)),lerp(hash(ix,iz+1),hash(ix+1,iz+1),smooth(x-ix)),smooth(z-iz));}
|
||||
private double hash(int x,int z){long v=seed^((long)x*0x9E3779B97F4A7C15L)^((long)z*0xC2B2AE3D27D4EB4FL);v=(v^(v>>>30))*0xBF58476D1CE4E5B9L;v=(v^(v>>>27))*0x94D049BB133111EBL;v^=v>>>31;return (v>>>11)*0x1.0p-53*2-1;}
|
||||
private static double segment(double x,double z,Point a,Point b){double dx=b.x-a.x,dz=b.z-a.z,l=dx*dx+dz*dz;double t=l==0?0:Math.max(0,Math.min(1,((x-a.x)*dx+(z-a.z)*dz)/l));return Math.hypot(x-a.x-t*dx,z-a.z-t*dz);}
|
||||
private static double smooth(double t){return t*t*(3-2*t);}
|
||||
private static double lerp(double a,double b,double t){return a+(b-a)*t;}
|
||||
public int tileEdge(int limit){if(limit<1||limit>4096)throw invalid("Tile budget must be 1..4096");int e=1;while((e+1)*(e+1)*(e+1)<=limit&&e<16)e++;return e;}
|
||||
private static int ceil(int n,int d){return (n+d-1)/d;}
|
||||
public int tileCount(int limit){int e=tileEdge(limit);return Math.multiplyExact(Math.multiplyExact(ceil(width(),e),ceil(length(),e)),ceil(height(),e));}
|
||||
/** Tile index order: X fastest, then Z, then Y. Envelope minimum is the stable tiling origin. */
|
||||
public Tile tile(int index,int limit){
|
||||
int edge=tileEdge(limit),nx=ceil(width(),edge),nz=ceil(length(),edge);
|
||||
if(index<0||index>=tileCount(limit))throw invalid("Tile index outside terrain");
|
||||
BlockPos min=bounds.min().add(new BlockPos(index%nx*edge,index/(nx*nz)*edge,index/nx%nz*edge));
|
||||
BlockPos max=new BlockPos(Math.min(min.x()+edge-1,bounds.max().x()),Math.min(min.y()+edge-1,bounds.max().y()),Math.min(min.z()+edge-1,bounds.max().z()));
|
||||
Map<BlockPos,String> desired=new LinkedHashMap<>();
|
||||
for(int z=min.z();z<=max.z();z++)for(int x=min.x();x<=max.x();x++){
|
||||
int h=surfaceHeight(x,z);
|
||||
for(int y=min.y();y<=max.y();y++){
|
||||
if(mode.equals("fill")&&y>h || mode.equals("cut")&&y<=h)continue;
|
||||
BlockPos at=new BlockPos(x,y,z);if(preserved(at))continue;
|
||||
desired.put(at,y>h?"minecraft:air":y==h?surface:y>=h-depth?soil:rock);
|
||||
}
|
||||
}
|
||||
return new Tile(new Region("terrain",min,max),Collections.unmodifiableMap(desired));
|
||||
}
|
||||
private static String material(JsonObject o,String key){String s=text(o,key);if(!s.startsWith("minecraft:")||!MATERIALS.contains(s.substring(10)))throw invalid("Unsupported terrain material: "+s);return s;}
|
||||
private static Point point(JsonObject o){fields(o,"x","z");return new Point(integer(o,"x",-30_000_000,30_000_000),integer(o,"z",-30_000_000,30_000_000));}
|
||||
private static BlockPos pos(JsonObject o){fields(o,"x","y","z");return new BlockPos(integer(o,"x",-30_000_000,30_000_000),integer(o,"y",-4096,4096),integer(o,"z",-30_000_000,30_000_000));}
|
||||
private static JsonElement required(JsonObject o,String k){if(o==null||!o.has(k)||o.get(k).isJsonNull())throw invalid("Missing field: "+k);return o.get(k);}
|
||||
private static JsonObject object(JsonObject o,String k){JsonElement e=required(o,k);if(!e.isJsonObject())throw invalid(k+" must be an object");return e.getAsJsonObject();}
|
||||
private static JsonArray array(JsonObject o,String k,int max){JsonElement e=required(o,k);if(!e.isJsonArray()||e.getAsJsonArray().size()>max)throw invalid(k+" must be an array of at most "+max);return e.getAsJsonArray();}
|
||||
private static String text(JsonObject o,String k){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isString())throw invalid(k+" must be text");return e.getAsString();}
|
||||
private static double number(JsonObject o,String k,double min,double max){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isNumber())throw invalid(k+" must be numeric");double v=e.getAsDouble();if(!Double.isFinite(v)||v<min||v>max)throw invalid(k+" out of range");return v;}
|
||||
private static int integer(JsonObject o,String k,int min,int max){JsonElement e=required(o,k);if(!e.isJsonPrimitive()||!e.getAsJsonPrimitive().isNumber()||!e.getAsString().matches("-?(0|[1-9][0-9]*)"))throw invalid(k+" must be an integer");double v=number(o,k,min,max);return (int)v;}
|
||||
private static void fields(JsonObject o,String... keys){if(o==null)throw invalid("Expected object");Set<String> allowed=Set.of(keys);for(String k:o.keySet())if(!allowed.contains(k))throw invalid("Unknown field: "+k);}
|
||||
private static IllegalArgumentException invalid(String message){return new IllegalArgumentException(message);}
|
||||
private static JsonElement canonical(JsonElement e){if(e.isJsonObject()){JsonObject r=new JsonObject();new TreeSet<>(e.getAsJsonObject().keySet()).forEach(k->r.add(k,canonical(e.getAsJsonObject().get(k))));return r;}if(e.isJsonArray()){JsonArray a=new JsonArray();e.getAsJsonArray().forEach(v->a.add(canonical(v)));return a;}return e.deepCopy();}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ package io.github.minecraftbuilder.core;
|
||||
/** The adapter must call all engine methods that touch this interface on its world thread. */
|
||||
public interface WorldAccess {
|
||||
String getBlock(BlockPos position);
|
||||
/** Apply canonical state without physics; unsupported side effects must be excluded by BlockPolicy. */
|
||||
/** Public canonical block data; callers cannot supply private captured payloads through this method. */
|
||||
void setBlock(BlockPos position, String canonicalState);
|
||||
|
||||
/** Durable comparison/undo value, including private block-entity data when the adapter supports it. */
|
||||
default String captureBlock(BlockPos position) { return getBlock(position); }
|
||||
|
||||
/** Resolve the desired durable value without mutating the world. Captured undo values stay exact. */
|
||||
default String prepareBlock(BlockPos position, String desired, String capturedBefore) { return desired; }
|
||||
|
||||
/** Restore a durable captured value. Must suppress immediate drop/removal side effects. */
|
||||
default void setCapturedBlock(BlockPos position, String capturedState) { setBlock(position, capturedState); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static io.github.minecraftbuilder.core.EditEngineTest.*;
|
||||
|
||||
/** Exercises the adapter snapshot contract through the actual durable journal and edit state machine. */
|
||||
final class BlockEntityJournalTest {
|
||||
@TempDir Path directory;
|
||||
private static final String CHEST = "minecraft:chest[facing=north]";
|
||||
private static final String ROTATED = "minecraft:chest[facing=east]";
|
||||
private static final String ITEMS = "|private inventory: diamond sword, signed book, custom plugin data";
|
||||
static final class EntityWorld implements WorldAccess {
|
||||
final Map<BlockPos,String> values = new HashMap<>();
|
||||
int writes;
|
||||
public String getBlock(BlockPos p) { return state(captureBlock(p)); }
|
||||
public String captureBlock(BlockPos p) { return values.getOrDefault(p, AIR); }
|
||||
public String prepareBlock(BlockPos p, String desired, String before) {
|
||||
if (desired.contains("|")) return desired;
|
||||
if (desired.startsWith("minecraft:chest"))
|
||||
return desired + (before.contains("|") ? before.substring(before.indexOf('|')) : "|empty inventory");
|
||||
return desired;
|
||||
}
|
||||
public void setBlock(BlockPos p, String desired) { fail("The editor must restore the complete captured value"); }
|
||||
public void setCapturedBlock(BlockPos p, String desired) { values.put(p, desired); writes++; }
|
||||
private static String state(String value) { return value.split("\\|", 2)[0]; }
|
||||
}
|
||||
private EditEngine engine(EntityWorld world, Journal journal) throws Exception {
|
||||
return new EditEngine(world, state -> state.startsWith("minecraft:"), plan -> {}, journal, LIMITS);
|
||||
}
|
||||
@Test void overwriteAndUndoAfterRestartRestoresPrivateDataFromDurableJournal() throws Exception {
|
||||
EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS);
|
||||
var journal = new JsonJournal(directory);
|
||||
EditEngine original = engine(world, journal);
|
||||
Plan plan = prepare(original, Map.of(A, STONE));
|
||||
assertEquals(CHEST + ITEMS, plan.changes().get(0).expected());
|
||||
assertEquals(0, world.writes, "prepare must not temporarily place a default block");
|
||||
String id = start(original, plan);
|
||||
assertEquals(OperationStatus.APPLIED, finish(original, id).status());
|
||||
assertEquals(STONE, world.captureBlock(A));
|
||||
|
||||
EditEngine restarted = engine(world, new JsonJournal(directory));
|
||||
Plan undo = restarted.prepareUndo(id); restarted.persistPlan(undo.id());
|
||||
assertEquals(CHEST + ITEMS, undo.changes().get(0).desired());
|
||||
assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, undo)).status());
|
||||
assertEquals(CHEST + ITEMS, world.captureBlock(A));
|
||||
}
|
||||
@Test void blockDataChangeRetainsContentsAndUndoDetectsManualInventoryChanges() throws Exception {
|
||||
EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS);
|
||||
EditEngine editor = engine(world, new JsonJournal(directory));
|
||||
Plan plan = prepare(editor, Map.of(A, ROTATED));
|
||||
assertEquals(ROTATED + ITEMS, plan.changes().get(0).desired());
|
||||
String id = start(editor, plan);
|
||||
assertEquals(OperationStatus.APPLIED, finish(editor, id).status());
|
||||
world.values.put(A, ROTATED + "|player deposited a diamond");
|
||||
assertEquals(ROTATED, world.getBlock(A), "visible BlockData did not change");
|
||||
assertThrows(IllegalStateException.class, () -> editor.prepareUndo(id));
|
||||
assertEquals(1, world.writes);
|
||||
}
|
||||
@Test void inventoryEditDuringJournalIoStopsBeforeAnyWrite() throws Exception {
|
||||
EntityWorld world = new EntityWorld(); world.values.put(A, CHEST + ITEMS);
|
||||
EditEngine editor = engine(world, new JsonJournal(directory));
|
||||
Plan plan = prepare(editor, Map.of(A, STONE)); String id = start(editor, plan);
|
||||
SliceIntent intent = nextIntent(editor, id); editor.persistIntent(intent);
|
||||
world.values.put(A, CHEST + "|items moved by a hopper");
|
||||
assertEquals(OperationStatus.CONFLICT, editor.commitSlice(intent).status());
|
||||
assertEquals(0, world.writes);
|
||||
}
|
||||
@Test void planBudgetStopsPreparationBeforePersistenceOrWorldMutation() throws Exception {
|
||||
EntityWorld world = new EntityWorld();
|
||||
String large = CHEST + "|" + "x".repeat(1024 * 1024);
|
||||
Map<BlockPos,String> desired = new HashMap<>();
|
||||
for (int x = 0; x < 9; x++) { var p = new BlockPos(x, 0, 0); world.values.put(p, large); desired.put(p, STONE); }
|
||||
var journal = new MemoryJournal(); EditEngine editor = engine(world, journal);
|
||||
var error = assertThrows(IllegalArgumentException.class,
|
||||
() -> editor.prepare("project", "epoch", REGION, desired, Set.of()));
|
||||
assertTrue(error.getMessage().contains("snapshot_budget_exceeded"));
|
||||
assertEquals(0, world.writes); assertTrue(journal.plans.isEmpty());
|
||||
}
|
||||
@Test void plainLegacyJournalStillAppliesAndUndoesWithSnapshotAwareAdapter() throws Exception {
|
||||
EntityWorld world = new EntityWorld();
|
||||
EditEngine editor = engine(world, new JsonJournal(directory));
|
||||
String id = start(editor, prepare(editor, Map.of(A, STONE)));
|
||||
assertEquals(OperationStatus.APPLIED, finish(editor, id).status());
|
||||
EditEngine restarted = engine(world, new JsonJournal(directory));
|
||||
Plan undo = restarted.prepareUndo(id); restarted.persistPlan(undo.id());
|
||||
assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, undo)).status());
|
||||
assertEquals(AIR, world.captureBlock(A));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TerrainBrushTest {
|
||||
static final Region BOX=new Region("world",new BlockPos(-4,-4,-4),new BlockPos(4,10,4));
|
||||
static TerrainBrush.Spec spec(String action,int amount,int height,double strength,double falloff){return new TerrainBrush.Spec(BOX,0,0,3,action,amount,height,strength,falloff,1,List.of());}
|
||||
static Map<BlockPos,String> snapshot(IntBinaryOperator height){Map<BlockPos,String> map=new LinkedHashMap<>();for(int y=-4;y<=10;y++)for(int z=-4;z<=4;z++)for(int x=-4;x<=4;x++){
|
||||
int h=height.applyAsInt(x,z);map.put(new BlockPos(x,y,z),y>h?"minecraft:air":y==h?"minecraft:grass_block[snowy=false]":y>=h-2?"minecraft:dirt":"minecraft:stone");
|
||||
}return map;}
|
||||
@Test void raiseAndLowerAreRelativeToEachOriginalColumnAndMoveSurface(){
|
||||
var snap=snapshot((x,z)->x/2+2);var up=TerrainBrush.compile(spec("raise",2,0,1,0),snap,4096);
|
||||
var down=TerrainBrush.compile(spec("lower",2,0,1,0),snap,4096);
|
||||
for(int z=-3;z<=3;z++)for(int x=-3;x<=3;x++)if(x*x+z*z<=9){
|
||||
int h=x/2+2;assertEquals(h+2,up.after()[z+4][x+4]);assertEquals(h-2,down.after()[z+4][x+4]);
|
||||
assertEquals("minecraft:grass_block[snowy=false]",up.desired().get(new BlockPos(x,h+2,z)));
|
||||
assertEquals("minecraft:dirt",up.desired().get(new BlockPos(x,h,z)));
|
||||
assertEquals("minecraft:air",down.desired().get(new BlockPos(x,h,z)));
|
||||
}
|
||||
assertEquals(snap.keySet(),up.dependencies());assertEquals(1215,up.dependencies().size());
|
||||
}
|
||||
@Test void softFalloffAndStrengthAreSymmetricAndLeaveEdgeUntouched(){
|
||||
var snap=snapshot((x,z)->3);var up=TerrainBrush.compile(spec("raise",4,0,.5,1),snap,4096);var down=TerrainBrush.compile(spec("lower",4,0,.5,1),snap,4096);
|
||||
assertEquals(5,up.after()[4][4]);assertEquals(1,down.after()[4][4]);assertEquals(3,up.after()[4][7]);
|
||||
for(int z=0;z<9;z++)for(int x=0;x<9;x++)assertEquals(up.after()[z][x]-3,3-down.after()[z][x]);
|
||||
assertTrue(TerrainBrush.compile(spec("raise",4,0,0,.5),snap,4096).desired().isEmpty());
|
||||
}
|
||||
@Test void flattenUsesAbsoluteLevelAndSmoothUsesImmutableHalo(){
|
||||
var snap=snapshot((x,z)->x==0&&z==0?8:2);
|
||||
var flat=TerrainBrush.compile(spec("flatten",1,4,1,0),snap,4096);assertEquals(4,flat.after()[4][4]);assertEquals(4,flat.after()[4][5]);
|
||||
var smooth=TerrainBrush.compile(spec("smooth",1,0,1,0),snap,4096);
|
||||
assertEquals(3,smooth.after()[4][4]);assertEquals(3,smooth.after()[4][5]);assertEquals(3,smooth.after()[4][3]);
|
||||
assertEquals(2,smooth.after()[4][6]);
|
||||
var reversed=new LinkedHashMap<BlockPos,String>();var entries=new ArrayList<>(snap.entrySet());Collections.reverse(entries);entries.forEach(e->reversed.put(e.getKey(),e.getValue()));
|
||||
assertEquals(smooth.desired(),TerrainBrush.compile(spec("smooth",1,0,1,0),reversed,4096).desired());
|
||||
}
|
||||
@Test void preserveSkipsTheWholeAffectedColumn(){
|
||||
var b=spec("raise",2,0,1,0);var s=new TerrainBrush.Spec(BOX,0,0,3,"raise",2,0,1,0,1,List.of(new Region("world",new BlockPos(0,3,0),new BlockPos(0,3,0))));
|
||||
var result=TerrainBrush.compile(s,snapshot((x,z)->2),4096);
|
||||
assertEquals(2,result.after()[4][4]);assertFalse(result.desired().keySet().stream().anyMatch(p->p.x()==0&&p.z()==0));assertEquals(4,result.after()[4][5]);
|
||||
}
|
||||
@Test void refusesAmbiguousSurfacesStructuresFluidsVoidsAndClipping(){
|
||||
for(String state:List.of("minecraft:gold_block","minecraft:water","minecraft:bedrock","minecraft:oak_log")){
|
||||
var snap=snapshot((x,z)->2);snap.put(new BlockPos(0,2,0),state);assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snap,4096));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snapshot((x,z)->10),4096));
|
||||
assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",1,0,1,0),snapshot((x,z)->-5),4096));
|
||||
assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",20,0,1,0),snapshot((x,z)->2),4096));
|
||||
var cave=snapshot((x,z)->2);cave.put(new BlockPos(0,0,0),"minecraft:cave_air");assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("lower",2,0,1,0),cave,4096));
|
||||
assertThrows(IllegalArgumentException.class,()->TerrainBrush.compile(spec("raise",2,0,1,0),snapshot((x,z)->2),1));
|
||||
}
|
||||
@Test void validatesHaloScanBudgetAndActionSpecificFields(){
|
||||
assertThrows(IllegalArgumentException.class,()->new TerrainBrush.Spec(BOX,0,0,4,"smooth",1,0,1,0,1,List.of()));
|
||||
assertThrows(IllegalArgumentException.class,()->new TerrainBrush.Spec(new Region("w",new BlockPos(-20,-20,-20),new BlockPos(20,20,20)),0,0,3,"raise",1,0,1,0,1,List.of()));
|
||||
String base="\"min\":{\"x\":-4,\"y\":-4,\"z\":-4},\"max\":{\"x\":4,\"y\":10,\"z\":4},\"center\":{\"x\":0,\"z\":0},\"radius\":3,";
|
||||
for(String tail:List.of("\"action\":\"raise\"","\"action\":\"flatten\"","\"action\":\"smooth\",\"amount\":2","\"action\":\"raise\",\"amount\":2,\"height\":3","\"action\":\"raise\",\"amount\":2,\"script\":\"bad\""))assertThrows(IllegalArgumentException.class,()->TerrainBrush.parse(JsonParser.parseString("{"+base+tail+"}").getAsJsonObject()));
|
||||
}
|
||||
@Test void realEngineAppliesAndUndoesBrushWithAllSnapshotDependencies() throws Exception {
|
||||
var original=snapshot((x,z)->2);var world=new EditEngineTest.MemoryWorld();world.blocks.putAll(original);
|
||||
var engine=new EditEngine(world,s->true,p->{},new EditEngineTest.MemoryJournal(),new Limits(4096,4096,128,1_000_000_000,600000,8));
|
||||
var brush=TerrainBrush.compile(spec("raise",2,0,1,.5),original,4096);
|
||||
var plan=engine.prepare("project","epoch",BOX,brush.desired(),brush.dependencies());engine.persistPlan(plan.id());
|
||||
String id=EditEngineTest.start(engine,plan);assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,id).status());
|
||||
brush.desired().forEach((p,s)->assertEquals(s,world.getBlock(p)));
|
||||
var undo=engine.prepareUndo(id);engine.persistPlan(undo.id());assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,EditEngineTest.start(engine,undo)).status());assertEquals(original,world.blocks);
|
||||
}
|
||||
@Test void haloEditDuringJournalIoStopsBeforeAnyBrushWrite() throws Exception {
|
||||
var original=snapshot((x,z)->2);var world=new EditEngineTest.MemoryWorld();world.blocks.putAll(original);
|
||||
var engine=new EditEngine(world,s->true,p->{},new EditEngineTest.MemoryJournal(),new Limits(4096,4096,128,1_000_000_000,600000,8));
|
||||
var brush=TerrainBrush.compile(spec("smooth",1,0,1,0),snapshot((x,z)->x==0&&z==0?8:2),4096);
|
||||
// Engine must capture the exact same source that produced the smoothing result.
|
||||
world.blocks.clear();world.blocks.putAll(snapshot((x,z)->x==0&&z==0?8:2));
|
||||
var plan=engine.prepare("project","epoch",BOX,brush.desired(),brush.dependencies());engine.persistPlan(plan.id());String id=EditEngineTest.start(engine,plan);
|
||||
var intent=EditEngineTest.nextIntent(engine,id);engine.persistIntent(intent);
|
||||
BlockPos halo=new BlockPos(4,3,0);assertFalse(brush.desired().containsKey(halo));world.blocks.put(halo,"minecraft:gold_block");
|
||||
assertEquals(OperationStatus.CONFLICT,engine.commitSlice(intent).status());assertEquals(0,world.writes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package io.github.minecraftbuilder.core;
|
||||
|
||||
import com.google.gson.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TerrainRecipeTest {
|
||||
static JsonObject json(){return JsonParser.parseString("""
|
||||
{"version":1,"min":{"x":-17,"y":-4,"z":-17},"max":{"x":17,"y":20,"z":17},
|
||||
"base_height":5,"seed":123,"mode":"sculpt","noise":{"amplitude":0,"scale":24},
|
||||
"palette":{"rock":"minecraft:stone","soil":"minecraft:dirt","surface":"minecraft:grass_block","soil_depth":2},
|
||||
"features":[],"preserve":[]}
|
||||
""").getAsJsonObject();}
|
||||
static TerrainRecipe feature(String features){var j=json();j.add("features",JsonParser.parseString(features));return new TerrainRecipe(j);}
|
||||
static Map<BlockPos,String> all(TerrainRecipe r,int budget){Map<BlockPos,String> result=new HashMap<>();for(int i=0;i<r.tileCount(budget);i++){
|
||||
var tile=r.tile(i,budget);assertTrue(tile.bounds().volume()<=budget);assertTrue(tile.blocks().size()<=budget);
|
||||
tile.blocks().forEach((p,s)->{assertTrue(r.bounds().contains(p));assertNull(result.put(p,s),"overlapping tile at "+p);});
|
||||
}return result;}
|
||||
@Test void tilingHasNoGapsOverlapsOrSeamsIncludingNegativeCoordinatesAndVerticalLayers(){
|
||||
var j=json();j.getAsJsonObject("noise").addProperty("amplitude",4);var r=new TerrainRecipe(j);
|
||||
var a=all(r,4096);var b=all(r,512);assertEquals(r.bounds().volume(),a.size());assertEquals(a,b);
|
||||
for(int z=-17;z<=17;z++)for(int x=-17;x<=17;x++){
|
||||
int h=r.surfaceHeight(x,z);assertEquals("minecraft:grass_block",a.get(new BlockPos(x,h,z)));
|
||||
assertEquals("minecraft:dirt",a.get(new BlockPos(x,h-2,z)));assertEquals("minecraft:stone",a.get(new BlockPos(x,h-3,z)));
|
||||
assertEquals("minecraft:air",a.get(new BlockPos(x,h+1,z)));
|
||||
}
|
||||
}
|
||||
@Test void deterministicSeedAndWorldCoordinatesIndependentOfEnvelope(){
|
||||
var j=json();j.getAsJsonObject("noise").addProperty("amplitude",20);var a=new TerrainRecipe(j);var b=new TerrainRecipe(j.deepCopy());
|
||||
assertEquals(a.id(),b.id());j.getAsJsonObject("min").addProperty("x",-9);var cropped=new TerrainRecipe(j);
|
||||
for(int z=-9;z<=9;z++)for(int x=-9;x<=9;x++)assertEquals(a.surfaceHeight(x,z),cropped.surfaceHeight(x,z));
|
||||
j.addProperty("seed",124);var other=new TerrainRecipe(j);assertNotEquals(a.id(),other.id());
|
||||
assertTrue(java.util.stream.IntStream.range(-8,9).anyMatch(x->a.surfaceHeight(x,7)!=other.surfaceHeight(x,7)));
|
||||
}
|
||||
@Test void hashIgnoresObjectKeyOrderButPreservesFeatureOrder(){
|
||||
var j=json();var reversed=new JsonObject();var keys=new ArrayList<>(j.keySet());Collections.reverse(keys);keys.forEach(k->reversed.add(k,j.get(k)));
|
||||
assertEquals(new TerrainRecipe(j).id(),new TerrainRecipe(reversed).id());
|
||||
}
|
||||
@Test void plateauBlendsOnlyOutsideItsFootprintAndOrderMatters(){
|
||||
var r=feature("""
|
||||
[{"type":"plateau","min":{"x":-2,"z":-2},"max":{"x":2,"z":2},"height":13,"falloff":4}]
|
||||
""");
|
||||
assertEquals(13,r.surfaceHeight(0,0));assertEquals(13,r.surfaceHeight(2,2));assertEquals(9,r.surfaceHeight(4,0));assertEquals(5,r.surfaceHeight(6,0));
|
||||
var later=feature("""
|
||||
[{"type":"hill","center":{"x":0,"z":0},"radius":1,"height":10,"falloff":4},
|
||||
{"type":"plateau","min":{"x":-2,"z":-2},"max":{"x":2,"z":2},"height":8,"falloff":2}]
|
||||
""");assertEquals(8,later.surfaceHeight(0,0));
|
||||
}
|
||||
@Test void hillAndRidgeAddAndFallOffContinuously(){
|
||||
var hill=feature("""
|
||||
[{"type":"hill","center":{"x":0,"z":0},"radius":2,"height":8,"falloff":4}]
|
||||
""");
|
||||
assertEquals(13,hill.surfaceHeight(0,0));assertEquals(9,hill.surfaceHeight(4,0));assertEquals(5,hill.surfaceHeight(6,0));
|
||||
var ridge=feature("""
|
||||
[{"type":"ridge","points":[{"x":-4,"z":0},{"x":4,"z":0}],"width":2,"height":8,"falloff":4}]
|
||||
""");
|
||||
assertEquals(13,ridge.surfaceHeight(0,0));assertEquals(9,ridge.surfaceHeight(0,4));assertEquals(5,ridge.surfaceHeight(0,6));
|
||||
}
|
||||
@Test void basinAndChannelOnlyLowerAndHandleRepeatedPoints(){
|
||||
var basin=feature("""
|
||||
[{"type":"basin","center":{"x":0,"z":0},"radius":2,"height":-3,"falloff":4}]
|
||||
""");
|
||||
assertEquals(-3,basin.surfaceHeight(0,0));assertEquals(1,basin.surfaceHeight(4,0));assertEquals(5,basin.surfaceHeight(6,0));
|
||||
var high=feature("""
|
||||
[{"type":"basin","center":{"x":0,"z":0},"radius":2,"height":20,"falloff":4}]
|
||||
""");assertEquals(5,high.surfaceHeight(0,0));
|
||||
var channel=feature("""
|
||||
[{"type":"channel","points":[{"x":0,"z":0},{"x":0,"z":0},{"x":8,"z":0}],"width":2,"height":-3,"falloff":4}]
|
||||
""");assertEquals(-3,channel.surfaceHeight(4,0));assertEquals(1,channel.surfaceHeight(4,4));
|
||||
}
|
||||
@Test void terracesUseFloorForNegativeElevations(){
|
||||
var j=json();j.addProperty("base_height",-1);j.add("features",JsonParser.parseString("""
|
||||
[{"type":"terrace","step":4,"strength":1}]
|
||||
"""));
|
||||
assertEquals(-4,new TerrainRecipe(j).surfaceHeight(0,0));
|
||||
}
|
||||
@Test void modeAndPreserveMasksNeverLeakAcrossTiles(){
|
||||
var j=json();j.add("preserve",JsonParser.parseString("""
|
||||
[{"min":{"x":-1,"y":0,"z":-1},"max":{"x":1,"y":9,"z":1}}]
|
||||
"""));
|
||||
var r=new TerrainRecipe(j);var sculpt=all(r,4096);assertEquals(r.bounds().volume()-90,sculpt.size());assertFalse(sculpt.containsKey(new BlockPos(0,5,0)));
|
||||
j.addProperty("mode","fill");var fill=all(new TerrainRecipe(j),4096);assertTrue(fill.keySet().stream().allMatch(p->p.y()<=5));
|
||||
j.addProperty("mode","cut");var cut=all(new TerrainRecipe(j),4096);assertTrue(cut.values().stream().allMatch(s->s.equals("minecraft:air")));assertTrue(cut.keySet().stream().allMatch(p->p.y()>5));
|
||||
var both=new HashMap<>(fill);both.putAll(cut);assertEquals(sculpt,both);
|
||||
}
|
||||
@Test void malformedOrExpensiveRecipesRejectedBeforeExpansion(){
|
||||
var j=json();j.addProperty("script","bad");assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(j));
|
||||
for(String path:List.of("version","seed","base_height")){var n=json();n.addProperty(path,1.5);assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(n));}
|
||||
var big=json();big.getAsJsonObject("max").addProperty("x",30000000);assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(big));
|
||||
var wet=json();wet.getAsJsonObject("palette").addProperty("rock","minecraft:water");assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(wet));
|
||||
var empty=json();empty.add("features",JsonParser.parseString("""
|
||||
[{"type":"channel","points":[],"width":1,"height":0,"falloff":1}]
|
||||
"""));assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(empty));
|
||||
assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(-1,4096));assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(99999,4096));
|
||||
assertThrows(IllegalArgumentException.class,()->new TerrainRecipe(json()).tile(0,0));
|
||||
}
|
||||
@Test void naturalPolicyProtectsStructuresAndBedrock(){
|
||||
assertTrue(TerrainRecipe.replaceable("minecraft:grass_block[snowy=false]"));assertTrue(TerrainRecipe.replaceable("minecraft:air"));
|
||||
for(String s:List.of("bedrock","stone_bricks","oak_planks","water","chest","gold_block"))assertFalse(TerrainRecipe.replaceable("minecraft:"+s));
|
||||
}
|
||||
@Test void terrainPlanUsesRealJournalConflictChecksAndCheckedUndo() throws Exception {
|
||||
var world=new EditEngineTest.MemoryWorld();var journal=new EditEngineTest.MemoryJournal();
|
||||
var engine=new EditEngine(world,s->true,p->{},journal,new Limits(4096,16,128,1_000_000_000,600000,4));
|
||||
var j=json();j.add("max",JsonParser.parseString("{\"x\":-14,\"y\":0,\"z\":-14}"));var r=new TerrainRecipe(j);
|
||||
var desired=r.tile(0,4096).blocks();var plan=engine.prepare("project","epoch",EditEngineTest.REGION,desired,Set.of());engine.persistPlan(plan.id());
|
||||
String id=EditEngineTest.start(engine,plan);assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,id).status());
|
||||
desired.forEach((p,s)->assertEquals(s,world.getBlock(p)));
|
||||
var undo=engine.prepareUndo(id);engine.persistPlan(undo.id());assertEquals(OperationStatus.APPLIED,EditEngineTest.finish(engine,EditEngineTest.start(engine,undo)).status());
|
||||
desired.keySet().forEach(p->assertEquals("minecraft:air",world.getBlock(p)));
|
||||
var stale=engine.prepare("project","epoch",EditEngineTest.REGION,desired,Set.of());engine.persistPlan(stale.id());
|
||||
var edited=desired.keySet().iterator().next();world.blocks.put(edited,"minecraft:gold_block");world.writes=0;
|
||||
assertEquals(OperationStatus.CONFLICT,EditEngineTest.finish(engine,EditEngineTest.start(engine,stale)).status());assertEquals(0,world.writes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user