From 0a791bb8bfa9bb340610c175e7ffd3b4f4cfa21d Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 05:02:45 -0400 Subject: [PATCH] Place static objects at absolute world coordinates A dimension can now list staticObjects: an object key, a world position, a fixed rotation and scale, block edits and an optional bore or smart bore. The layer compiles once per dimension and stamps its blocks into every newly generated chunk it covers, later entries winning overlaps. Pack validation, the schema, the export closure and the object transforms (origin-anchored scaling, tile rotation) cover the new fields. --- .../iris/core/loader/ResourceLoader.java | 5 + .../core/pack/PackDimensionValidator.java | 12 + .../iris/core/pack/PackExportClosure.java | 14 + .../core/pack/PackStaticObjectValidator.java | 222 ++++++++++++++++ .../arcane/iris/core/pack/PackValidator.java | 10 + .../core/project/IrisPackageCompiler.java | 8 + .../iris/core/project/SchemaBuilder.java | 2 +- .../iris/engine/EngineRuntimeBuilder.java | 4 + .../iris/engine/object/IrisDimension.java | 14 + .../arcane/iris/engine/object/IrisObject.java | 4 + .../object/IrisObjectPlacementRunner.java | 9 +- .../engine/object/IrisObjectTransforms.java | 130 +++++++++- .../iris/engine/object/IrisStaticObject.java | 109 ++++++++ .../engine/object/IrisStaticObjectLayer.java | 243 ++++++++++++++++++ .../object/IrisStaticObjectRotation.java | 52 ++++ ...ourceLoaderStaticObjectValidationTest.java | 101 ++++++++ .../iris/core/pack/PackExportClosureTest.java | 19 ++ .../pack/PackStaticObjectValidatorTest.java | 131 ++++++++++ .../project/IrisStaticObjectSchemaTest.java | 113 ++++++++ ...isObjectPlacementRunnerRegressionTest.java | 53 ++++ .../object/IrisObjectTransformsTileTest.java | 213 +++++++++++++++ .../object/IrisStaticObjectLayerTest.java | 220 ++++++++++++++++ .../engine/object/IrisStaticObjectTest.java | 100 +++++++ 23 files changed, 1776 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackStaticObjectValidator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisStaticObject.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectLayer.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectRotation.java create mode 100644 core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderStaticObjectValidationTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackStaticObjectValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/IrisStaticObjectSchemaTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectLayerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectTest.java diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index 7bc7248a6..67375aacd 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -24,10 +24,12 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.project.SchemaBuilder; +import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.data.KCache; @@ -415,6 +417,9 @@ public class ResourceLoader implements MeteredCache { rawText = IO.readAll(j); JSONObject parsed = new JSONObject(rawText); JsonSchemaValidator.validateTopLevelKeys(parsed, rawText, j, resourceTypeName, objectClass); + if (objectClass == IrisDimension.class) { + PackValidator.requireValidStaticObjects(manager.getDataFolder(), name, parsed); + } T t = getManager().getGson() .fromJson(preprocess(parsed).toString(0), objectClass); t.setLoadKey(name); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java index 765842b95..457a93b9f 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java @@ -49,6 +49,7 @@ final class PackDimensionValidator { validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); validateWorldBoundary(dimensionKey, dimJson, blockingErrors); + validateStaticObjects(packFolder, dimensionKey, dimJson, blockingErrors); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -93,6 +94,17 @@ final class PackDimensionValidator { } } + static void validateStaticObjects(File packFolder, String dimensionKey, JSONObject dimension, + List blockingErrors) { + if (!dimension.has("staticObjects")) { + return; + } + JSONObject height = resolveDimensionHeight(packFolder, dimension); + int minY = height == null ? -64 : (int) height.optDouble("min", 16D); + int maxY = height == null ? 320 : (int) height.optDouble("max", 32D); + PackStaticObjectValidator.validate(packFolder, dimensionKey, dimension, minY, maxY, blockingErrors); + } + static void validateImportedStructurePolicy(String dimensionKey, JSONObject dimension, List blockingErrors, List warnings) { if (!dimension.has("importedStructures")) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java index e4ea58f3e..894845f6c 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java @@ -20,6 +20,7 @@ package art.arcane.iris.core.pack; import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.volmlib.util.collection.KSet; /** @@ -69,4 +70,17 @@ public final class PackExportClosure { } return objectKeys; } + + public static KSet collectStaticObjectKeys(Iterable placements) { + KSet objectKeys = new KSet<>(); + if (placements == null) { + return objectKeys; + } + for (IrisStaticObject placement : placements) { + if (placement != null && placement.getObject() != null && !placement.getObject().isBlank()) { + objectKeys.add(placement.getObject()); + } + } + return objectKeys; + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackStaticObjectValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackStaticObjectValidator.java new file mode 100644 index 000000000..225feab6c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackStaticObjectValidator.java @@ -0,0 +1,222 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisObjectPlacementScaleInterpolator; +import art.arcane.iris.engine.object.IrisStaticObject; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; + +final class PackStaticObjectValidator { + private PackStaticObjectValidator() { + } + + static void validate(File packFolder, String dimensionKey, JSONObject dimension, + int minY, int maxY, List errors) { + if (!dimension.has("staticObjects")) { + return; + } + String path = "Dimension '" + dimensionKey + "'.staticObjects"; + JSONArray entries = dimension.optJSONArray("staticObjects"); + if (entries == null) { + errors.add(path + " must be an array."); + return; + } + Path objectsRoot = packFolder.toPath().toAbsolutePath().normalize().resolve("objects"); + for (int index = 0; index < entries.length(); index++) { + String entryPath = path + "[" + index + "]"; + JSONObject entry = entries.optJSONObject(index); + if (entry == null) { + errors.add(entryPath + " must be an object."); + continue; + } + validateObjectKey(entryPath, entry, objectsRoot, errors); + validatePosition(packFolder, entryPath, entry, minY, maxY, errors); + validateRotation(entryPath, entry, errors); + validateNumber(entryPath, entry, "scale", IrisStaticObject.MINIMUM_SCALE, + IrisStaticObject.MAXIMUM_SCALE, errors); + validateInterpolation(entryPath, entry, errors); + validateBoolean(entryPath, entry, "bore", errors); + validateBoolean(entryPath, entry, "smartBore", errors); + validateSeed(entryPath, entry, errors); + validateEdits(packFolder, entryPath, entry, errors); + } + } + + private static void validateObjectKey(String path, JSONObject entry, Path objectsRoot, + List errors) { + Object raw = entry.opt("object"); + if (!(raw instanceof String key) || key.isBlank()) { + errors.add(path + ".object must be a nonblank object load key."); + } else { + Path objectFile = resolveContained(objectsRoot, key + ".iob"); + if (objectFile == null || !Files.isRegularFile(objectFile)) { + errors.add(path + ".object references missing object '" + key + "'."); + } + } + } + + private static void validatePosition(File packFolder, String path, JSONObject entry, + int minY, int maxY, List errors) { + JSONObject position = resolveObject(packFolder, entry.opt("position"), "position-3d", + path + ".position", errors); + if (position == null) { + return; + } + validateInteger(path + ".position", position, "x", -IrisStaticObject.MAXIMUM_HORIZONTAL_POSITION, + IrisStaticObject.MAXIMUM_HORIZONTAL_POSITION, 0, errors); + validateInteger(path + ".position", position, "y", minY, maxY - 1, 0, errors); + validateInteger(path + ".position", position, "z", -IrisStaticObject.MAXIMUM_HORIZONTAL_POSITION, + IrisStaticObject.MAXIMUM_HORIZONTAL_POSITION, 0, errors); + } + + private static void validateRotation(String path, JSONObject entry, List errors) { + if (!entry.has("rotation")) { + return; + } + JSONObject rotation = entry.optJSONObject("rotation"); + if (rotation == null) { + errors.add(path + ".rotation must be an object."); + return; + } + validateNumber(path + ".rotation", rotation, "x", -360, 360, errors); + validateNumber(path + ".rotation", rotation, "y", -360, 360, errors); + validateNumber(path + ".rotation", rotation, "z", -360, 360, errors); + } + + private static void validateInterpolation(String path, JSONObject entry, List errors) { + if (!entry.has("scaleInterpolation")) { + return; + } + Object raw = entry.opt("scaleInterpolation"); + if (raw instanceof String name) { + for (IrisObjectPlacementScaleInterpolator value : IrisObjectPlacementScaleInterpolator.values()) { + if (value.name().equals(name)) { + return; + } + } + } + errors.add(path + ".scaleInterpolation must be NONE, TRILINEAR, TRICUBIC or TRIHERMITE."); + } + + private static void validateSeed(String path, JSONObject entry, List errors) { + if (!entry.has("seed")) { + return; + } + if (entry.opt("seed") instanceof Number number) { + try { + new BigDecimal(number.toString()).longValueExact(); + return; + } catch (ArithmeticException | NumberFormatException ignored) { + } + } + errors.add(path + ".seed must be an integer between " + Long.MIN_VALUE + " and " + Long.MAX_VALUE + "."); + } + + private static void validateEdits(File packFolder, String path, JSONObject entry, List errors) { + if (!entry.has("edit")) { + return; + } + JSONArray edits = entry.optJSONArray("edit"); + if (edits == null) { + errors.add(path + ".edit must be an array."); + return; + } + for (int index = 0; index < edits.length(); index++) { + String editPath = path + ".edit[" + index + "]"; + JSONObject edit = resolveObject(packFolder, edits.opt(index), "object-block-replacer", editPath, errors); + if (edit == null) { + continue; + } + validateBlockList(editPath + ".find", edit.optJSONArray("find"), errors); + JSONObject replacement = resolveObject(packFolder, edit.opt("replace"), "palette", + editPath + ".replace", errors); + if (replacement != null) { + validateBlockList(editPath + ".replace.palette", replacement.optJSONArray("palette"), errors); + } + validateNumber(editPath, edit, "chance", 0, 1, errors); + validateBoolean(editPath, edit, "exact", errors); + } + } + + private static void validateBlockList(String path, JSONArray blocks, List errors) { + if (blocks == null || blocks.length() == 0) { + errors.add(path + " must be a nonempty array of block objects."); + return; + } + for (int index = 0; index < blocks.length(); index++) { + JSONObject block = blocks.optJSONObject(index); + if (block == null || !(block.opt("block") instanceof String key) || key.isBlank()) { + errors.add(path + "[" + index + "] must be a block object with a nonblank block key."); + } + } + } + + private static JSONObject resolveObject(File packFolder, Object value, String snippetType, + String path, List errors) { + if (value instanceof JSONObject object) { + return object; + } + if (value instanceof String reference && reference.startsWith("snippet/")) { + String base = "snippet/" + snippetType + "/"; + String key = reference.startsWith(base) ? reference : base + reference.substring("snippet/".length()); + Path root = packFolder.toPath().toAbsolutePath().normalize(); + Path snippet = resolveContained(root, key + ".json"); + if (snippet == null || !snippet.startsWith(root.resolve(base)) || !Files.isRegularFile(snippet)) { + errors.add(path + " references missing " + snippetType + " snippet '" + reference + "'."); + return null; + } + try { + return new JSONObject(Files.readString(snippet, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException e) { + errors.add(path + " could not read snippet '" + reference + "': " + e.getMessage()); + return null; + } + } + errors.add(path + " must be an object or a " + snippetType + " snippet reference."); + return null; + } + + private static Path resolveContained(Path root, String relative) { + try { + Path path = root.resolve(relative).normalize(); + return path.startsWith(root) ? path : null; + } catch (InvalidPathException ignored) { + return null; + } + } + + private static void validateNumber(String path, JSONObject owner, String field, + double minimum, double maximum, List errors) { + if (!owner.has(field)) { + return; + } + if (!(owner.opt(field) instanceof Number number) || !Double.isFinite(number.doubleValue()) + || number.doubleValue() < minimum || number.doubleValue() > maximum) { + errors.add(path + "." + field + " must be finite and between " + minimum + " and " + maximum + "."); + } + } + + private static void validateInteger(String path, JSONObject owner, String field, int minimum, + int maximum, int fallback, List errors) { + Object raw = owner.has(field) ? owner.opt(field) : fallback; + if (!(raw instanceof Number number) || !Double.isFinite(number.doubleValue()) + || number.doubleValue() != Math.rint(number.doubleValue()) + || number.doubleValue() < minimum || number.doubleValue() > maximum) { + errors.add(path + "." + field + " must be an integer between " + minimum + " and " + maximum + "."); + } + } + + private static void validateBoolean(String path, JSONObject owner, String field, List errors) { + if (owner.has(field) && !(owner.opt(field) instanceof Boolean)) { + errors.add(path + "." + field + " must be a boolean."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 0a70e0061..4c92b93de 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -18,6 +18,8 @@ package art.arcane.iris.core.pack; +import art.arcane.volmlib.util.json.JSONObject; + import java.io.File; import java.util.ArrayList; import java.util.List; @@ -56,6 +58,14 @@ public final class PackValidator { return validate(packFolder, false); } + public static void requireValidStaticObjects(File packFolder, String dimensionKey, JSONObject dimension) { + List errors = new ArrayList<>(); + PackDimensionValidator.validateStaticObjects(packFolder, dimensionKey, dimension, errors); + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", errors)); + } + } + private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) { String packName = packFolder == null ? "" : packFolder.getName(); List blockingErrors = new ArrayList<>(); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java index 464c04049..24d7dc650 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java @@ -35,6 +35,7 @@ import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.core.pack.PackExportClosure; import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.engine.object.IrisStructurePlacement; @@ -132,6 +133,9 @@ public class IrisPackageCompiler { KList allPlacements = new KList<>(); regions.forEach((r) -> allPlacements.addAll(r.getObjects())); biomes.forEach((i) -> allPlacements.addAll(i.getObjects())); + for (IrisStaticObject placement : dimension.getStaticObjects()) { + allPlacements.add(placement.toPlacement()); + } KSet markers = new KSet<>(); for (String markerKey : PackExportClosure.collectMarkerKeys(allPlacements)) { IrisMarker marker = dm.getMarkerLoader().load(markerKey); @@ -172,6 +176,10 @@ public class IrisPackageCompiler { j.setPlace(newNames); } + for (IrisStaticObject placement : dimension.getStaticObjects()) { + placement.setObject(renameObjects.get(placement.getObject())); + } + KMap> lookupObjects = renameObjects.flip(); StringBuilder gb = new StringBuilder(); ChronoLatch cl = new ChronoLatch(1000); diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index df0ea3e4b..690b40f63 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -841,7 +841,7 @@ public class SchemaBuilder { anyOf.put("anyOf", arr); anyOf.put("description", prop.getString("description")); anyOf.put("x-intellij-html-description", prop.getString("x-intellij-html-description")); - anyOf.put("!required", type.isAnnotationPresent(Required.class)); + anyOf.put("!required", Boolean.TRUE.equals(prop.remove("!required")) || type.isAnnotationPresent(Required.class)); return anyOf; } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index baa90d9b0..2ec39f3b8 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -35,6 +35,7 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionMode; import art.arcane.iris.engine.object.IrisDimensionModeType; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisStaticObjectLayer; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.project.context.IrisContext; @@ -87,6 +88,9 @@ final class EngineRuntimeBuilder { IrisLogging.debug("[IrisEngine timing] mantle.hotload=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.mode = createMode(); + IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); + assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) -> + staticObjects.apply(engine, x, z, blocks)); IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.worldManager = IrisServices.get(EngineWorldManagerProvider.class).create(engine); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index ba967150b..e7eead9e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -80,6 +80,7 @@ public class IrisDimension extends IrisRegistrant { private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); private final transient AtomicCache parallaxSize = new AtomicCache<>(); + private final transient AtomicCache staticObjectLayer = new AtomicCache<>(); private final transient AtomicCache rockLayerGenerator = new AtomicCache<>(); private final transient AtomicCache fluidLayerGenerator = new AtomicCache<>(); private final transient AtomicCache coordFracture = new AtomicCache<>(); @@ -196,6 +197,9 @@ public class IrisDimension extends IrisRegistrant { @ArrayType(type = IrisImageMapBinding.class) @Desc("Bind reusable image-map resources to typed world-generation inputs") private KList imageMaps = new KList<>(); + @ArrayType(type = IrisStaticObject.class) + @Desc("Static objects placed at absolute world coordinates in newly generated chunks. Each entry transforms its own copy; later entries win where objects overlap. Reloading does not rewrite existing chunks or repair player edits.") + private KList staticObjects = new KList<>(); @Desc("When true, sets the dimension's ambient light to maximum, making all blocks fully lit regardless of light level.") private boolean fullbright = false; @RegistryListResource(IrisDimension.class) @@ -299,6 +303,16 @@ public class IrisDimension extends IrisRegistrant { return (int) getDimensionHeight().getMin(); } + public IrisStaticObjectLayer getStaticObjectLayer(IrisData data) { + return staticObjectLayer.aquire(() -> IrisStaticObjectLayer.compile(this, data)); + } + + public IrisDimension setStaticObjects(KList staticObjects) { + this.staticObjects = staticObjects; + staticObjectLayer.reset(); + return this; + } + public Map getCarvingEntryIndex() { return carvingEntryIndex.aquire(() -> { Map index = new HashMap<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 1549eb5cb..9db9a9d8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -304,6 +304,10 @@ public class IrisObject extends IrisRegistrant { return IrisObjectTransforms.scaled(this, scale, interpolation); } + public IrisObject scaledAroundOrigin(double scale, IrisObjectPlacementScaleInterpolator interpolation) { + return IrisObjectTransforms.scaledAroundOrigin(this, scale, interpolation); + } + public void place(Location at) { readLock.lock(); try { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 93a780e8e..2a1d66155 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -514,7 +514,8 @@ final class IrisObjectPlacementRunner { if (j.isExact() ? k.matches(data) : IrisObjectShaping.materialKey(k).equals(IrisObjectShaping.materialKey(data))) { PlatformBlockState newData = j.getReplace(rng, i.getX() + x, i.getY() + y, i.getZ() + z, rdata); - if (IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(data)) && !(newData.isCustom() || data.isCustom())) + boolean sameMaterial = IrisObjectShaping.materialKey(newData).equals(IrisObjectShaping.materialKey(data)); + if (sameMaterial && !(newData.isCustom() || data.isCustom())) data = BlockDataMergeSupport.merge(data, newData); else data = newData; @@ -522,6 +523,8 @@ final class IrisObjectPlacementRunner { Optional t = j.getReplace().getTile(rng, x, y, z, rdata); if (t.isPresent()) { tile = t.get(); + } else if (!sameMaterial) { + tile = null; } } } @@ -552,13 +555,13 @@ final class IrisObjectPlacementRunner { data = data.withProperty("waterlogged", "true"); } - if (B.isVineBlock(data)) { + if (!rawStructurePiece && B.isVineBlock(data)) { data = attachVineFaces(placer, data, xx, yy, zz); } // Short-circuit order matters for cost only: the mantle read is paid solely // for vine blocks. Both operands are pure, so the value is unchanged. - boolean wouldReplace = B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); + boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); String material = IrisObjectShaping.materialKey(data); boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java index aaaf8645a..e2919b2f1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java @@ -21,14 +21,19 @@ package art.arcane.iris.engine.object; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.data.VectorMap; +import art.arcane.iris.util.common.math.AxisAlignedBB; import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.iris.util.common.math.IrisVector; +import art.arcane.iris.util.common.math.Vector3i; import art.arcane.iris.util.project.interpolation.Interpolation3D; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.Iterator; import java.util.List; +import java.util.Locale; +import java.util.Map; /** * Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers. @@ -69,18 +74,33 @@ final class IrisObjectTransforms { } static IrisObject scaled(IrisObject self, double scale, IrisObjectPlacementScaleInterpolator interpolation) { + return scaled(self, scale, interpolation, ScaleOrigin.DEFAULT); + } + + static IrisObject scaledAroundOrigin(IrisObject self, double scale, IrisObjectPlacementScaleInterpolator interpolation) { + if (!Double.isFinite(scale) || scale <= 0 || scale > 50) { + throw new IllegalArgumentException("Object scale must be finite, greater than zero, and at most 50"); + } + return scaled(self, scale, interpolation, ScaleOrigin.SAVED); + } + + private static IrisObject scaled(IrisObject self, double scale, IrisObjectPlacementScaleInterpolator interpolation, + ScaleOrigin origin) { if (interpolation == null) { interpolation = IrisObjectPlacementScaleInterpolator.NONE; } + boolean savedOrigin = origin == ScaleOrigin.SAVED; IrisVector sm1 = new IrisVector(scale - 1, scale - 1, scale - 1); scale = Math.max(0.001, Math.min(50, scale)); - if (scale < 1) { + if (!savedOrigin && scale < 1) { scale = scale - 0.0001; } IrisPosition l1 = self.getAABB().max(); IrisPosition l2 = self.getAABB().min(); VectorMap placeBlock = new VectorMap<>(); + VectorMap placeTile = new VectorMap<>(); + VectorMap placeMax = savedOrigin && scale > 1 ? new VectorMap<>() : null; IrisVector center = new IrisVector(self.getCenter().getX(), self.getCenter().getY(), self.getCenter().getZ()); if (self.getH() == 2) { @@ -93,33 +113,72 @@ final class IrisObjectTransforms { center = center.setZ(center.getBlockZ() + 0.5); } - IrisObject oo = new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); + IrisObject oo = savedOrigin ? createOriginScaledObject(self, scale) + : new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); oo.setLoadKey(self.getLoadKey()); oo.setLoader(self.getLoader()); oo.setLoadFile(self.getLoadFile()); + boolean hasTiles; self.readLock.lock(); try { - for (var entry : self.blocks) { + hasTiles = !self.states.isEmpty(); + for (Map.Entry entry : self.blocks) { PlatformBlockState bd = entry.getValue(); - placeBlock.put(entry.getKey().clone().add(IrisObject.HALF).subtract(center) - .multiply(scale).add(sm1).toBlockVector(), bd); + IrisBlockVector sourcePosition = entry.getKey(); + IrisBlockVector position = savedOrigin + ? scaledMinimum(sourcePosition, scale) + : sourcePosition.clone().add(IrisObject.HALF).subtract(center) + .multiply(scale).add(sm1).toBlockVector(); + placeBlock.put(position, bd); + if (placeMax != null) { + placeMax.put(position, scaledMaximum(sourcePosition, scale)); + } + if (hasTiles) { + TileData tile = self.states.get(entry.getKey()); + if (tile == null) { + placeTile.remove(position); + } else { + placeTile.put(position, tile); + } + } } } finally { self.readLock.unlock(); } - for (var entry : placeBlock) { + for (Map.Entry entry : placeBlock) { IrisBlockVector v = entry.getKey(); + TileData tile = hasTiles ? placeTile.get(v) : null; if (scale > 1) { - for (IrisBlockVector vec : IrisObjectShaping.blocksBetweenTwoPoints(v.clone().add(center), v.clone().add(center).add(sm1))) { + IrisVector minimum = savedOrigin ? v : v.clone().add(center); + IrisVector maximum = savedOrigin ? placeMax.get(v) : v.clone().add(center).add(sm1); + for (IrisBlockVector vec : IrisObjectShaping.blocksBetweenTwoPoints(minimum, maximum)) { oo.blocks.put(vec, entry.getValue()); + if (hasTiles) { + if (tile == null) { + oo.states.remove(vec); + } else { + oo.states.put(vec, tile.clone()); + } + } } } else { - oo.setUnsigned(v.getBlockX(), v.getBlockY(), v.getBlockZ(), entry.getValue()); + IrisBlockVector position = savedOrigin + ? v + : oo.getSigned(v.getBlockX(), v.getBlockY(), v.getBlockZ()); + oo.blocks.put(position, entry.getValue()); + if (hasTiles) { + if (tile == null) { + oo.states.remove(position); + } else { + oo.states.put(position, tile.clone()); + } + } } } + VectorMap scaledBlocks = oo.blocks; if (scale > 1) { switch (interpolation) { case TRILINEAR -> trilinear(oo, (int) Math.round(scale)); @@ -128,9 +187,59 @@ final class IrisObjectTransforms { } } + removeInapplicableTiles(oo, scaledBlocks); return oo; } + private static IrisObject createOriginScaledObject(IrisObject source, double scale) { + IrisBlockVector minimum = scaledMinimum(new IrisBlockVector( + -source.getCenter().getX(), -source.getCenter().getY(), -source.getCenter().getZ()), scale); + IrisBlockVector maximum = scaledMaximum(new IrisBlockVector( + source.getW() - source.getCenter().getX() - 1, + source.getH() - source.getCenter().getY() - 1, + source.getD() - source.getCenter().getZ() - 1), scale); + IrisObject object = new IrisObject( + maximum.getBlockX() - minimum.getBlockX() + 1, + maximum.getBlockY() - minimum.getBlockY() + 1, + maximum.getBlockZ() - minimum.getBlockZ() + 1); + object.setCenter(new Vector3i(-minimum.getBlockX(), -minimum.getBlockY(), -minimum.getBlockZ())); + object.aabb.aquire(() -> new AxisAlignedBB( + new IrisPosition(minimum.getBlockX(), minimum.getBlockY(), minimum.getBlockZ()), + new IrisPosition(maximum.getBlockX(), maximum.getBlockY(), maximum.getBlockZ()))); + return object; + } + + private static IrisBlockVector scaledMinimum(IrisBlockVector position, double scale) { + return new IrisBlockVector(Math.floor(position.getX() * scale), + Math.floor(position.getY() * scale), Math.floor(position.getZ() * scale)); + } + + private static IrisBlockVector scaledMaximum(IrisBlockVector position, double scale) { + return new IrisBlockVector(Math.ceil((position.getX() + 1) * scale) - 1, + Math.ceil((position.getY() + 1) * scale) - 1, + Math.ceil((position.getZ() + 1) * scale) - 1); + } + + private static void removeInapplicableTiles(IrisObject object, VectorMap sourceBlocks) { + Iterator> iterator = object.states.iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + PlatformBlockState block = object.blocks.get(entry.getKey()); + String tileKey = entry.getValue().getMaterialKey(); + if (tileKey == null) { + tileKey = IrisObjectShaping.materialKey(sourceBlocks.get(entry.getKey())); + } else { + tileKey = tileKey.toLowerCase(Locale.ROOT); + if (tileKey.indexOf(':') < 0) { + tileKey = "minecraft:" + tileKey; + } + } + if (block == null || !tileKey.equals(IrisObjectShaping.materialKey(block))) { + iterator.remove(); + } + } + } + static void trilinear(IrisObject self, int rad) { self.writeLock.lock(); try { @@ -366,4 +475,9 @@ final class IrisObjectTransforms { private record NearestBlock(int x, int y, int z, int rank, PlatformBlockState state) { } + + private enum ScaleOrigin { + DEFAULT, + SAVED + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObject.java new file mode 100644 index 000000000..14fc3ac3e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObject.java @@ -0,0 +1,109 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +@Desc("One object placed at a fixed world position, with transformations and edits applied to a copy") +public class IrisStaticObject { + public static final int MAXIMUM_HORIZONTAL_POSITION = 29_999_984; + public static final double MINIMUM_SCALE = 0.01D; + public static final double MAXIMUM_SCALE = 50D; + + @Required + @RegistryListResource(IrisObject.class) + @Desc("Object load key under objects/, without the .iob extension") + private String object; + @Required + @Desc("Absolute world X, Y and Z for the object's saved origin. Rotation and scaling keep this origin fixed.") + private IrisPosition position; + @Desc("Fixed rotation around the saved object origin, in degrees. All axes default to zero.") + private IrisStaticObjectRotation rotation = new IrisStaticObjectRotation(); + @MinNumber(MINIMUM_SCALE) + @MaxNumber(MAXIMUM_SCALE) + @Desc("Fixed size multiplier. 1 preserves the saved size, 0.5 halves it and 2 doubles it.") + private double scale = 1D; + @Desc("Interpolation used when enlarging the object. NONE preserves blocky shapes.") + private IrisObjectPlacementScaleInterpolator scaleInterpolation = IrisObjectPlacementScaleInterpolator.NONE; + @ArrayType(type = IrisObjectReplace.class) + @Desc("Material and block-state replacements applied before rotation, without changing the source object") + private KList edit = new KList<>(); + @Desc("Replace blocks inside the object's transformed bounding box with air before placing its blocks") + private boolean bore = false; + @Desc("Fill enclosed rooms and pockets with air before placement, preserving their empty interiors") + private boolean smartBore = false; + @Desc("Fixed seed for block replacement palettes and probabilities, independent of the world seed and chunk generation order.") + private long seed = 0L; + + public void validate(int minY, int maxY) { + if (object == null || object.isBlank()) { + throw new IllegalArgumentException("object must be a nonblank object load key."); + } + if (position == null) { + throw new IllegalArgumentException("position is required."); + } + if (position.getX() < -MAXIMUM_HORIZONTAL_POSITION || position.getX() > MAXIMUM_HORIZONTAL_POSITION + || position.getZ() < -MAXIMUM_HORIZONTAL_POSITION || position.getZ() > MAXIMUM_HORIZONTAL_POSITION) { + throw new IllegalArgumentException("position.x and position.z must be between " + + -MAXIMUM_HORIZONTAL_POSITION + " and " + MAXIMUM_HORIZONTAL_POSITION + "."); + } + if (position.getY() < minY || position.getY() >= maxY) { + throw new IllegalArgumentException("position.y must be within the dimension's world height " + + minY + " <= y < " + maxY + "."); + } + if (rotation == null) { + throw new IllegalArgumentException("rotation must be an object."); + } + rotation.validate(); + if (!Double.isFinite(scale) || scale < MINIMUM_SCALE || scale > MAXIMUM_SCALE) { + throw new IllegalArgumentException("scale must be finite and between " + + MINIMUM_SCALE + " and " + MAXIMUM_SCALE + "."); + } + if (scaleInterpolation == null) { + throw new IllegalArgumentException("scaleInterpolation must be a supported interpolation mode."); + } + if (edit == null || edit.contains(null)) { + throw new IllegalArgumentException("edit must be an array of block replacements."); + } + for (int index = 0; index < edit.size(); index++) { + validateEdit(edit.get(index), index); + } + } + + public IrisObjectPlacement toPlacement() { + return new IrisObjectPlacement() + .setPlace(new KList<>(object)) + .setMode(ObjectPlaceMode.STRUCTURE_PIECE) + .setForcePlace(true) + .setRotation(rotation.toRotation()) + .setScale(new IrisObjectScale().setSize(scale).setInterpolation(scaleInterpolation)) + .setEdit(new KList<>(edit)) + .setBore(bore) + .setSmartBore(smartBore); + } + + private static void validateEdit(IrisObjectReplace replacement, int index) { + if (replacement.getFind() == null || replacement.getFind().isEmpty() + || replacement.getFind().contains(null)) { + throw new IllegalArgumentException("edit[" + index + "].find must contain block objects."); + } + IrisMaterialPalette palette = replacement.getReplace(); + if (palette == null || palette.getPalette() == null || palette.getPalette().isEmpty() + || palette.getPalette().contains(null)) { + throw new IllegalArgumentException("edit[" + index + "].replace must contain a block palette."); + } + if (!Float.isFinite(replacement.getChance()) || replacement.getChance() < 0 || replacement.getChance() > 1) { + throw new IllegalArgumentException("edit[" + index + "].chance must be between 0 and 1."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectLayer.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectLayer.java new file mode 100644 index 000000000..2382c98ed --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectLayer.java @@ -0,0 +1,243 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterMarker; +import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.matter.MatterStructurePOI; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Compiles fixed placements once per dimension snapshot and writes only the current chunk's cells. + * Stored Y coordinates are offsets from the dimension floor; configured positions use absolute world Y. + */ +public final class IrisStaticObjectLayer { + private static final IrisStaticObjectLayer EMPTY = new IrisStaticObjectLayer(Map.of()); + private static final List> REPLACED_METADATA = List.of( + TileWrapper.class, Identifier.class, String.class, TreeBlockMaterial.class, + MatterMarker.class, MatterStructurePOI.class); + + private final Map chunks; + + private IrisStaticObjectLayer(Map chunks) { + this.chunks = chunks; + } + + public static IrisStaticObjectLayer compile(IrisDimension dimension, IrisData data) { + Objects.requireNonNull(dimension.getStaticObjects(), "staticObjects must be a list"); + if (dimension.getStaticObjects().isEmpty()) { + return EMPTY; + } + Compilation compilation = new Compilation(dimension.getMaxHeight() - dimension.getMinHeight()); + for (int index = 0; index < dimension.getStaticObjects().size(); index++) { + IrisStaticObject entry = dimension.getStaticObjects().get(index); + try { + Objects.requireNonNull(entry, "entry must not be null"); + entry.validate(dimension.getMinHeight(), dimension.getMaxHeight()); + IrisObject object = data.getObjectLoader().load(entry.getObject()); + if (object == null) { + throw new IllegalArgumentException("Object '" + entry.getObject() + "' could not be loaded"); + } + if (entry.getScale() != 1D) { + object = object.scaledAroundOrigin(entry.getScale(), entry.getScaleInterpolation()); + } else if (entry.isSmartBore()) { + object = object.copy(); + } + IrisPosition position = entry.getPosition(); + object.place(position.getX(), position.getY() - dimension.getMinHeight(), + position.getZ(), compilation, entry.toPlacement(), new RNG(entry.getSeed()), data); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("Invalid staticObjects[" + index + "] in dimension '" + + dimension.getLoadKey() + "': " + failure.getMessage(), failure); + } + } + return compilation.freeze(); + } + + public List blocks(int chunkX, int chunkZ) { + Chunk chunk = chunks.get(chunkKey(chunkX, chunkZ)); + return chunk == null ? List.of() : chunk.ordered(); + } + + /** Tests world X/Z and internal Y, with the dimension minimum already subtracted. */ + public boolean contains(int x, int y, int z) { + if (chunks.isEmpty() || y < 0) { + return false; + } + Chunk chunk = chunks.get(chunkKey(x >> 4, z >> 4)); + return chunk != null && chunk.blocks().containsKey(Compilation.blockKey(x, y, z)); + } + + public boolean isEmpty() { + return chunks.isEmpty(); + } + + public void apply(Engine engine, int x, int z, Hunk output) { + List blocks = blocks(x >> 4, z >> 4); + if (blocks.isEmpty()) { + return; + } + MantleChunk chunk = engine.getMantle().getMantle().getChunk(x >> 4, z >> 4); + chunk.use(); + try { + for (Block block : blocks) { + Matter section = chunk.getOrCreate(block.y() >> 4); + for (Class type : REPLACED_METADATA) { + MatterSlice slice = section.getSlice(type); + if (slice != null) { + slice.set(block.x(), block.y() & 15, block.z(), null); + } + } + PlatformBlockState state = block.state(); + if (state.isCustom()) { + section.slice(Identifier.class).set(block.x(), block.y() & 15, block.z(), + Identifier.fromString(state.deferredPlacementKey())); + engine.getMantle().getMantle().flag(x >> 4, z >> 4, MantleFlag.CUSTOM_ACTIVE, true); + } + if (block.tile() != null) { + section.slice(TileWrapper.class).set(block.x(), block.y() & 15, block.z(), + new TileWrapper(block.tile().clone())); + } + output.set(block.x(), block.y(), block.z(), state.placementBaseState()); + } + } finally { + chunk.release(); + } + } + + private static long chunkKey(int x, int z) { + return ((long) x << 32) | (z & 0xffffffffL); + } + + public record Block(int x, int y, int z, PlatformBlockState state, TileData tile) { + } + + private record Chunk(Map blocks, List ordered) { + } + + private static final class Compilation implements IObjectPlacer { + private final int height; + private final Map> chunks = new HashMap<>(); + + private Compilation(int height) { + this.height = height; + } + + private IrisStaticObjectLayer freeze() { + Map compiled = new HashMap<>(chunks.size()); + for (Map.Entry> chunk : chunks.entrySet()) { + Map blocks = Map.copyOf(chunk.getValue()); + compiled.put(chunk.getKey(), new Chunk(blocks, List.copyOf(blocks.values()))); + } + return new IrisStaticObjectLayer(Map.copyOf(compiled)); + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + if (y < 0 || y >= height) { + throw new IllegalArgumentException("Transformed object extends outside the dimension height"); + } + if (Math.abs((long) x) > 29999984L || Math.abs((long) z) > 29999984L) { + throw new IllegalArgumentException("Transformed object extends outside the world coordinate limits"); + } + Objects.requireNonNull(state, "Object block must not be null"); + if (state.isCustom() && (state.deferredPlacementKey() == null || state.placementBaseState() == null)) { + throw new IllegalArgumentException("Custom object block has no placement data: " + state.key()); + } + Map chunk = chunks.computeIfAbsent(chunkKey(x >> 4, z >> 4), key -> new HashMap<>()); + chunk.put(blockKey(x, y, z), new Block(x & 15, y, z & 15, state, null)); + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + Block block = block(x, y, z); + return block == null ? IrisObject.States.AIR : block.state(); + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + Block block = block(x, y, z); + if (block != null) { + chunks.get(chunkKey(x >> 4, z >> 4)).put(blockKey(x, y, z), + new Block(block.x(), block.y(), block.z(), block.state(), tile.clone())); + } + } + + @Override + public int getHighest(int x, int z, IrisData data) { + throw new IllegalStateException("Static objects cannot sample terrain"); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return getHighest(x, z, data); + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + return get(x, y, z).isSolid(); + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 0; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setData(int x, int y, int z, T data) { + throw new IllegalStateException("Static objects cannot create placement markers"); + } + + @Override + public T getData(int x, int y, int z, Class type) { + return null; + } + + @Override + public Engine getEngine() { + return null; + } + + private Block block(int x, int y, int z) { + Map chunk = chunks.get(chunkKey(x >> 4, z >> 4)); + return chunk == null ? null : chunk.get(blockKey(x, y, z)); + } + + private static int blockKey(int x, int y, int z) { + return (y << 8) | ((z & 15) << 4) | (x & 15); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectRotation.java new file mode 100644 index 000000000..b1ef2e808 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectRotation.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +@Desc("Fixed object rotation in degrees around its saved origin") +public class IrisStaticObjectRotation { + @MinNumber(-360) + @MaxNumber(360) + @Desc("Fixed X-axis rotation in degrees, including negative and fractional angles") + private double x = 0; + @MinNumber(-360) + @MaxNumber(360) + @Desc("Fixed Y-axis rotation in degrees, including negative and fractional angles") + private double y = 0; + @MinNumber(-360) + @MaxNumber(360) + @Desc("Fixed Z-axis rotation in degrees, including negative and fractional angles") + private double z = 0; + + public void validate() { + validateAngle("rotation.x", x); + validateAngle("rotation.y", y); + validateAngle("rotation.z", z); + } + + public IrisObjectRotation toRotation() { + validate(); + return new IrisObjectRotation() + .setEnabled(x != 0 || y != 0 || z != 0) + .setXAxis(fixedAxis(x)) + .setYAxis(fixedAxis(y)) + .setZAxis(fixedAxis(z)); + } + + private static IrisAxisRotationClamp fixedAxis(double angle) { + return new IrisAxisRotationClamp(angle != 0, false, angle, angle, 0); + } + + private static void validateAngle(String field, double angle) { + if (!Double.isFinite(angle) || angle < -360 || angle > 360) { + throw new IllegalArgumentException(field + " must be finite and between -360 and 360 degrees."); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderStaticObjectValidationTest.java b/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderStaticObjectValidationTest.java new file mode 100644 index 000000000..403c19da3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/ResourceLoaderStaticObjectValidationTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.core.loader; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import com.google.gson.Gson; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ResourceLoaderStaticObjectValidationTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + private File pack; + private IrisData data; + + @Before + public void setUp() throws Exception { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + previousSettings = IrisSettings.settings; + IrisPlatforms.unbind(); + pack = temporaryFolder.newFolder("pack"); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.dataFolder()).thenReturn(pack); + IrisPlatforms.bind(platform); + IrisSettings.settings = new IrisSettings(); + data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + when(data.getGson()).thenReturn(new Gson()); + write("objects/tower.iob", ""); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void malformedInlineOriginNeverReachesDeserialization() throws Exception { + File dimension = write("dimensions/main.json", """ + {"staticObjects":[{"object":"tower","position":{"x":"wrong","y":100,"z":-100}}]} + """); + + assertNull(loader().loadFile(dimension, "main")); + verify(data, never()).getGson(); + } + + @Test + public void malformedPositionSnippetRejectsReloadAndPreservesThePreviousDimension() throws Exception { + File dimension = write("dimensions/main.json", """ + {"staticObjects":[{"object":"tower","position":{"x":100,"y":100,"z":-100}}]} + """); + ResourceLoader loader = loader(); + IrisDimension previous = loader.loadFile(dimension, "main"); + assertNotNull(previous); + write("snippet/position-3d/tower.json", "{\"x\":\"wrong\",\"y\":100}"); + write("dimensions/main.json", """ + {"staticObjects":[{"object":"tower","position":"snippet/tower"}]} + """); + + assertNull(loader.loadFile(dimension, "main")); + assertEquals(new IrisPosition(100, 100, -100), previous.getStaticObjects().getFirst().getPosition()); + verify(data, times(1)).getGson(); + } + + private ResourceLoader loader() { + return new ResourceLoader<>(pack, data, "dimensions", "Dimension", IrisDimension.class, + ResourceLoader.Options.datapackCompiler()); + } + + private File write(String relative, String content) throws Exception { + Path path = pack.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + return path.toFile(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java index 3f03aec0a..9186e262e 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.pack; import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; @@ -49,6 +50,19 @@ public class PackExportClosureTest { assertTrue(PackExportClosure.collectObjectKeys(placements).isEmpty()); } + @Test + public void collectsStaticObjectKeysWithoutDuplicates() { + KList placements = new KList<>(); + placements.add(new IrisStaticObject().setObject("landmarks/tower")); + placements.add(new IrisStaticObject().setObject("landmarks/tower")); + placements.add(new IrisStaticObject().setObject("landmarks/bridge")); + placements.add(new IrisStaticObject().setObject("")); + placements.add((IrisStaticObject) null); + + assertEquals(List.of("landmarks/bridge", "landmarks/tower"), + PackExportClosure.collectStaticObjectKeys(placements).stream().sorted().toList()); + } + /** * Source guard: both packagers must export the ambient-spawning graph. Spawner and marker * folders were silently omitted from exports, leaving dangling entitySpawners references. @@ -61,6 +75,9 @@ public class PackExportClosureTest { assertTrue("Bukkit packager must write markers/", bukkit.contains("\"markers/\"")); assertTrue("Bukkit packager must export region objects alongside biome objects", bukkit.contains("regions.forEach((r) -> allPlacements.addAll(r.getObjects()))")); + assertTrue("Bukkit packager must export static objects", bukkit.contains("dimension.getStaticObjects()")); + assertTrue("Bukkit obfuscation must rewrite static object references", + bukkit.contains("placement.setObject(renameObjects.get(placement.getObject()))")); assertTrue("Bukkit packager must export entity loot tables", bukkit.contains("getLoot().getTables()")); int bukkitValidation = bukkit.indexOf("PackValidator.validateForPackaging(project.getPath())"); assertTrue("Bukkit packager must validate before opening or mutating package state", @@ -74,6 +91,8 @@ public class PackExportClosureTest { assertTrue("modded packager must write markers/", modded.contains("\"markers\"")); assertTrue("modded packager must include initial spawns", modded.contains("getInitialSpawns")); assertTrue("modded packager must export region objects", modded.contains("region.getObjects()")); + assertTrue("modded packager must export static objects", + modded.contains("PackExportClosure.collectStaticObjectKeys(dimension.getStaticObjects())")); int moddedValidation = modded.indexOf("PackValidator.validateForPackaging(packFolder)"); assertTrue("modded packager must validate before mutating package state", moddedValidation >= 0 && moddedValidation < modded.indexOf("IO.delete(folder)")); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackStaticObjectValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackStaticObjectValidatorTest.java new file mode 100644 index 000000000..7f7fe3baa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackStaticObjectValidatorTest.java @@ -0,0 +1,131 @@ +package art.arcane.iris.core.pack; + +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackStaticObjectValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsAbsentEmptyAndCompleteStaticObjects() throws Exception { + File pack = pack(); + assertEquals(List.of(), validate(pack, "{}")); + assertEquals(List.of(), validate(pack, "{\"staticObjects\":[]}")); + assertEquals(List.of(), validate(pack, """ + {"staticObjects":[{ + "object":"landmarks/tower","position":{"x":100,"y":100,"z":-100}, + "rotation":{"x":-22.5,"y":90,"z":-180},"scale":0.5, + "scaleInterpolation":"TRILINEAR","seed":9223372036854775807,"bore":true,"smartBore":true, + "edit":[{"find":[{"block":"stone"}],"replace":{"palette":[{"block":"gold_block"}]}}] + }]} + """)); + } + + @Test + public void rejectsMalformedContainersMissingObjectsAndMissingOrigins() throws Exception { + File pack = pack(); + assertEquals(List.of("Dimension 'main'.staticObjects must be an array."), + validate(pack, "{\"staticObjects\":null}")); + assertEquals(List.of("Dimension 'main'.staticObjects[0] must be an object."), + validate(pack, "{\"staticObjects\":[null]}")); + List errors = validate(pack, "{\"staticObjects\":[{\"object\":\"missing\"}]}"); + assertEquals(2, errors.size()); + assertContains(errors, ".object references missing object 'missing'"); + assertContains(errors, ".position must be an object"); + } + + @Test + public void checksAllScalarTypesAndLimits() throws Exception { + List errors = validate(pack(), """ + {"staticObjects":[{ + "object":"landmarks/tower","position":{"x":29999985,"y":320,"z":0.5}, + "rotation":{"x":null,"y":-361,"z":"90"},"scale":0, + "scaleInterpolation":"CUBIC","seed":1.5,"bore":null,"smartBore":"true","edit":null + }]} + """); + assertEquals(12, errors.size()); + assertContains(errors, ".position.y must be an integer between -64 and 319"); + assertContains(errors, ".rotation.y must be finite"); + assertContains(errors, ".seed must be an integer"); + } + + @Test + public void resolvesHeightPositionAndReplacementSnippets() throws Exception { + File pack = pack(); + write(pack, "snippet/range/tall.json", "{\"min\":-128,\"max\":512}"); + write(pack, "snippet/position-3d/tower.json", "{\"x\":100,\"y\":-128,\"z\":-100}"); + write(pack, "snippet/object-block-replacer/gilded.json", + "{\"find\":[{\"block\":\"stone\"}],\"replace\":\"snippet/gold\"}"); + write(pack, "snippet/palette/gold.json", "{\"palette\":[{\"block\":\"gold_block\"}]}"); + + assertEquals(List.of(), validate(pack, """ + {"dimensionHeight":"snippet/tall","staticObjects":[{ + "object":"landmarks/tower","position":"snippet/tower","edit":["snippet/gilded"] + }]} + """)); + } + + @Test + public void rejectsInvalidEditsAndUnresolvedSnippets() throws Exception { + List errors = validate(pack(), """ + {"staticObjects":[{ + "object":"landmarks/tower","position":"snippet/missing", + "edit":[null,{"find":[],"replace":{"palette":[]},"chance":2,"exact":"yes"}] + }]} + """); + assertEquals(6, errors.size()); + assertContains(errors, "references missing position-3d snippet"); + assertContains(errors, ".edit[1].find must be a nonempty array"); + assertContains(errors, ".edit[1].replace.palette must be a nonempty array"); + } + + @Test + public void dimensionAdmissionRejectsMissingStaticObjectsBeforeRegions() throws Exception { + File pack = pack(); + write(pack, "dimensions/main.json", """ + {"regions":[],"staticObjects":[{"object":"missing","position":{"y":100}}]} + """); + List errors = new ArrayList<>(); + File dimension = new File(pack, "dimensions/main.json"); + + PackDimensionValidator.validateDimensions(pack, new File[]{dimension}, errors, new ArrayList<>()); + + assertContains(errors, ".object references missing object 'missing'"); + assertContains(errors, "declares no regions"); + } + + private File pack() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "objects/landmarks/tower.iob", ""); + return pack; + } + + private static List validate(File pack, String json) { + List errors = new ArrayList<>(); + PackDimensionValidator.validateStaticObjects(pack, "main", new JSONObject(json), errors); + return errors; + } + + private static void assertContains(List errors, String fragment) { + assertTrue(errors.toString(), errors.stream().anyMatch(error -> error.contains(fragment))); + } + + private static void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisStaticObjectSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisStaticObjectSchemaTest.java new file mode 100644 index 000000000..0de1e7b91 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisStaticObjectSchemaTest.java @@ -0,0 +1,113 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisExpression; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisStaticObject; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisStaticObjectSchemaTest { + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + if (previousPlatform != null) { + IrisPlatforms.unbind(); + } + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockTypeKeys()).thenReturn(List.of()); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void dimensionExposesAnOptionalTypedStaticObjectList() throws Exception { + Field field = IrisDimension.class.getDeclaredField("staticObjects"); + + assertEquals(IrisStaticObject.class, field.getAnnotation(ArrayType.class).type()); + assertTrue(new IrisDimension().getStaticObjects().isEmpty()); + } + + @Test + public void schemaRequiresTheObjectAndOriginAndExposesFixedTransforms() { + JSONObject schema = new SchemaBuilder(IrisStaticObject.class, schemaData()).construct(); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject definitions = schema.getJSONObject("definitions"); + JSONObject rotation = definitions.getJSONObject(properties.getJSONObject("rotation") + .getString("$ref").substring("#/definitions/".length())).getJSONObject("properties"); + + assertEquals(2, schema.getJSONArray("required").length()); + assertEquals("object", schema.getJSONArray("required").getString(0)); + assertEquals("position", schema.getJSONArray("required").getString(1)); + assertEquals("#/definitions/erzobjects", properties.getJSONObject("object").getString("$ref")); + assertEquals("landmarks/tower", definitions.getJSONObject("erzobjects").getJSONArray("enum").getString(0)); + assertEquals(IrisStaticObject.MINIMUM_SCALE, properties.getJSONObject("scale").getDouble("minimum"), 0D); + assertEquals(IrisStaticObject.MAXIMUM_SCALE, properties.getJSONObject("scale").getDouble("maximum"), 0D); + assertEquals("integer", properties.getJSONObject("seed").getString("type")); + for (String axis : List.of("x", "y", "z")) { + assertEquals("number", rotation.getJSONObject(axis).getString("type")); + assertEquals(-360D, rotation.getJSONObject(axis).getDouble("minimum"), 0D); + assertEquals(360D, rotation.getJSONObject(axis).getDouble("maximum"), 0D); + } + assertTrue(properties.has("edit")); + assertTrue(properties.has("bore")); + assertTrue(properties.has("smartBore")); + assertFalse(properties.has("chance")); + assertFalse(properties.has("density")); + } + + @SuppressWarnings("unchecked") + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + ResourceLoader expressionLoader = mock(ResourceLoader.class); + ResourceLoader blockLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisObject.class, objectLoader); + loaders.put(IrisExpression.class, expressionLoader); + when(data.getLoaders()).thenReturn(loaders); + when(data.getBlockLoader()).thenReturn(blockLoader); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); + when(objectLoader.getPossibleKeys()).thenReturn(new String[]{"landmarks/tower"}); + when(objectLoader.getFolderName()).thenReturn("objects"); + when(objectLoader.getResourceTypeName()).thenReturn("Object"); + when(expressionLoader.getPossibleKeys()).thenReturn(new String[0]); + when(expressionLoader.getFolderName()).thenReturn("expressions"); + when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); + when(blockLoader.getPossibleKeys()).thenReturn(new String[0]); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java index 4798a6ed8..a46141d86 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java @@ -7,6 +7,8 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import org.junit.After; import org.junit.Before; @@ -16,14 +18,18 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -188,6 +194,52 @@ public class IrisObjectPlacementRunnerRegressionTest { assertEquals("Object placement data is required.", error.getMessage()); } + @Test + public void rawPlacementPreservesVineFacesAndReplacesSolidDestination() { + RecordingPlacer placer = new RecordingPlacer(null); + placer.set(0, ANCHOR_Y, 0, solid); + placer.set(1, ANCHOR_Y, 0, solid); + PlatformBlockState vine = state("minecraft:vine[east=false,north=true]", false); + when(vine.isVineBlock()).thenReturn(true); + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, vine); + IrisObjectPlacement placement = placement(); + placement.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + placement.setRotation(IrisObjectRotation.of(0, 0, 0)); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertSame(vine, placer.get(0, ANCHOR_Y, 0)); + verify(vine, never()).withProperty(anyString(), anyString()); + } + + @Test + public void changingMaterialWithAnEditClearsOriginalTileData() { + RecordingPlacer placer = new RecordingPlacer(null); + PlatformBlockState chest = state("minecraft:chest", true); + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, chest); + object.setUnsignedTile(0, 0, 0, new TileData("minecraft:chest", new KMap<>())); + IrisObjectReplace edit = mock(IrisObjectReplace.class); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(edit.getChance()).thenReturn(1F); + when(edit.getFind(data)).thenReturn(new KList<>(chest)); + when(edit.getReplace(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(solid); + when(edit.getReplace()).thenReturn(palette); + when(palette.getTile(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(Optional.empty()); + IrisObjectPlacement placement = placement(); + placement.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + placement.getEdit().add(edit); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertSame(solid, placer.get(0, ANCHOR_Y, 0)); + assertNull(placer.getData(0, ANCHOR_Y, 0, TileData.class)); + assertEquals(1, object.getStates().size()); + } + private IrisObjectPlacement placement() { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); @@ -322,6 +374,7 @@ public class IrisObjectPlacementRunnerRegressionTest { @Override public void setTile(int x, int y, int z, TileData tile) { + setData(x, y, z, tile); } @Override diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java new file mode 100644 index 000000000..0059779c4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java @@ -0,0 +1,213 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectTransformsTileTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + Map states = new HashMap<>(); + when(registries.block(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + return states.computeIfAbsent(key, IrisObjectTransformsTileTest::state); + }); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void enlargedTilesFollowEveryExpandedBlockAndCloneProperties() { + IrisObject source = new IrisObject(1, 1, 1); + TileData original = tile("minecraft:chest", "saved inventory"); + source.setUnsigned(0, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(0, 0, 0, original); + + IrisObject scaled = source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(8, scaled.getBlocks().size()); + assertEquals(8, scaled.getStates().size()); + Set copies = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Map.Entry block : scaled.getBlocks()) { + TileData copy = scaled.getStates().get(block.getKey()); + assertNotNull(copy); + assertNotSame(original, copy); + assertNotSame(original.getProperties(), copy.getProperties()); + assertEquals(original, copy); + assertTrue(copies.add(copy)); + } + } + + @Test + public void reducedTilesStayWithTheWinningBlocks() { + IrisObject source = new IrisObject(7, 1, 1); + for (int x = 0; x < 7; x++) { + String material = x % 2 == 0 ? "minecraft:chest" : "minecraft:barrel"; + source.setUnsigned(x, 0, 0, state(material)); + source.setUnsignedTile(x, 0, 0, tile(material, material)); + } + + IrisObject scaled = source.scaled(0.5, IrisObjectPlacementScaleInterpolator.NONE); + + assertTrue(scaled.getBlocks().size() < source.getBlocks().size()); + assertEquals(scaled.getBlocks().size(), scaled.getStates().size()); + for (Map.Entry block : scaled.getBlocks()) { + TileData copy = scaled.getStates().get(block.getKey()); + assertNotNull(copy); + assertEquals(block.getValue().materialKey(), copy.getMaterialKey()); + assertEquals(block.getValue().materialKey(), copy.getProperties().get("name")); + } + } + + @Test + public void interpolatedTilesOnlyRemainOnTheirMaterial() { + IrisObject source = new IrisObject(3, 3, 3); + source.setUnsigned(1, 1, 1, state("minecraft:chest")); + source.setUnsignedTile(1, 1, 1, tile("minecraft:chest", "saved inventory")); + source.setUnsigned(2, 1, 1, state("minecraft:stone")); + for (IrisObjectPlacementScaleInterpolator interpolation : new IrisObjectPlacementScaleInterpolator[] { + IrisObjectPlacementScaleInterpolator.TRILINEAR, + IrisObjectPlacementScaleInterpolator.TRICUBIC, + IrisObjectPlacementScaleInterpolator.TRIHERMITE + }) { + IrisObject scaled = source.scaled(2, interpolation); + + assertFalse(scaled.getStates().isEmpty()); + for (Map.Entry tile : scaled.getStates()) { + PlatformBlockState block = scaled.getBlocks().get(tile.getKey()); + assertNotNull(block); + assertEquals(tile.getValue().getMaterialKey(), block.materialKey()); + } + } + } + + @Test + public void incompatibleSourceTileDoesNotSurviveScaling() { + IrisObject source = new IrisObject(1, 1, 1); + source.setUnsigned(0, 0, 0, state("minecraft:stone")); + source.setUnsignedTile(0, 0, 0, tile("minecraft:chest", "saved inventory")); + + IrisObject scaled = source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE); + + assertTrue(scaled.getStates().isEmpty()); + assertEquals(1, source.getStates().size()); + } + + @Test + public void enlargedAsymmetricObjectKeepsItsSavedOrigin() { + IrisObject source = asymmetricObject(); + + IrisObject scaled = source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(new Vector3i(10, 2, 4), scaled.getCenter()); + assertEquals(14, scaled.getW()); + assertEquals(10, scaled.getH()); + assertEquals(6, scaled.getD()); + assertEquals("minecraft:chest", scaled.getBlocks().get(new IrisBlockVector(0, 0, 0)).materialKey()); + assertEquals("minecraft:chest", scaled.getBlocks().get(new IrisBlockVector(1, 1, 1)).materialKey()); + assertEquals("minecraft:stone", scaled.getBlocks().get(new IrisBlockVector(-8, 0, 0)).materialKey()); + assertEquals("minecraft:barrel", scaled.getBlocks().get(new IrisBlockVector(2, 4, 0)).materialKey()); + assertEquals("origin", scaled.getStates().get(new IrisBlockVector(0, 0, 0)).getProperties().get("name")); + assertEquals(-10, scaled.getAABB().min().getX()); + assertEquals(3, scaled.getAABB().max().getX()); + assertEquals(new Vector3i(5, 1, 2), source.getCenter()); + } + + @Test + public void reducedAsymmetricObjectKeepsItsSavedOriginAndTiles() { + IrisObject source = asymmetricObject(); + + IrisObject scaled = source.scaledAroundOrigin(0.5, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals("minecraft:chest", scaled.getBlocks().get(new IrisBlockVector(0, 0, 0)).materialKey()); + assertEquals("minecraft:stone", scaled.getBlocks().get(new IrisBlockVector(-2, 0, 0)).materialKey()); + assertEquals("minecraft:barrel", scaled.getBlocks().get(new IrisBlockVector(0, 1, 0)).materialKey()); + assertEquals("origin", scaled.getStates().get(new IrisBlockVector(0, 0, 0)).getProperties().get("name")); + assertEquals(3, scaled.getBlocks().size()); + assertEquals(2, scaled.getStates().size()); + } + + @Test + public void fractionalEnlargementScalesBothSidesOfTheSavedOrigin() { + IrisObject source = new IrisObject(5, 1, 1); + source.setUnsigned(0, 0, 0, state("minecraft:stone")); + source.setUnsigned(2, 0, 0, state("minecraft:chest")); + source.setUnsigned(4, 0, 0, state("minecraft:barrel")); + + IrisObject scaled = source.scaledAroundOrigin(1.5, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals("minecraft:stone", scaled.getBlocks().get(new IrisBlockVector(-3, 0, 0)).materialKey()); + assertEquals("minecraft:stone", scaled.getBlocks().get(new IrisBlockVector(-2, 0, 0)).materialKey()); + assertEquals("minecraft:chest", scaled.getBlocks().get(new IrisBlockVector(0, 0, 0)).materialKey()); + assertEquals("minecraft:barrel", scaled.getBlocks().get(new IrisBlockVector(3, 0, 0)).materialKey()); + assertEquals("minecraft:barrel", scaled.getBlocks().get(new IrisBlockVector(4, 0, 0)).materialKey()); + } + + @Test + public void untypedTilesRemainAttachedToTheirScaledSourceMaterial() { + IrisObject source = new IrisObject(1, 1, 1); + TileData tile = mock(TileData.class); + when(tile.clone()).thenReturn(tile); + source.setUnsigned(0, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(0, 0, 0, tile); + + IrisObject scaled = source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(8, scaled.getStates().size()); + } + + private static IrisObject asymmetricObject() { + IrisObject source = new IrisObject(7, 5, 3); + source.setCenter(new Vector3i(5, 1, 2)); + source.setUnsigned(5, 1, 2, state("minecraft:chest")); + source.setUnsignedTile(5, 1, 2, tile("minecraft:chest", "origin")); + source.setUnsigned(1, 1, 2, state("minecraft:stone")); + source.setUnsigned(6, 3, 2, state("minecraft:barrel")); + source.setUnsignedTile(6, 3, 2, tile("minecraft:barrel", "upper")); + return source; + } + + private static TileData tile(String material, String name) { + KMap properties = new KMap<>(); + properties.put("name", name); + return new TileData(material, properties); + } + + private static PlatformBlockState state(String key) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + when(state.isAir()).thenReturn(key.contains("air")); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectLayerTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectLayerTest.java new file mode 100644 index 000000000..e503f5d8f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectLayerTest.java @@ -0,0 +1,220 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisStaticObjectLayerTest { + private final Map states = new HashMap<>(); + private IrisData data; + private ResourceLoader loader; + private IrisObjectRotation.StateRotator previousRotator; + + @Before + public void setup() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenAnswer(call -> state(call.getArgument(0))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + previousRotator = IrisObjectRotation.bindPlatformRotator((rotation, block, x, y, z) -> block); + data = mock(IrisData.class); + loader = mock(ResourceLoader.class); + when(data.getObjectLoader()).thenReturn(loader); + } + + @After + public void cleanup() { + IrisObjectRotation.restorePlatformRotator(previousRotator); + IrisPlatforms.unbind(); + } + + @Test + public void emptyConfigurationDoesNotLoadObjectsOrTouchWorldData() { + IrisStaticObjectLayer layer = IrisStaticObjectLayer.compile(new IrisDimension(), data); + Engine engine = mock(Engine.class); + layer.apply(engine, 0, 0, Hunk.newArrayHunk(16, 384, 16)); + + assertTrue(layer.isEmpty()); + assertFalse(layer.contains(0, 0, 0)); + verifyNoInteractions(engine, loader); + } + + @Test + public void rotatedNegativeCoordinatesAndMinusOneYUseExactWorldOrigin() { + IrisObject object = object("cross", 5, 3, 5); + object.getBlocks().put(new IrisBlockVector(-2, -1, 0), state("minecraft:gold_block")); + object.getBlocks().put(new IrisBlockVector(2, 1, 0), state("minecraft:diamond_block")); + object.getBlocks().put(new IrisBlockVector(0, 0, 2), state("minecraft:emerald_block")); + IrisStaticObject entry = entry("cross", 15, -1, -16) + .setRotation(new IrisStaticObjectRotation().setY(90)); + IrisStaticObjectLayer layer = compile(entry); + + assertTrue(layer.contains(15, 62, -14)); + assertTrue(layer.contains(15, 64, -18)); + assertTrue(layer.contains(17, 63, -16)); + assertEquals(1, layer.blocks(0, -1).size()); + assertEquals(1, layer.blocks(0, -2).size()); + assertEquals(1, layer.blocks(1, -1).size()); + assertEquals(62, layer.blocks(0, -1).getFirst().y()); + assertEquals(3, object.getBlocks().size()); + } + + @Test + public void laterEntryAndBoreClearEarlierBlocksAndTiles() { + IrisObject chest = object("chest", 1, 1, 1); + chest.getBlocks().put(new IrisBlockVector(0, 0, 0), state("minecraft:chest")); + chest.getStates().put(new IrisBlockVector(0, 0, 0), new TileData("minecraft:chest", new KMap<>())); + IrisObject replacement = object("replacement", 3, 1, 1); + replacement.getBlocks().put(new IrisBlockVector(-1, 0, 0), state("minecraft:stone")); + replacement.getBlocks().put(new IrisBlockVector(1, 0, 0), state("minecraft:stone")); + IrisStaticObjectLayer layer = compile(entry("chest", 0, 100, 0), + entry("replacement", 0, 100, 0).setBore(true)); + + IrisStaticObjectLayer.Block center = block(layer, 0, 164, 0); + assertSame(IrisObject.States.AIR, center.state()); + assertNull(center.tile()); + assertEquals(1, chest.getStates().size()); + } + + @Test + public void chunkWritesStayLocalAndRemoveReplacedTileMetadata() { + IrisObject object = object("line", 3, 1, 1); + for (int x = -1; x <= 1; x++) { + object.getBlocks().put(new IrisBlockVector(x, 0, 0), state("minecraft:stone")); + } + IrisStaticObjectLayer layer = compile(entry("line", 15, 0, -1)); + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + Matter section = mock(Matter.class); + MatterSlice tiles = mock(MatterSlice.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.getOrCreate(anyInt())).thenReturn(section); + when(section.getSlice(TileWrapper.class)).thenReturn(tiles); + Hunk left = Hunk.newArrayHunk(16, 384, 16); + Hunk right = Hunk.newArrayHunk(16, 384, 16); + + layer.apply(engine, 16, -16, right); + layer.apply(engine, 0, -16, left); + + assertSame(state("minecraft:stone"), left.get(14, 64, 15)); + assertSame(state("minecraft:stone"), left.get(15, 64, 15)); + assertSame(state("minecraft:stone"), right.get(0, 64, 15)); + assertNull(right.get(1, 64, 15)); + verify(tiles).set(0, 0, 15, null); + verify(tiles).set(14, 0, 15, null); + verify(tiles).set(15, 0, 15, null); + } + + @Test + public void placementKeepsSavedTilesWithoutSharingTheirInstances() { + IrisObject object = object("chest", 1, 1, 1); + object.getBlocks().put(new IrisBlockVector(0, 0, 0), state("minecraft:chest")); + TileData tile = new TileData("minecraft:chest", new KMap<>()); + object.getStates().put(new IrisBlockVector(0, 0, 0), tile); + IrisStaticObjectLayer layer = compile(entry("chest", 100, 100, -100)); + + assertEquals(tile, block(layer, 100, 164, -100).tile()); + assertNotSame(tile, block(layer, 100, 164, -100).tile()); + } + + @Test + public void rejectsMissingObjectsAndTransformedHeightOverflow() { + assertThrows(IllegalArgumentException.class, () -> compile(entry("missing", 0, 0, 0))); + IrisObject object = object("tall", 1, 3, 1); + object.getBlocks().put(new IrisBlockVector(0, 1, 0), state("minecraft:stone")); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> compile(entry("tall", 0, 319, 0))); + assertTrue(failure.getMessage().contains("staticObjects[0]")); + assertTrue(failure.getMessage().contains("outside the dimension height")); + } + + @Test + public void repeatedCompilationDoesNotChangeSavedBlocksOrPlacement() { + IrisObject object = object("line", 3, 1, 1); + object.getBlocks().put(new IrisBlockVector(-1, 0, 0), state("minecraft:stone")); + object.getBlocks().put(new IrisBlockVector(1, 0, 0), state("minecraft:stone")); + IrisStaticObject entry = entry("line", 100, 100, -100).setBore(true).setSeed(948L); + IrisStaticObjectLayer first = compile(entry); + IrisStaticObjectLayer second = compile(entry); + + assertEquals(first.blocks(6, -7), second.blocks(6, -7)); + assertEquals(2, object.getBlocks().size()); + } + + private IrisStaticObjectLayer compile(IrisStaticObject... entries) { + return IrisStaticObjectLayer.compile(new IrisDimension().setStaticObjects(new KList<>(entries)), data); + } + + private IrisObject object(String key, int width, int height, int depth) { + IrisObject object = new IrisObject(width, height, depth); + object.setLoadKey(key); + object.setLoader(data); + when(loader.load(key)).thenReturn(object); + return object; + } + + private static IrisStaticObject entry(String key, int x, int y, int z) { + return new IrisStaticObject().setObject(key).setPosition(new IrisPosition(x, y, z)); + } + + private static IrisStaticObjectLayer.Block block(IrisStaticObjectLayer layer, int x, int y, int z) { + List blocks = layer.blocks(x >> 4, z >> 4); + return blocks.stream().filter(block -> block.x() == (x & 15) && block.y() == y && block.z() == (z & 15)) + .findFirst().orElseThrow(); + } + + private PlatformBlockState state(String key) { + return states.computeIfAbsent(key.toLowerCase(), value -> { + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.key()).thenReturn(value); + when(block.materialKey()).thenReturn(value); + when(block.isAir()).thenReturn(value.contains("air")); + when(block.isSolid()).thenReturn(!value.contains("air")); + when(block.isOccluding()).thenReturn(!value.contains("air")); + when(block.placementBaseState()).thenReturn(block); + return block; + }); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectTest.java new file mode 100644 index 000000000..57e696daf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStaticObjectTest.java @@ -0,0 +1,100 @@ +package art.arcane.iris.engine.object; + +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisStaticObjectTest { + @Test + public void defaultsKeepTheSavedOrientationAndOrigin() { + IrisStaticObject object = validObject(); + object.validate(-64, 320); + IrisObjectPlacement placement = object.toPlacement(); + + assertEquals(List.of("landmarks/tower"), placement.getPlace()); + assertEquals(ObjectPlaceMode.STRUCTURE_PIECE, placement.getMode()); + assertTrue(placement.isForcePlace()); + assertFalse(placement.getRotation().isEnabled()); + assertFalse(placement.getScale().shouldScale()); + assertEquals(new IrisPosition(100, 100, -100), object.getPosition()); + } + + @Test + public void fixedRotationsPreserveSignedFractionalAnglesForEverySpin() { + IrisStaticObject object = validObject().setRotation(new IrisStaticObjectRotation() + .setX(-22.5).setY(90).setZ(-180)); + object.validate(-64, 320); + IrisObjectRotation rotation = object.toPlacement().getRotation(); + + for (int spin : new int[]{0, 17, -222, 359}) { + assertEquals(Math.toRadians(-22.5), rotation.getXRotation(spin), 0D); + assertEquals(Math.toRadians(90), rotation.getYRotation(spin), 0D); + assertEquals(Math.toRadians(-180), rotation.getZRotation(spin), 0D); + } + assertFalse(rotation.getXAxis().isForceLock()); + } + + @Test + public void placementsKeepEditsAndFixedScaleWithoutSharingMutableLists() { + IrisObjectReplace edit = new IrisObjectReplace().setFind(new KList<>(new IrisBlockData("stone"))); + IrisStaticObject object = validObject() + .setScale(2.5) + .setScaleInterpolation(IrisObjectPlacementScaleInterpolator.TRILINEAR) + .setEdit(new KList<>(edit)) + .setBore(true) + .setSmartBore(true); + object.validate(-64, 320); + IrisObjectPlacement placement = object.toPlacement(); + + assertEquals(2.5, placement.getScale().getSize(), 0D); + assertEquals(IrisObjectPlacementScaleInterpolator.TRILINEAR, placement.getScale().getInterpolation()); + assertTrue(placement.isBore()); + assertTrue(placement.isSmartBore()); + assertEquals(object.getEdit(), placement.getEdit()); + assertNotSame(object.getEdit(), placement.getEdit()); + placement.getEdit().clear(); + assertEquals(1, object.getEdit().size()); + } + + @Test + public void rejectsMissingFieldsInvalidTransformsAndOutOfWorldOrigins() { + assertThrows(IllegalArgumentException.class, () -> new IrisStaticObject().validate(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> validObject().setPosition(null).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> validObject().setScale(Double.NaN).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> validObject().setScale(0).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> validObject().setScale(51).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, () -> validObject().setRotation(null).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, + () -> validObject().setRotation(new IrisStaticObjectRotation().setY(-361)).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, + () -> validObject().setPosition(new IrisPosition(0, 320, 0)).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, + () -> validObject().setPosition(new IrisPosition(-29_999_985, 0, 0)).validate(-64, 320)); + assertThrows(IllegalArgumentException.class, + () -> validObject().setEdit(new KList<>(new IrisObjectReplace())).validate(-64, 320)); + } + + @Test + public void jsonRoundTripKeepsTheLongSeedAndAllFixedSettings() { + IrisStaticObject source = validObject().setSeed(Long.MAX_VALUE) + .setRotation(new IrisStaticObjectRotation().setY(-45.5)).setScale(0.5).setBore(true); + Gson gson = new Gson(); + IrisStaticObject copy = gson.fromJson(gson.toJson(source), IrisStaticObject.class); + + copy.validate(-64, 320); + assertEquals(source, copy); + assertEquals(Long.MAX_VALUE, copy.getSeed()); + } + + private static IrisStaticObject validObject() { + return new IrisStaticObject().setObject("landmarks/tower").setPosition(new IrisPosition(100, 100, -100)); + } +}