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)); + } +}