From 0d464aabf3498adf8caf4225743d2cbf7703af60 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 04:54:35 -0400 Subject: [PATCH] Gate pools, placements and structures - Filter excluded biomes, regions, spawners, loot tables, jigsaw pieces and structures out of every pool through CompatPools and cascade empty pools to the container - Evaluate each object placement per object from the .iob palette header, honouring chance-1 edit rules and reporting every missing palette key - Treat edit find lists and marker mark lists as match-only - Exclude jigsaw pieces on missing palette keys, drop them from pools and exclude structures whose start pool is gone - Drop custom biome spawns whose entity is missing before the datapack JSON is written - Skip excluded placements in the object, floating object, tree and locator paths while keeping the per-chunk RNG draws unchanged - Keep the engine focus biome and region null when the gate excluded them --- .../iris/core/IrisDatapackCompiler.java | 4 +- .../art/arcane/iris/core/service/TreeSVC.java | 22 +- .../art/arcane/iris/engine/IrisComplex.java | 29 +- .../iris/engine/MarkerSpawnScanner.java | 6 + .../iris/engine/WorldEntitySpawner.java | 3 +- .../arcane/iris/engine/framework/Engine.java | 13 +- .../iris/engine/framework/HintedLocator.java | 9 +- .../framework/IrisStructureLocator.java | 4 + .../engine/framework/StructureAssembler.java | 8 + .../hydrology/policy/RiverPolicyResolver.java | 46 ++- .../MantleFloatingObjectComponent.java | 12 + .../components/MantleObjectComponent.java | 16 +- .../iris/engine/object/CompatPools.java | 219 ++++++++++++++ .../arcane/iris/engine/object/IrisBiome.java | 10 +- .../iris/engine/object/IrisBiomeCustom.java | 16 + .../object/IrisDimensionCarvingEntry.java | 9 +- .../iris/engine/object/IrisEntitySpawn.java | 13 +- .../object/IrisFloatingChildBiomes.java | 5 + .../iris/engine/object/IrisJigsawPiece.java | 42 +++ .../iris/engine/object/IrisJigsawPool.java | 75 +++++ .../iris/engine/object/IrisLootReference.java | 19 +- .../iris/engine/object/IrisLootTable.java | 21 ++ .../arcane/iris/engine/object/IrisMod.java | 46 +++ .../iris/engine/object/IrisObjectMarker.java | 2 +- .../engine/object/IrisObjectPlacement.java | 284 +++++++++++++++++- .../object/IrisObjectPlacementRunner.java | 2 +- .../iris/engine/object/IrisObjectReplace.java | 91 +++++- .../arcane/iris/engine/object/IrisRegion.java | 95 +++--- .../iris/engine/object/IrisRiverPolicy.java | 14 + .../iris/engine/object/IrisSpawner.java | 65 ++++ .../iris/engine/object/IrisStructure.java | 42 +++ .../engine/object/IrisStructurePlacement.java | 21 ++ .../StructureAssemblerCompatTest.java | 145 +++++++++ .../iris/engine/object/CompatFixtures.java | 84 ++++++ .../engine/object/CompatTestPlatform.java | 254 ++++++++++++++++ .../object/IrisBiomeCompatChildrenTest.java | 95 ++++++ .../object/IrisBiomeCustomCompatTest.java | 91 ++++++ .../object/IrisBiomeDerivativeCompatTest.java | 34 +++ .../object/IrisDimensionCompatRegionTest.java | 92 ++++++ .../engine/object/IrisJigsawCompatTest.java | 158 ++++++++++ .../object/IrisLootTableCompatTest.java | 101 +++++++ .../object/IrisObjectPlacementCompatTest.java | 282 +++++++++++++++++ .../object/IrisRegionCompatPoolTest.java | 142 +++++++++ .../engine/object/IrisSpawnerCompatTest.java | 126 ++++++++ 44 files changed, 2774 insertions(+), 93 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/CompatPools.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index feade6483..3f52fa5d5 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -2,6 +2,7 @@ package art.arcane.iris.core; import art.arcane.iris.BuildConstants; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.datapack.DataVersion; @@ -235,6 +236,7 @@ public final class IrisDatapackCompiler { fingerprintContent(dimension.getDimensionType().toJson(fixer))); String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT); + ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate(); for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) { if (biome == null || !biome.isCustom()) { continue; @@ -250,7 +252,7 @@ public final class IrisDatapackCompiler { } requirements.put( "worldgen/biome/" + biomeKey, - fingerprintContent(customBiome.generateJson(fixer))); + fingerprintContent(customBiome.generateJson(fixer, contentGate))); TreeSet tags = new TreeSet<>(); for (String tag : customBiome.getEffectiveTags(derivativeKey)) { String normalizedTag = normalizeRegistryKey(tag); diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index 5dcd00d24..ce8b75c91 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -142,8 +142,19 @@ public class TreeSVC implements IrisService { return; } + KList pool = placement.compatPlace(worldAccess.getData()); + + if (pool.isEmpty()) { + IrisLogging.debug("Tree placement has no object available on this Minecraft version"); + return; + } + saplingPlane.forEach(block -> block.setType(Material.AIR)); - IrisObject object = worldAccess.getData().getObjectLoader().load(placement.getPlace().getRandom(RNG.r)); + IrisObject object = worldAccess.getData().getObjectLoader().load(pool.getRandom(RNG.r)); + + if (object == null) { + return; + } String treeMarker = object.getLoadKey() + "@" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE); List blockStateList = new KList<>(); KMap dataCache = new KMap<>(); @@ -311,12 +322,12 @@ public class TreeSVC implements IrisService { // Retrieve objectPlacements of type `species` from biome IrisBiome biome = worldAccess.getEngine().getBiome(location.getBlockX(), location.getBlockY()-worldAccess.getTarget().getWorld().minHeight(), location.getBlockZ()); - placements.addAll(matchObjectPlacements(biome.getObjects(), size, type)); + placements.addAll(matchObjectPlacements(biome.getObjects(), size, type, worldAccess.getData())); // Add more or find any in the region if (isUseAll || placements.isEmpty()) { IrisRegion region = worldAccess.getEngine().getRegion(location.getBlockX(), location.getBlockZ()); - placements.addAll(matchObjectPlacements(region.getObjects(), size, type)); + placements.addAll(matchObjectPlacements(region.getObjects(), size, type, worldAccess.getData())); } // Check if no matches were found, return a random one if they are @@ -329,14 +340,15 @@ public class TreeSVC implements IrisService { * @param objects The object placements to check * @param size The size of the sapling area to filter with * @param type The type of the tree to filter with + * @param data The pack, for the version-content gate * @return A list of objectPlacements that matched. May be empty. */ - private KList matchObjectPlacements(KList objects, IrisTreeSize size, TreeType type) { + private KList matchObjectPlacements(KList objects, IrisTreeSize size, TreeType type, IrisData data) { KList p = new KList<>(); for (IrisObjectPlacement i : objects) { - if (i.matches(size, type)) { + if (i.matches(size, type) && !i.isCompatExcluded(data)) { p.add(i); } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index d4e92e1a5..d3df023f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; @@ -178,8 +179,9 @@ public class IrisComplex implements DataProvider { fluidHeight = engine.getDimension().getFluidHeight(); generators = new HashMap<>(); generatorBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); - focusBiome = engine.getFocus(); - focusRegion = engine.getFocusRegion(); + // A registrant the version-content gate excluded never enters a pool, focus included. + focusBiome = compatUsable(engine.getFocus()); + focusRegion = compatUsable(engine.getFocusRegion()); Map> inferredStreams = new HashMap<>(); KList preparedRegions = new KList<>(); @@ -195,7 +197,7 @@ public class IrisComplex implements DataProvider { } else { engine.getDimension().getRegions().forEach(regionKey -> { IrisRegion region = data.getRegionLoader().load(regionKey); - if (region == null) { + if (region == null || region.isCompatExcluded()) { return; } prepareInferredBiomes(region, preparedRegions); @@ -244,7 +246,7 @@ public class IrisComplex implements DataProvider { ProceduralStream.of((x, z) -> focusRegion, Interpolated.of(a -> 0D, a -> focusRegion)) : regionStyleStream - .selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions())) + .selectRarity(compatRegionPool(engine)) .cache2D("regionStream", engine, cacheSize); regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream .convertAware2D((region, x, z) -> { @@ -1083,11 +1085,30 @@ public class IrisComplex implements DataProvider { private KList loadInferredBiomes(KList keys, InferredType type) { KList inferred = new KList<>(); for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + // Excluded biomes cannot generate on this Minecraft version, so they leave the selection pool. + if (biome.isCompatExcluded()) { + continue; + } inferred.add(biome.withInferredType(type)); } return inferred; } + /** The dimension's region pool with regions the version-content gate excluded removed. */ + private KList compatRegionPool(Engine engine) { + KList pool = new KList<>(); + for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) { + if (!region.isCompatExcluded()) { + pool.add(region); + } + } + return pool; + } + + private static T compatUsable(T registrant) { + return registrant == null || registrant.isCompatExcluded() ? null : registrant; + } + private void registerGenerators(IrisBiome biome) { generatorBiomes.add(biome); biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this))); diff --git a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java index 5abb03270..cf752e7d0 100644 --- a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java +++ b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java @@ -83,6 +83,9 @@ final class MarkerSpawnScanner { IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); continue; } + if (m.isCompatExcluded()) { + continue; + } m.setReferenceMarker(mark); // This is so fucking incorrect its a joke @@ -182,6 +185,9 @@ final class MarkerSpawnScanner { IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); continue; } + if (spawner.isCompatExcluded()) { + continue; + } spawner.setReferenceMarker(mark); data.spawners.add(spawner); } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java index acb9f6b27..e5a36ffdd 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java @@ -299,7 +299,8 @@ final class WorldEntitySpawner { } //@builder - Predicate filter = i -> i.canSpawn(manager.getEngine(), c.getX(), c.getZ()); + // Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool. + Predicate filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ()); ChunkCounter counter = new ChunkCounter(c.getEntities()); IrisBiome biome = EngineBukkitOps.getSurfaceBiome(manager.getEngine(), c); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index b7c508ca7..4f7560f23 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -502,7 +502,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisRegion region = getRegion(x, z); for (IrisObjectPlacement i : region.getObjects()) { - if (i.getPlace().contains(object)) { + if (i.compatPlace(getData()).contains(object)) { return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z); } } @@ -510,7 +510,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisBiome biome = getSurfaceBiome(x, z); for (IrisObjectPlacement i : biome.getObjects()) { - if (i.getPlace().contains(object)) { + if (i.compatPlace(getData()).contains(object)) { return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z); } } @@ -526,19 +526,20 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return false; } + IrisData data = getData(); Set biomeKeys = getDimension().getReachableBiomes(this).stream() - .filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey)) + .filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey, data)) .map(IrisRegistrant::getLoadKey) .collect(Collectors.toSet()); Set regionKeys = getDimension().getAllRegions(this).stream() .filter((i) -> i.getAllBiomeIds().stream().anyMatch(biomeKeys::contains) - || containsObjectPlacement(i.getObjects(), normalizedObjectKey)) + || containsObjectPlacement(i.getObjects(), normalizedObjectKey, data)) .map(IrisRegistrant::getLoadKey) .collect(Collectors.toSet()); return !regionKeys.isEmpty(); } - private static boolean containsObjectPlacement(KList placements, String normalizedObjectKey) { + private static boolean containsObjectPlacement(KList placements, String normalizedObjectKey, IrisData data) { if (placements == null || placements.isEmpty() || normalizedObjectKey.isBlank()) { return false; } @@ -548,7 +549,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, continue; } - for (String placedObject : placement.getPlace()) { + for (String placedObject : placement.compatPlace(data)) { String normalizedPlacedObject = normalizeObjectPlacementKey(placedObject); if (!normalizedPlacedObject.isBlank() && normalizedPlacedObject.equals(normalizedObjectKey)) { return true; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index f0bd5a62b..00bec61ba 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; @@ -410,7 +411,7 @@ public final class HintedLocator implements Locator { continue; } - if (placesObject(region.getSurfaceObjects(), objectKey)) { + if (placesObject(region.getSurfaceObjects(), objectKey, engine.getData())) { directRegions.add(region.getLoadKey()); } @@ -511,9 +512,9 @@ public final class HintedLocator implements Locator { }; } - private static boolean placesObject(KList placements, String objectKey) { + private static boolean placesObject(KList placements, String objectKey, IrisData data) { for (IrisObjectPlacement placement : placements) { - if (placement != null && placement.getPlace().contains(objectKey)) { + if (placement != null && placement.compatPlace(data).contains(objectKey)) { return true; } } @@ -637,7 +638,7 @@ public final class HintedLocator implements Locator { continue; } - if (placesObject(biome.getSurfaceObjects(), objectKey)) { + if (placesObject(biome.getSurfaceObjects(), objectKey, engine.getData())) { return true; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 0a920b312..5d99e823b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -507,6 +507,10 @@ public final class IrisStructureLocator { throw new IllegalStateException("Iris structure placement references missing structure '" + selectedKey + "'"); } + if (structure.isCompatExcluded()) { + // Gated out on this Minecraft version; the pack report already says why. + return null; + } StructureGraphCompilation compilation = StructureGraphCatalog.compile(engine.getData(), structure); if (!compilation.isAssemblyViable()) { throw new IllegalStateException("Iris structure '" + selectedKey diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java index 3ff75d307..fb7d048d2 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java @@ -1179,6 +1179,11 @@ public final class StructureAssembler { if (pool.getPieces() == null) { throw assemblyFailure("pool has no piece list"); } + // A pool the version-content gate excluded has nothing left to place: treat it as empty so the branch + // terminates or falls back, instead of failing the whole assembly. + if (pool.isCompatExcluded()) { + return List.of(); + } List eligible = new ArrayList<>(); List required = new ArrayList<>(); for (IrisJigsawPieceEntry entry : pool.getPieces()) { @@ -1199,6 +1204,9 @@ public final class StructureAssembler { if (piece == null) { throw assemblyFailure("pool references missing piece '" + pieceKey + "'"); } + if (piece.isCompatExcluded()) { + continue; + } int placements = placementCounts.getOrDefault(pieceKey, 0); if (!pieceEnabled(piece) || terminalOnly && !piece.resolvedRules().isTerminal() diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java index c521f949e..8d401f2f4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology.policy; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; @@ -17,7 +19,8 @@ public final class RiverPolicyResolver { return resolve( dimension == null ? null : dimension.getRiverPolicy(), region == null ? null : region.getRiverPolicy(), - biome == null ? null : biome.getRiverPolicy() + biome == null ? null : biome.getRiverPolicy(), + loaderOf(biome, region, dimension) ); } @@ -25,14 +28,37 @@ public final class RiverPolicyResolver { IrisRiverPolicy dimensionPolicy, IrisRiverPolicy regionPolicy, IrisRiverPolicy biomePolicy + ) { + return resolve(dimensionPolicy, regionPolicy, biomePolicy, null); + } + + /** + * @param data pack the policies came from, used to drop river biome references the version-content gate excluded. + * Null skips that filtering. + */ + public static EffectiveRiverPolicy resolve( + IrisRiverPolicy dimensionPolicy, + IrisRiverPolicy regionPolicy, + IrisRiverPolicy biomePolicy, + IrisData data ) { State state = new State(); - state.apply(dimensionPolicy); - state.apply(regionPolicy); - state.apply(biomePolicy); + state.apply(dimensionPolicy, data); + state.apply(regionPolicy, data); + state.apply(biomePolicy, data); return state.build(); } + private static IrisData loaderOf(IrisRegistrant... registrants) { + for (IrisRegistrant registrant : registrants) { + if (registrant != null && registrant.getLoader() != null) { + return registrant.getLoader(); + } + } + + return null; + } + private static final class State { private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL; private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW; @@ -50,7 +76,7 @@ public final class RiverPolicyResolver { private double routingMultiplier = 1D; private double bankMultiplier = 1D; - private void apply(IrisRiverPolicy policy) { + private void apply(IrisRiverPolicy policy, IrisData data) { if (policy == null) { return; } @@ -67,19 +93,19 @@ public final class RiverPolicyResolver { profiles = List.copyOf(policy.getProfiles()); } if (policy.getSurfaceBiomes() != null) { - surfaceBiomes = List.copyOf(policy.getSurfaceBiomes()); + surfaceBiomes = List.copyOf(policy.compatBiomes(policy.getSurfaceBiomes(), data, "surfaceBiomes")); } if (policy.getMouthBiomes() != null) { - mouthBiomes = List.copyOf(policy.getMouthBiomes()); + mouthBiomes = List.copyOf(policy.compatBiomes(policy.getMouthBiomes(), data, "mouthBiomes")); } if (policy.getShoreBiomes() != null) { - shoreBiomes = List.copyOf(policy.getShoreBiomes()); + shoreBiomes = List.copyOf(policy.compatBiomes(policy.getShoreBiomes(), data, "shoreBiomes")); } if (policy.getBankBiomes() != null) { - bankBiomes = List.copyOf(policy.getBankBiomes()); + bankBiomes = List.copyOf(policy.compatBiomes(policy.getBankBiomes(), data, "bankBiomes")); } if (policy.getFloodedCaveBiomes() != null) { - floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes()); + floodedCaveBiomes = List.copyOf(policy.compatBiomes(policy.getFloodedCaveBiomes(), data, "floodedCaveBiomes")); } if (policy.getSurfacePools() != null) { surfacePools = List.copyOf(policy.getSurfacePools()); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index c1f611cc4..192ed44f3 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -171,6 +171,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); for (int i = 0; i < density; i++) { @@ -217,6 +221,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); @@ -302,6 +310,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index fe2cad55f..a49bd7d8a 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -417,7 +417,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (IrisObjectPlacement i : surfaceBiome.getSurfaceObjects()) { biomeSurfaceChecked++; - boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)); + boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=biome-surface" @@ -450,7 +450,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } biomeCaveChecked++; - boolean chance = rng.chance(i.getChance()); + boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=biome-cave" @@ -480,7 +480,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (IrisObjectPlacement i : region.getSurfaceObjects()) { regionSurfaceChecked++; - boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)); + boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=region-surface" @@ -513,7 +513,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } regionCaveChecked++; - boolean chance = rng.chance(i.getChance()); + boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=region-cave" @@ -1182,7 +1182,7 @@ public class MantleObjectComponent extends IrisMantleComponent { boolean forcePlace = dimension.isUpperObjectsForcePlace(); if (upperBiome != null) { for (IrisObjectPlacement i : upperBiome.getSurfaceObjects()) { - if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) { continue; } try { @@ -1197,7 +1197,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (upperRegion != null) { for (IrisObjectPlacement i : upperRegion.getSurfaceObjects()) { - if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) { continue; } try { @@ -1635,13 +1635,13 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisRegion region = getEngineMantle().getEngine().getRegion((x << 4) + 8, (z << 4) + 8); Set v = new KSet<>(); for (IrisObjectPlacement i : biome.getSurfaceObjects()) { - if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) { v.addAll(guessPlacedKeys(rng, x, z, i)); } } for (IrisObjectPlacement i : region.getSurfaceObjects()) { - if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) { v.addAll(guessPlacedKeys(rng, x, z, i)); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java new file mode 100644 index 000000000..186f95436 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java @@ -0,0 +1,219 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; + +import java.util.ArrayList; +import java.util.List; + +/** + * Registrant reference pools filtered by the version-content gate. A reference to a registrant the gate EXCLUDED is + * dropped from every pool that could pick it and reported once against the containing registrant; a container whose + * required pool ends up empty cascades to EXCLUDED through {@link #cascade}. Cascade and drop findings reuse the + * registry and key of the cause so the console groups everything under the one missing content key. + *

+ * Surviving entries keep their declaration order, so only the removed entries change what a pool picks. + */ +final class CompatPools { + private CompatPools() { + } + + static boolean excluded(IrisRegistrant registrant) { + return registrant != null && registrant.isCompatExcluded(); + } + + /** + * Loads a registrant reference pool, skipping entries that fail to load and entries the gate excluded. Each + * excluded reference produces one DROPPED finding in {@code sink} (when given) and in the pack report. + * + * @param field the JSON field the keys came from, used for the finding detail ({@code landBiomes[2] key}) + */ + static KList load(ResourceLoader loader, + List keys, + IrisData data, + String subjectType, + String subjectKey, + String field, + List sink) { + KList kept = new KList<>(); + + if (loader == null || keys == null) { + return kept; + } + + for (int index = 0; index < keys.size(); index++) { + String key = keys.get(index); + T loaded = loader.load(key); + + if (loaded == null) { + continue; + } + + if (loaded.isCompatExcluded()) { + drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, sink); + continue; + } + + kept.add(loaded); + } + + return kept; + } + + /** The surviving keys of a reference pool, in declaration order. */ + static KList surviving(ResourceLoader loader, + List keys, + IrisData data, + String subjectType, + String subjectKey, + String field) { + KList kept = new KList<>(); + + if (keys == null) { + return kept; + } + + if (loader == null) { + kept.addAll(keys); + return kept; + } + + for (int index = 0; index < keys.size(); index++) { + String key = keys.get(index); + T loaded = loader.load(key); + + if (loaded != null && loaded.isCompatExcluded()) { + drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, null); + continue; + } + + kept.add(key); + } + + return kept; + } + + /** + * Records one DROPPED finding for a reference to an excluded registrant, reusing the registry and key of the + * child's exclusion cause. Returns null when the child carries no cause to attribute the drop to. + */ + static CompatFinding drop(IrisData data, + IrisRegistrant child, + String subjectType, + String subjectKey, + String detail, + List sink) { + CompatFinding cause = causeOf(child == null ? null : child.getCompat()); + + if (cause == null) { + return null; + } + + CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.DROPPED, + subjectType, subjectKey, detail); + record(data, finding); + + if (sink != null) { + sink.add(finding); + } + + return finding; + } + + /** + * The EXCLUDED status for a container whose required pool lost every entry. Returns {@code base} untouched when no + * cause was collected, so a container that never declared the pool is not excluded for being empty. + */ + static CompatStatus cascade(IrisData data, + CompatStatus base, + List causes, + String subjectType, + String subjectKey, + String detail) { + if (causes == null || causes.isEmpty()) { + return base; + } + + CompatFinding cause = causes.getFirst(); + CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED, + subjectType, subjectKey, detail); + record(data, finding); + + List reasons = new ArrayList<>(base == null ? List.of() : base.reasons()); + reasons.add(finding); + return CompatStatus.excludedBy(reasons); + } + + /** The findings on a status that dropped an entry, used as the cause set of a cascade. */ + static List droppedReasons(CompatStatus status) { + List dropped = new ArrayList<>(); + + if (status == null) { + return dropped; + } + + for (CompatFinding reason : status.reasons()) { + if (reason.action() == CompatAction.DROPPED) { + dropped.add(reason); + } + } + + return dropped; + } + + static void record(IrisData data, CompatFinding finding) { + if (data == null || finding == null) { + return; + } + + try { + data.getCompatReport().record(finding); + } catch (Throwable ignored) { + // A report is best effort: never let reporting break a pool build. + } + } + + /** The finding that explains an exclusion: the EXCLUDED reason if there is one, otherwise the first reason. */ + private static CompatFinding causeOf(CompatStatus status) { + if (status == null) { + return null; + } + + CompatFinding fallback = null; + + for (CompatFinding reason : status.reasons()) { + if (reason.action() == CompatAction.EXCLUDED) { + return reason; + } + + if (fallback == null) { + fallback = reason; + } + } + + return fallback; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 985dcae2f..2db10c7da 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -559,13 +559,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { public KList getRealChildren(DataProvider g) { return realChildren.aquire(() -> { - KList realChildren = new KList<>(); - - for (String i : getChildren()) { - realChildren.add(g.getData().getBiomeLoader().load(i)); - } - - return realChildren; + IrisData data = g == null ? null : g.getData(); + return CompatPools.load(data == null ? null : data.getBiomeLoader(), getChildren(), data, + "biome", getLoadKey(), "children", null); }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java index 2e61d0b3f..aba7b5b05 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.KeyStatus; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.DependsOn; @@ -132,6 +134,16 @@ public class IrisBiomeCustom { } public String generateJson(IDataFixer fixer) { + return generateJson(fixer, null); + } + + /** + * @param gate version-content gate used to keep spawns whose entity type is missing on this server out of the + * generated datapack (the game rejects a datapack that names an unknown entity). Null consults the + * live platform registries; an unreadable registry keeps every spawn. + */ + public String generateJson(IDataFixer fixer, ContentGate gate) { + ContentGate contentGate = gate == null ? ContentGate.forData(null) : gate; JSONObject effects = new JSONObject(); effects.put("sky_color", parseColor(getSkyColor())); effects.put("fog_color", parseColor(getFogColor())); @@ -196,6 +208,10 @@ public class IrisBiomeCustom { IrisLogging.warn("Skipping custom biome spawn with null entity type in biome " + getId()); continue; } + if (contentGate.entity(key) == KeyStatus.MISSING) { + IrisLogging.debug("Dropping custom biome spawn " + key + " in biome " + getId() + ": entity missing on this server"); + continue; + } IrisBiomeCustomSpawnType group = i.getGroup() == null ? IrisBiomeCustomSpawnType.MISC : i.getGroup(); JSONArray g = groups.computeIfAbsent(group, (k) -> new JSONArray()); JSONObject o = new JSONObject(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java index 7dde2e11c..129c6a563 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java @@ -63,7 +63,14 @@ public class IrisDimensionCarvingEntry { return null; } - return data.getBiomeLoader().load(biomeKey.trim()); + IrisBiome loaded = data.getBiomeLoader().load(biomeKey.trim()); + + if (loaded != null && loaded.isCompatExcluded()) { + CompatPools.drop(data, loaded, "carving entry", getId(), "biome " + biomeKey.trim(), null); + return null; + } + + return loaded; }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index a9ccde1c6..1c4e24062 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.data.cache.AtomicCache; @@ -147,7 +148,17 @@ public class IrisEntitySpawn implements IRare { } public IrisEntity getRealEntity(Engine g) { - return ent.aquire(() -> g.getData().getEntityLoader().load(getEntity())); + return ent.aquire(() -> resolveEntity(g.getData())); + } + + /** The referenced entity, or null when it does not load or the version-content gate excluded it. */ + IrisEntity resolveEntity(IrisData data) { + if (data == null || data.getEntityLoader() == null) { + return null; + } + + IrisEntity entity = data.getEntityLoader().load(getEntity()); + return entity == null || entity.isCompatExcluded() ? null : entity; } public Entity spawn(Engine g, Location at) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java index 6c0df7bdf..bca701a99 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java @@ -358,6 +358,11 @@ public class IrisFloatingChildBiomes implements IRare { return parent; } + if (loaded.isCompatExcluded()) { + CompatPools.drop(data, loaded, "floating child biome", parent.getLoadKey(), "biome " + biome, null); + return parent; + } + return loaded; }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java index a8e943bb3..9203dffb4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -81,6 +87,42 @@ public class IrisJigsawPiece extends IrisRegistrant { return rules == null ? new IrisJigsawPieceRules() : rules; } + /** + * A piece is one object stamped verbatim - nothing rewrites its blocks - so a palette key the server does not + * have (after the pack's declared fallbacks) takes the whole piece out of every pool that lists it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || object == null || object.isBlank()) { + return base; + } + + for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(object.trim()))) { + String key = ContentGate.baseKey(ContentGate.normalizeState(paletteKey)); + + if (key == null || gate.resolveBlock(key) != null) { + continue; + } + + CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.EXCLUDED, + "jigsaw piece", getLoadKey(), "object " + object.trim()); + gate.report().record(finding); + KList reasons = new KList<>(base.reasons()); + reasons.add(finding); + return CompatStatus.excludedBy(reasons); + } + + return base; + } + @Override public String getFolderName() { return "jigsaw-pieces"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java index 849e3655d..76862e026 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -51,6 +57,75 @@ public class IrisJigsawPool extends IrisRegistrant { return structureRequiresCaps || mandatoryFallback; } + /** + * Excluded pieces drop out of the pool; a pool with nothing left to place is excluded in turn so every connector + * that targets it terminates instead of failing assembly. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || pieces == null || pieces.isEmpty()) { + return base; + } + + KList reasons = new KList<>(base.reasons()); + CompatFinding cause = null; + int usable = 0; + + for (int i = 0; i < pieces.size(); i++) { + IrisJigsawPieceEntry entry = pieces.get(i); + + if (entry == null) { + continue; + } + + String key = entry.getPiece() == null ? "" : entry.getPiece().trim(); + + // An empty membership terminates a branch on purpose, and a piece key that does not resolve at all is a + // pack error the loader already reports - neither is a version-content problem. + if (entry.isEmpty() || key.isEmpty()) { + usable++; + continue; + } + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, key, false); + + if (piece == null || !piece.isCompatExcluded()) { + usable++; + continue; + } + + CompatFinding reason = piece.getCompat().reasons().isEmpty() ? null : piece.getCompat().reasons().getFirst(); + CompatFinding dropped = new CompatFinding( + reason == null ? CompatRegistry.BLOCK : reason.registry(), + reason == null ? key : reason.key(), + CompatAction.DROPPED, "jigsaw pool", getLoadKey(), "pieces[" + i + "] " + key); + gate.report().record(dropped); + reasons.add(dropped); + + if (cause == null) { + cause = dropped; + } + } + + if (usable > 0 || cause == null) { + return new CompatStatus(false, reasons); + } + + CompatFinding excluded = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED, + "jigsaw pool", getLoadKey(), "no pieces remain"); + gate.report().record(excluded); + reasons.add(excluded); + return CompatStatus.excludedBy(reasons); + } + @Override public String getFolderName() { return "jigsaw-pools"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java index 189876a40..457785fac 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -57,9 +58,25 @@ public class IrisLootReference { return tt.aquire(() -> { KList t = new KList<>(); + IrisData data = g == null ? null : g.getData(); + + if (data == null || data.getLootLoader() == null) { + return t; + } for (String i : tables) { - t.add(g.getData().getLootLoader().load(i)); + IrisLootTable table = data.getLootLoader().load(i); + + if (table == null) { + continue; + } + + if (table.isCompatExcluded()) { + CompatPools.drop(data, table, "loot reference", table.getLoadKey(), "tables " + i, null); + continue; + } + + t.add(table); } return t; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java index 692f18b55..4db606ea0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -103,6 +105,25 @@ public class IrisLootTable extends IrisRegistrant { return lootf; } + /** + * Cascade: the gate walker drops loot entries whose item is missing on this server. A table with nothing left to + * roll leaves every reference pool that could pick it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + return cascadeEmptyLoot(super.evaluateCompat(gate)); + } + + /** EXCLUDED when the walker dropped every loot entry; the walker's verdict is passed in as {@code base}. */ + CompatStatus cascadeEmptyLoot(CompatStatus base) { + if (base.excluded() || !loot.isEmpty()) { + return base; + } + + return CompatPools.cascade(getLoader(), base, CompatPools.droppedReasons(base), + "loot", getLoadKey(), "no loot entries remain"); + } + @Override public String getFolderName() { return "loot"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java index d8c40fc89..081de8ee3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java @@ -18,6 +18,9 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -104,6 +107,49 @@ public class IrisMod extends IrisRegistrant { @Desc("Replace noise styles with other styles") private KList styleReplacers = new KList<>(); + /** + * A pack modification only references other registrants, so it never cascades: excluded biome and region + * references are dropped from its lists and reported, and the modification keeps applying whatever is left. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + IrisData data = getLoader(); + + if (data == null) { + return base; + } + + removeBiomes = biomes(data, removeBiomes, "removeBiomes"); + removeRegions = regions(data, removeRegions, "removeRegions"); + injectRegions = regions(data, injectRegions, "injectRegions"); + + for (int index = 0; index < biomeInjectors.size(); index++) { + IrisModBiomeInjector injector = biomeInjectors.get(index); + injector.setInject(biomes(data, injector.getInject(), "biomeInjectors[" + index + "].inject")); + } + + for (int index = 0; index < biomeReplacers.size(); index++) { + IrisModBiomeReplacer replacer = biomeReplacers.get(index); + replacer.setFind(biomes(data, replacer.getFind(), "biomeReplacers[" + index + "].find")); + } + + for (int index = 0; index < regionReplacers.size(); index++) { + IrisModRegionReplacer replacer = regionReplacers.get(index); + replacer.setFind(regions(data, replacer.getFind(), "regionReplacers[" + index + "].find")); + } + + return base; + } + + private KList biomes(IrisData data, KList keys, String field) { + return CompatPools.surviving(data.getBiomeLoader(), keys, data, "mod", getLoadKey(), field); + } + + private KList regions(IrisData data, KList keys, String field) { + return CompatPools.surviving(data.getRegionLoader(), keys, data, "mod", getLoadKey(), field); + } + @Override public String getFolderName() { return "mods"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java index d89c2d7db..2d4ffc1cd 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java @@ -63,7 +63,7 @@ public class IrisObjectMarker { KList b = new KList<>(); for (IrisBlockData i : mark) { - PlatformBlockState bx = i.getBlockData(rdata); + PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata); if (bx != null) { b.add(bx); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index d070edd45..da38d8ef5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -20,6 +20,12 @@ package art.arcane.iris.engine.object; import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.cache.LazyBoundedCache; @@ -181,6 +187,274 @@ public class IrisObjectPlacement { @SerializedName(value = "forcePlace", alternate = {"force"}) private boolean forcePlace = false; private transient AtomicCache cache = new AtomicCache<>(); + private transient AtomicCache compatCache = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile KList compatPlaceCache; + + /** + * Version-content gate verdict for this placement on the running server. The placement's own generated blocks + * (every {@code edit[].replace} palette option) exclude the whole placement when one of them is unavailable; each + * object in {@code place} is then dropped when its .iob palette carries a key that neither resolves nor is + * rewritten by a matching {@code edit} rule with chance 1. Match sides ({@code edit[].find}, {@code markers[].mark}) + * never count. Cached per placement. + */ + public CompatStatus evaluateCompat(IrisData data) { + return compatCache.aquire(() -> computeCompat(data)); + } + + /** True when nothing in this placement can generate on the running server. */ + public boolean isCompatExcluded(IrisData data) { + return evaluateCompat(data).excluded(); + } + + /** The {@code place} pool with objects that cannot generate on the running server removed. */ + public KList compatPlace(IrisData data) { + evaluateCompat(data); + KList survivors = compatPlaceCache; + return survivors == null ? place : survivors; + } + + private CompatStatus computeCompat(IrisData data) { + if (data == null) { + compatPlaceCache = place; + return CompatStatus.OK; + } + + ContentGate gate = data.getContentGate(); + PackCompatReport report = data.getCompatReport(); + + if (gate == null || !gate.ready()) { + // Registry not bound or not ready: UNKNOWN is never MISSING, so nothing is gated. + report.markIncomplete("registry not ready while evaluating object placements"); + compatPlaceCache = place; + return CompatStatus.OK; + } + + String subject = compatSubject(); + KList reasons = new KList<>(); + CompatFinding blocked = evaluatePlacementBlocks(data, gate, subject, reasons); + record(report, reasons); + + if (blocked != null) { + compatPlaceCache = new KList<>(); + return CompatStatus.excludedBy(reasons); + } + + KList survivors = new KList<>(place.size()); + CompatFinding firstDrop = null; + + for (int i = 0; i < place.size(); i++) { + String objectKey = place.get(i); + KList objectReasons = new KList<>(); + KList missing = unplaceableKeys(objectKey, data, gate, subject + " place[" + i + "]", objectReasons); + record(report, objectReasons); + reasons.addAll(objectReasons); + + if (missing.isEmpty()) { + survivors.add(objectKey); + continue; + } + + // One finding per missing key, so the operator sees every fallback the object needs at once. + for (String key : missing) { + CompatFinding dropped = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.DROPPED, + "object", objectKey, subject + " place[" + i + "]"); + report.record(dropped); + reasons.add(dropped); + + if (firstDrop == null) { + firstDrop = dropped; + } + } + } + + if (firstDrop != null && survivors.isEmpty()) { + CompatFinding excluded = new CompatFinding(firstDrop.registry(), firstDrop.key(), CompatAction.EXCLUDED, + "placement", subject, "no objects remain"); + report.record(excluded); + reasons.add(excluded); + compatPlaceCache = new KList<>(); + return CompatStatus.excludedBy(reasons); + } + + compatPlaceCache = survivors.size() == place.size() ? place : survivors; + return new CompatStatus(false, reasons); + } + + /** The subject a finding names: the single object, or the whole place list when there is more than one. */ + private String compatSubject() { + if (place.isEmpty()) { + return ""; + } + return place.size() == 1 ? place.getFirst() : place.toString(", "); + } + + /** + * The placement's own composition blocks: the {@code edit[].replace} palettes. {@code edit[].find} and + * {@code markers[].mark} are match sides and never count - matching a block the server does not have is harmless. + * + * @return the finding that excludes this placement, or null when every declared block resolves + */ + private CompatFinding evaluatePlacementBlocks(IrisData data, ContentGate gate, String subject, KList reasons) { + CompatFinding blocked = null; + + for (int i = 0; i < edit.size(); i++) { + IrisObjectReplace rule = edit.get(i); + + if (rule == null || rule.getReplace() == null) { + continue; + } + + KList palette = rule.getReplace().getPalette(); + + for (int j = 0; j < palette.size(); j++) { + CompatFinding finding = evaluateBlockEntry(palette.get(j), data, gate, subject, + "edit[" + i + "].replace.palette[" + j + "]", reasons); + + if (finding != null && blocked == null) { + blocked = finding; + } + } + } + + return blocked; + } + + /** Records what happened to one declared block entry; returns the finding when it excludes the placement. */ + private CompatFinding evaluateBlockEntry(IrisBlockData block, IrisData data, ContentGate gate, String subject, + String detail, KList reasons) { + if (block == null) { + return null; + } + + BlockCompat resolution = resolveBlockEntry(block, data, gate, new KList<>()); + + if (resolution.missing()) { + CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.EXCLUDED, + "placement", subject, detail); + reasons.add(finding); + return finding; + } + + if (resolution.substituted()) { + reasons.add(new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.SUBSTITUTED, + "placement", subject, detail + " (using " + resolution.replacement() + ")")); + } + + return null; + } + + /** + * Every distinct palette key of {@code objectKey} that cannot reach the world, in palette order; empty when the + * object still places. A key survives when the gate resolves it (dimension fallbacks count) or when an + * {@code edit} rule with chance 1 matches it - that rule's replace palette was already verified by + * {@link #evaluatePlacementBlocks}. A rule below chance 1 does not cover the key: the original block can still be + * rolled. + */ + private KList unplaceableKeys(String objectKey, IrisData data, ContentGate gate, String detail, KList reasons) { + KList missing = new KList<>(); + + // Palette header only - no IrisObject is built and no state is resolved. A legacy (V1) object reads as an + // empty palette and is therefore never dropped; V1 has no palette block to scan without a full load. + for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(objectKey))) { + String base = ContentGate.baseKey(ContentGate.normalizeState(paletteKey)); + + if (base == null) { + continue; + } + + // Existence is a registry question about the block, never about its properties: a property that was + // renamed on a block the server still has is out of scope for the gate. + ContentGate.BlockResolution resolution = gate.resolveBlock(base); + + if (resolution != null) { + if (resolution.substituted()) { + reasons.add(new CompatFinding(CompatRegistry.BLOCK, base, CompatAction.SUBSTITUTED, + "object", objectKey, detail + " (using " + resolution.resolvedKey() + ")")); + } + + continue; + } + + if (coveredByEdit(paletteKey)) { + continue; + } + + missing.addIfMissing(base); + } + + return missing; + } + + /** True when an {@code edit} rule certainly rewrites this palette state before it is written. */ + private boolean coveredByEdit(String paletteKey) { + for (IrisObjectReplace rule : edit) { + if (rule == null || rule.getChance() < 1F) { + continue; + } + + if (rule.matchesState(paletteKey)) { + return true; + } + } + + return false; + } + + /** + * Mirrors {@code IrisBlockData.getBlockData} against the gate: pack custom block first, then the registry with + * dimension fallbacks, then the entry's own {@code backup}. + */ + private static BlockCompat resolveBlockEntry(IrisBlockData block, IrisData data, ContentGate gate, KList seen) { + for (IrisBlockData visited : seen) { + if (visited == block) { + return new BlockCompat(String.valueOf(block.getBlock()), null, true, false); + } + } + + seen.add(block); + String declared = block.getBlock(); + IrisBlockData custom = data.getBlockLoader() == null ? null : data.getBlockLoader().load(declared, false); + + if (custom != null && custom != block) { + return resolveBlockEntry(custom, data, gate, seen); + } + + String base = ContentGate.baseKey(ContentGate.normalizeState(declared)); + + if (base == null) { + return new BlockCompat(String.valueOf(declared), null, true, false); + } + + ContentGate.BlockResolution resolution = gate.resolveBlock(base); + + if (resolution != null) { + return new BlockCompat(base, resolution.resolvedKey(), false, resolution.substituted()); + } + + IrisBlockData backup = block.getBackup(); + + if (backup != null) { + BlockCompat viaBackup = resolveBlockEntry(backup, data, gate, seen); + + if (!viaBackup.missing()) { + return new BlockCompat(base, viaBackup.replacement(), false, true); + } + } + + return new BlockCompat(base, null, true, false); + } + + private static void record(PackCompatReport report, KList findings) { + for (CompatFinding finding : findings) { + report.record(finding); + } + } + + /** One block entry's verdict: the key asked for, what will actually generate, and how it got there. */ + private record BlockCompat(String key, String replacement, boolean missing, boolean substituted) { + } public IrisObjectPlacement toPlacement(String... place) { IrisObjectPlacement p = new IrisObjectPlacement(); @@ -244,11 +518,14 @@ public class IrisObjectPlacement { } public IrisObject getObject(DataProvider g, RNG random) { - if (place.isEmpty()) { + IrisData data = g.getData(); + KList pool = compatPlace(data); + + if (pool.isEmpty()) { return null; } - return g.getData().getObjectLoader().load(place.get(random.nextInt(place.size()))); + return data.getObjectLoader().load(pool.get(random.nextInt(pool.size()))); } public boolean matches(IrisTreeSize size, TreeType type) { @@ -298,6 +575,9 @@ public class IrisObjectPlacement { IrisLogging.warn("Couldn't find loot table " + loot.getName()); continue; } + if (table.isCompatExcluded()) { + continue; + } if (loot.getFilter().isEmpty()) //Table applies to all containers { 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..9f27492e7 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 @@ -424,7 +424,7 @@ final class IrisObjectPlacementRunner { IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker()); int markerSalt = markerIndex++; - if (marker == null) { + if (marker == null || marker.isCompatExcluded()) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java index 38c621d5f..401882b08 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -35,6 +36,9 @@ import lombok.NoArgsConstructor; import art.arcane.iris.spi.PlatformBlockState; import lombok.experimental.Accessors; +import java.util.LinkedHashMap; +import java.util.Map; + @Snippet("object-block-replacer") @Accessors(chain = true) @NoArgsConstructor @@ -65,7 +69,7 @@ public class IrisObjectReplace { KList b = new KList<>(); for (IrisBlockData i : find) { - PlatformBlockState bx = i.getBlockData(rdata); + PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata); if (bx != null) { b.add(bx); @@ -79,4 +83,89 @@ public class IrisObjectReplace { public PlatformBlockState getReplace(RNG seed, double x, double y, double z, IrisData rdata) { return getReplace().get(seed, x, y, z, rdata); } + + /** + * Key-level mirror of the runtime find match, for the version-content gate: it has to decide whether a palette + * key the server does not have is rewritten by this rule, and a missing key has no resolved state to compare. + * Non-exact compares the base block; exact requires every property named by the find entry to match, the same + * partial-match contract {@code PlatformBlockState.matches} uses at runtime. + */ + public boolean matchesState(String stateKey) { + String state = ContentGate.normalizeState(stateKey); + + if (state == null) { + return false; + } + + String stateBase = ContentGate.baseKey(state); + + for (IrisBlockData f : find) { + if (f == null) { + continue; + } + + String findState = ContentGate.normalizeState(declaredState(f)); + + if (findState == null || !ContentGate.baseKey(findState).equals(stateBase)) { + continue; + } + + if (!exact || propertiesMatch(findState, state)) { + return true; + } + } + + return false; + } + + /** The find entry as one state string; {@code block} already carrying properties wins over the data map. */ + private static String declaredState(IrisBlockData block) { + String declared = block.getBlock(); + + if (declared == null) { + return null; + } + + return declared.indexOf('[') < 0 ? declared + block.computeProperties() : declared; + } + + /** Every property the find side names must be present with the same value on the candidate state. */ + private static boolean propertiesMatch(String findState, String candidateState) { + Map required = properties(findState); + + if (required.isEmpty()) { + return true; + } + + Map candidate = properties(candidateState); + + for (Map.Entry entry : required.entrySet()) { + if (!entry.getValue().equals(candidate.get(entry.getKey()))) { + return false; + } + } + + return true; + } + + private static Map properties(String state) { + int open = state.indexOf('['); + int close = state.lastIndexOf(']'); + + if (open < 0 || close < open) { + return Map.of(); + } + + Map properties = new LinkedHashMap<>(); + + for (String part : state.substring(open + 1, close).split(",")) { + int split = part.indexOf('='); + + if (split > 0) { + properties.put(part.substring(0, split).trim(), part.substring(split + 1).trim()); + } + } + + return properties; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 97b30d808..bddb10265 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -19,6 +19,9 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; @@ -44,6 +47,8 @@ import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import java.awt.Color; +import java.util.ArrayList; +import java.util.List; import java.util.Random; @@ -321,6 +326,11 @@ public class IrisRegion extends IrisRegistrant implements IRare { continue; } + if (biome.isCompatExcluded()) { + CompatPools.drop(g.getData(), biome, "region", getLoadKey(), "biomes " + i, null); + continue; + } + names.add(biome.getCarvingBiome()); b.put(biome.getLoadKey(), biome); names.addAll(biome.getChildren()); @@ -345,55 +355,26 @@ public class IrisRegion extends IrisRegistrant implements IRare { } public KList getRealCaveBiomes(DataProvider g) { - return realCaveBiomes.aquire(() -> - { - KList realCaveBiomes = new KList<>(); - - for (String i : getCaveBiomes()) { - realCaveBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realCaveBiomes; - }); + return realCaveBiomes.aquire(() -> resolvePool(g, getCaveBiomes(), "caveBiomes", null)); } public KList getRealShoreBiomes(DataProvider g) { - return realShoreBiomes.aquire(() -> - { - KList realShoreBiomes = new KList<>(); - - for (String i : getShoreBiomes()) { - realShoreBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realShoreBiomes; - }); + return realShoreBiomes.aquire(() -> resolvePool(g, getShoreBiomes(), "shoreBiomes", null)); } public KList getRealSeaBiomes(DataProvider g) { - return realSeaBiomes.aquire(() -> - { - KList realSeaBiomes = new KList<>(); - - for (String i : getSeaBiomes()) { - realSeaBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realSeaBiomes; - }); + return realSeaBiomes.aquire(() -> resolvePool(g, getSeaBiomes(), "seaBiomes", null)); } public KList getRealLandBiomes(DataProvider g) { - return realLandBiomes.aquire(() -> - { - KList realLandBiomes = new KList<>(); + return realLandBiomes.aquire(() -> resolvePool(g, getLandBiomes(), "landBiomes", null)); + } - for (String i : getLandBiomes()) { - realLandBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realLandBiomes; - }); + /** A biome reference pool with unloadable and gate-excluded biomes removed; drops are reported once. */ + private KList resolvePool(DataProvider g, KList keys, String field, List sink) { + IrisData data = g == null ? null : g.getData(); + return CompatPools.load(data == null ? null : data.getBiomeLoader(), keys, data, + "region", getLoadKey(), field, sink); } public KList getAllAnyBiomes() { @@ -468,6 +449,42 @@ public class IrisRegion extends IrisRegistrant implements IRare { } + /** + * Cascade: a region that lost every land biome to the gate cannot generate, so it leaves the dimension's region + * pool too. Loading biomes here is safe - biomes never load regions. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null) { + return base; + } + + for (int index = 0; index < structures.size(); index++) { + structures.get(index).reportExcludedCaveBiomes(data, "region", getLoadKey(), + "structures[" + index + "].caveBiomes"); + } + + if (getLandBiomes().isEmpty()) { + return base; + } + + List drops = new ArrayList<>(); + + if (!resolvePool(() -> data, getLandBiomes(), "landBiomes", drops).isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "region", getLoadKey(), "no land biomes remain"); + } + @Override public String getFolderName() { return "regions"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java index 89c088dca..57f0ccb3d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -4,6 +4,7 @@ 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.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; @@ -83,6 +84,19 @@ public class IrisRiverPolicy { @Desc("Multiplier applied to the eroded bank width outside the shore band.") private Double bankMultiplier = null; + /** + * A declared river biome selection with biomes the version-content gate excluded removed. A declared-but-now-empty + * selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null. + */ + public KList compatBiomes(KList declared, IrisData data, String field) { + if (declared == null) { + return null; + } + + return CompatPools.surviving(data == null ? null : data.getBiomeLoader(), declared, data, + "river policy", "", field); + } + public KSet getAllBiomeIds() { KSet biomeIds = new KSet<>(); addAll(biomeIds, surfaceBiomes); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java index 1c4a0b6a8..38c9c69eb 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java @@ -18,6 +18,10 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -30,6 +34,9 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.ArrayList; +import java.util.List; + @EqualsAndHashCode(callSuper = true) @Accessors(chain = true) @NoArgsConstructor @@ -123,6 +130,64 @@ public class IrisSpawner extends IrisRegistrant { engine.getEngineData().getChunk(x, z).getCooldown(this).spawn(engine); } + /** + * Cascade: a spawn whose entity the gate excluded can never spawn, so it leaves this spawner's lists; a spawner + * with nothing left to spawn leaves every pool that references it. Loading entities here is safe - entities never + * load spawners. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || data.getEntityLoader() == null) { + return base; + } + + boolean declared = !spawns.isEmpty() || !initialSpawns.isEmpty(); + List drops = new ArrayList<>(); + spawns = dropExcludedSpawns(data, spawns, "spawns", drops); + initialSpawns = dropExcludedSpawns(data, initialSpawns, "initialSpawns", drops); + + if (!declared || !spawns.isEmpty() || !initialSpawns.isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "spawner", getLoadKey(), "no entity spawns remain"); + } + + private KList dropExcludedSpawns(IrisData data, + KList declared, + String field, + List drops) { + KList kept = new KList<>(); + + for (int index = 0; index < declared.size(); index++) { + IrisEntitySpawn spawn = declared.get(index); + + if (spawn == null) { + continue; + } + + IrisEntity entity = data.getEntityLoader().load(spawn.getEntity()); + + if (entity != null && entity.isCompatExcluded()) { + CompatPools.drop(data, entity, "spawner", getLoadKey(), + field + "[" + index + "] " + spawn.getEntity(), drops); + continue; + } + + kept.add(spawn); + } + + return kept; + } + @Override public String getFolderName() { return "spawners"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java index 74113d3ce..5d123c0e5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -126,6 +132,42 @@ public class IrisStructure extends IrisRegistrant { return placement; } + /** + * The start pool is the only mandatory entry point into an assembly: when it has nothing left to place, the + * structure cannot generate on this server and every placement that lists it skips it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || startPool == null || startPool.isBlank()) { + return base; + } + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, startPool.trim(), false); + + if (pool == null || !pool.isCompatExcluded()) { + return base; + } + + CompatFinding reason = pool.getCompat().reasons().isEmpty() ? null : pool.getCompat().reasons().getLast(); + CompatFinding excluded = new CompatFinding( + reason == null ? CompatRegistry.BLOCK : reason.registry(), + reason == null ? startPool.trim() : reason.key(), + CompatAction.EXCLUDED, "structure", getLoadKey(), + "start pool " + startPool.trim() + " is unavailable"); + gate.report().record(excluded); + KList reasons = new KList<>(base.reasons()); + reasons.add(excluded); + return CompatStatus.excludedBy(reasons); + } + @Override public String getFolderName() { return "structures"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index eb456f9dc..5afb331d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -147,4 +148,24 @@ public class IrisStructurePlacement { IrisStructureAnchorMode activeAnchor = resolvedAnchor(); return activeAnchor == IrisStructureAnchorMode.HEIGHT_BAND || activeAnchor.isCave(); } + + /** + * Reports cave biome references the version-content gate excluded. The list is deliberately not filtered: it is an + * allowlist whose emptiness means "any cave biome", and it feeds the placement determinism signature. An excluded + * biome never generates, so the entry is inert - this only makes it visible in the pack compat report. + */ + void reportExcludedCaveBiomes(IrisData data, String subjectType, String subjectKey, String field) { + if (data == null || data.getBiomeLoader() == null || caveBiomes == null) { + return; + } + + for (int index = 0; index < caveBiomes.size(); index++) { + String key = caveBiomes.get(index); + IrisBiome biome = data.getBiomeLoader().load(key); + + if (biome != null && biome.isCompatExcluded()) { + CompatPools.drop(data, biome, subjectType, subjectKey, field + "[" + index + "] " + key, null); + } + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java new file mode 100644 index 000000000..f9f339348 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java @@ -0,0 +1,145 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +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.math.RNG; +import org.junit.AfterClass; +import org.junit.BeforeClass; +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.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureAssemblerCompatTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void anExcludedPieceIsNeverDrawnFromItsPool() { + IrisData data = mock(IrisData.class); + IrisJigsawPiece good = piece("good-object"); + IrisJigsawPiece bad = excluded(piece("bad-object")); + when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(pool("good-piece", "bad-piece")); + when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool()); + when(data.load(IrisJigsawPiece.class, "good-piece", false)).thenReturn(good); + when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(bad); + when(data.load(IrisObject.class, "good-object", false)).thenReturn(new IrisObject(1, 1, 1)); + when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1)); + + for (int seed = 0; seed < 32; seed++) { + StructureAssemblyResult result = StructureAssembler + .forData(data, structure("start"), new IrisPosition(0, 0, 0)) + .assemble(new RNG(seed)); + + assertTrue(result.detail(), result.hasOutput()); + assertTrue(result.pieces().toString(), result.pieces().stream() + .allMatch(placed -> "good-object".equals(placed.getPiece().getObject()))); + } + } + + @Test + public void aPoolWithOnlyExcludedPiecesStopsTheAssemblyWithoutThrowing() { + IrisData data = mock(IrisData.class); + IrisJigsawPool startPool = pool("bad-piece"); + startPool.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED)))); + when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(startPool); + when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool()); + when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(excluded(piece("bad-object"))); + when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = StructureAssembler + .forData(data, structure("start"), new IrisPosition(0, 0, 0)) + .assemble(new RNG(3)); + + assertFalse(result.hasOutput()); + assertEquals(StructureAssemblyStatus.FAILED_RULES, result.status()); + } + + private static IrisJigsawPiece excluded(IrisJigsawPiece piece) { + piece.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED)))); + return piece; + } + + private static CompatFinding finding(CompatAction action) { + return new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", action, "jigsaw piece", "bad-piece", "object bad-object"); + } + + private static IrisJigsawPiece piece(String object) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject(object).setRotatable(false); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(new IrisPosition()) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setPool("terminal") + .setName("start") + .setTargetName("door") + .setJoint(JigsawJoint.ROLLABLE)); + return piece; + } + + private static IrisJigsawPool pool(String... pieces) { + IrisJigsawPool pool = new IrisJigsawPool(); + for (String piece : pieces) { + pool.getPieces().add(new IrisJigsawPieceEntry().setPiece(piece).setWeight(1)); + } + return pool; + } + + private static IrisStructure structure(String startPool) { + return new IrisStructure().setStartPool(startPool).setMaxDepth(1).setMaxSizeChunks(8); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java b/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java new file mode 100644 index 000000000..d652bffe1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java @@ -0,0 +1,84 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Shared fixtures for the version-content pool cascade tests. The gate walker (lane L1) is not exercised here: the + * tests stamp {@code compat} on a registrant directly, which is exactly what the walker stores. + */ +final class CompatFixtures { + static final String MISSING_BLOCK = "minecraft:sulfur"; + static final String MISSING_ENTITY = "minecraft:camel"; + + private CompatFixtures() { + } + + static IrisData data(PackCompatReport report) { + IrisData data = mock(IrisData.class); + when(data.getCompatReport()).thenReturn(report); + return data; + } + + /** Marks a registrant excluded the way the gate walker would for a missing composed block. */ + static T excludeBlock(T registrant) { + return exclude(registrant, CompatRegistry.BLOCK, MISSING_BLOCK); + } + + /** Marks a registrant excluded the way the gate walker would for a missing entity type. */ + static T excludeEntity(T registrant) { + return exclude(registrant, CompatRegistry.ENTITY, MISSING_ENTITY); + } + + static T exclude(T registrant, CompatRegistry registry, String key) { + registrant.setCompat(CompatStatus.excludedBy(List.of(new CompatFinding(registry, key, + CompatAction.EXCLUDED, registrant.getTypeName().toLowerCase(java.util.Locale.ROOT), + registrant.getLoadKey(), "composition")))); + return registrant; + } + + static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + static IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + return region; + } + + static IrisEntity entity(String key) { + IrisEntity entity = new IrisEntity(); + entity.setLoadKey(key); + return entity; + } + + static IrisLootTable lootTable(String key) { + IrisLootTable table = new IrisLootTable(); + table.setLoadKey(key); + return table; + } + + static CompatFinding find(PackCompatReport report, CompatAction action, String subjectType, String subjectKey) { + for (CompatFinding finding : report.findings()) { + if (finding.action() == action + && finding.subjectType().equals(subjectType) + && finding.subjectKey().equals(subjectKey)) { + return finding; + } + } + return null; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java b/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java new file mode 100644 index 000000000..3068f1fe5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java @@ -0,0 +1,254 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformItem; +import art.arcane.iris.spi.PlatformRegistries; +import org.mockito.Answers; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Binds a platform whose block registry holds exactly the keys a compat test declares, so the gate can be exercised + * without a server. Also writes the .iob and JSON fixtures those tests need. + */ +final class CompatTestPlatform { + private final IrisPlatform previous; + + private CompatTestPlatform(IrisPlatform previous) { + this.previous = previous; + } + + static CompatTestPlatform bind(File dataFolder, List blockKeys) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(dataFolder); + when(platform.registries()).thenReturn(new FixedRegistries(blockKeys)); + IrisPlatforms.bind(platform); + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + return new CompatTestPlatform(previous); + } + + void unbind() { + IrisServices.clear(); + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + /** A minimal V2 .iob under {@code /objects/.iob} carrying only the given palette. */ + static File writeObject(File pack, String key, String... palette) throws IOException { + File file = new File(pack, "objects/" + key + ".iob"); + file.getParentFile().mkdirs(); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.writeInt(1); + out.writeInt(1); + out.writeInt(1); + out.writeUTF("Iris V2 IOB;"); + out.writeShort(palette.length); + for (String entry : palette) { + out.writeUTF(entry); + } + out.writeInt(0); + out.writeInt(0); + } + return file; + } + + static File write(File pack, String path, String content) throws IOException { + File file = new File(pack, path); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return file; + } + + private record FixedRegistries(List blocks) implements PlatformRegistries { + private static String base(String key) { + if (key == null) { + return null; + } + String trimmed = key.trim().toLowerCase(Locale.ROOT); + int props = trimmed.indexOf('['); + String head = props < 0 ? trimmed : trimmed.substring(0, props); + return head.indexOf(':') < 0 ? "minecraft:" + head : head; + } + + private boolean has(String key) { + String normalized = base(key); + if (normalized == null) { + return false; + } + for (String block : blocks) { + if (base(block).equals(normalized)) { + return true; + } + } + return false; + } + + @Override + public PlatformBlockState block(String key) { + PlatformBlockState state = blockOrNull(key, false); + return state == null ? air() : state; + } + + @Override + public PlatformBlockState blockOrNull(String key) { + return blockOrNull(key, false); + } + + @Override + public PlatformBlockState blockOrNull(String key, boolean warn) { + return has(key) ? StateCache.of(base(key)) : null; + } + + @Override + public PlatformBlockState air() { + return StateCache.of("minecraft:air"); + } + + @Override + public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) { + return ore; + } + + @Override + public PlatformBiome biome(String key) { + return null; + } + + @Override + public PlatformItem item(String key) { + return null; + } + + @Override + public PlatformEntityType entity(String key) { + return null; + } + + @Override + public List blockKeys() { + return blocks; + } + + @Override + public List biomeKeys() { + return List.of(); + } + + @Override + public List structureKeys() { + return List.of(); + } + + @Override + public List itemKeys() { + return List.of(); + } + + @Override + public List entityKeys() { + return List.of(); + } + + @Override + public List blockTypeKeys() { + return blocks; + } + + @Override + public List enchantmentKeys() { + return List.of(); + } + + @Override + public List potionEffectKeys() { + return List.of(); + } + + @Override + public List lootTableKeys() { + return List.of(); + } + + @Override + public Map> blockStateProperties() { + return Map.of(); + } + } + + /** One interned mock per key so {@code matches} and identity behave like a real adapter. */ + private static final class StateCache { + private static final Map STATES = new LinkedHashMap<>(); + + private StateCache() { + } + + static synchronized PlatformBlockState of(String key) { + return STATES.computeIfAbsent(key, k -> { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(k); + return state; + }); + } + } + + private static final class NoOpPreservationRegistry implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java new file mode 100644 index 000000000..f1666a1ae --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.biome; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeCompatChildrenTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisBiome... biomes) { + IrisData data = CompatFixtures.data(report); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + for (IrisBiome biome : biomes) { + when(biomeLoader.load(biome.getLoadKey())).thenReturn(biome); + } + return data; + } + + @Test + public void realChildrenDropsExcludedChildAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + IrisBiome hills = biome("land/hills"); + IrisBiome sulfur = excludeBlock(biome("land/sulfur-flats")); + parent.setChildren(new KList<>("land/hills", "land/sulfur-flats")); + IrisData data = dataWith(report, parent, hills, sulfur); + parent.setLoader(data); + + KList children = parent.getRealChildren(() -> data); + + assertEquals(1, children.size()); + assertEquals("land/hills", children.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "biome", "land/plains"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("children[1] land/sulfur-flats", dropped.detail()); + } + + @Test + public void realChildrenSkipsUnloadableChild() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + parent.setChildren(new KList<>("land/gone")); + IrisData data = dataWith(report, parent); + parent.setLoader(data); + + assertTrue(parent.getRealChildren(() -> data).isEmpty()); + } + + @Test + public void floatingChildBiomeFallsBackToParentWhenTargetExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("sky/sulfur-isles")); + IrisData data = dataWith(report, parent, sulfur); + parent.setLoader(data); + IrisFloatingChildBiomes floating = new IrisFloatingChildBiomes().setBiome("sky/sulfur-isles"); + + assertSame(parent, floating.getRealBiome(parent, data)); + CompatFinding dropped = find(report, CompatAction.DROPPED, "floating child biome", "land/plains"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + } + + @Test + public void carvingEntryResolvesNullWhenBiomeExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + IrisData data = dataWith(report, sulfur); + IrisDimensionCarvingEntry entry = new IrisDimensionCarvingEntry() + .setId("deep-band") + .setBiome("cave/sulfur-grotto"); + + assertNull(entry.getRealBiome(data)); + CompatFinding dropped = find(report, CompatAction.DROPPED, "carving entry", "deep-band"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java new file mode 100644 index 000000000..b48c9c7d6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeCustomCompatTest { + private static final IDataFixer PASSTHROUGH = new IDataFixer() { + @Override + public JSONObject resolve(IDataFixer.Dimension dimension, art.arcane.iris.engine.object.IrisDimensionTypeOptions options) { + return new JSONObject(); + } + + @Override + public void fixDimension(IDataFixer.Dimension dimension, JSONObject json) { + } + }; + + private static ContentGate gateWithEntities(String... present) { + List entities = new ArrayList<>(); + entities.add("minecraft:pig"); + entities.addAll(List.of(present)); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.entityKeys()).thenReturn(entities); + return new ContentGate(registries, Map.of(), new PackCompatReport()); + } + + private static IrisBiomeCustom customBiome() { + IrisBiomeCustom biome = new IrisBiomeCustom(); + biome.setId("sulfur_grotto"); + biome.setSpawnRarity(1); + biome.setSpawns(new KList<>( + new IrisBiomeCustomSpawn().setType("minecraft:zombie"), + new IrisBiomeCustomSpawn().setType("minecraft:camel"))); + return biome; + } + + @Test + public void generateJsonOmitsSpawnWhoseEntityTypeIsMissing() { + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gateWithEntities("minecraft:zombie"))); + JSONArray monsters = json.getJSONObject("spawners").getJSONArray("misc"); + + assertEquals(1, monsters.length()); + assertEquals("minecraft:zombie", monsters.getJSONObject(0).getString("type")); + assertFalse(json.getJSONObject("spawners").toString().contains("minecraft:camel")); + } + + @Test + public void generateJsonKeepsEverySpawnWhenAllEntitiesArePresent() { + JSONObject json = new JSONObject(customBiome() + .generateJson(PASSTHROUGH, gateWithEntities("minecraft:zombie", "minecraft:camel"))); + + assertEquals(2, json.getJSONObject("spawners").getJSONArray("misc").length()); + } + + @Test + public void generateJsonKeepsEverySpawnWhenRegistryCannotBeConsulted() { + // Registry not ready (modded early boot): UNKNOWN must never behave like MISSING. + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of()); + when(registries.entityKeys()).thenReturn(List.of()); + ContentGate gate = new ContentGate(registries, Map.of(), new PackCompatReport()); + + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gate)); + + assertEquals(2, json.getJSONObject("spawners").getJSONArray("misc").length()); + } + + @Test + public void generateJsonWritesNoSpawnersWhenEveryEntityIsMissing() { + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gateWithEntities())); + + assertEquals(0, json.getJSONObject("spawners").length()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java new file mode 100644 index 000000000..19055378c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * The gate walker drops a {@code vanillaDerivative} whose biome key is missing (policy table: entry DROPPED, falls back + * to derivative). This pins the fallback the drop relies on; the drop itself is simulated by clearing the field, which + * is exactly what the walker does. + */ +public class IrisBiomeDerivativeCompatTest { + @Test + public void droppedVanillaDerivativeFallsBackToDerivative() { + IrisBiome biome = CompatFixtures.biome("cave/sulfur-grotto"); + biome.setDerivative("minecraft:dripstone_caves"); + biome.setVanillaDerivative("minecraft:sulfur_caves"); + + assertEquals("minecraft:sulfur_caves", biome.getVanillaDerivativeKey()); + + biome.setVanillaDerivative(null); + + assertEquals("minecraft:dripstone_caves", biome.getVanillaDerivativeKey()); + } + + @Test + public void blankVanillaDerivativeFallsBackToDerivative() { + IrisBiome biome = CompatFixtures.biome("cave/sulfur-grotto"); + biome.setDerivative("minecraft:dripstone_caves"); + biome.setVanillaDerivative(""); + + assertEquals("minecraft:dripstone_caves", biome.getVanillaDerivativeKey()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java new file mode 100644 index 000000000..e9dc36dfe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static art.arcane.iris.engine.object.CompatFixtures.region; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDimensionCompatRegionTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisRegion... regions) { + IrisData data = CompatFixtures.data(report); + ResourceLoader regionLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + for (IrisRegion region : regions) { + when(regionLoader.load(region.getLoadKey())).thenReturn(region); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void allRegionsDropsExcludedRegionAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisRegion plains = region("overworld"); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, plains, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("overworld", "sulfur-lands")); + dimension.setLoadKey("overworld"); + dimension.setLoader(data); + + KList pool = dimension.getAllRegions(() -> data); + + assertEquals(1, pool.size()); + assertEquals("overworld", pool.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "dimension", "overworld"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("regions[1] sulfur-lands", dropped.detail()); + } + + @Test + public void evaluateCompatExcludesDimensionWithNoRegionsLeft() { + PackCompatReport report = new PackCompatReport(); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("sulfur-lands")); + dimension.setLoadKey("sulfurworld"); + dimension.setLoader(data); + + CompatStatus status = dimension.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "dimension", "sulfurworld"); + assertNotNull(cascade); + assertEquals(MISSING_BLOCK, cascade.key()); + assertEquals("no regions remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsDimensionWithSurvivingRegion() { + PackCompatReport report = new PackCompatReport(); + IrisRegion plains = region("overworld"); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, plains, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("overworld", "sulfur-lands")); + dimension.setLoadKey("overworld"); + dimension.setLoader(data); + + assertFalse(dimension.evaluateCompat(gate(report)).excluded()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java new file mode 100644 index 000000000..e8ef7c161 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java @@ -0,0 +1,158 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.loader.IrisData; +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.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class IrisJigsawCompatTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private CompatTestPlatform platform; + private IrisSettings previousSettings; + private IrisData data; + + @Before + public void bindPlatform() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + platform = CompatTestPlatform.bind(temporaryFolder.getRoot(), + List.of("minecraft:stone", "minecraft:cobblestone", "minecraft:air")); + } + + @After + public void restorePlatform() { + if (data != null) { + data.close(); + data = null; + } + if (platform != null) { + platform.unbind(); + platform = null; + } + IrisSettings.settings = previousSettings; + } + + @Test + public void aPieceWhoseObjectNeedsAMissingBlockIsExcluded() throws Exception { + File pack = pack("piece"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + data = IrisData.get(pack); + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, "bad", false); + + assertNotNull(piece); + assertTrue(piece.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> "minecraft:sulfur".equals(finding.key()) + && finding.action() == CompatAction.EXCLUDED)); + } + + @Test + public void aPieceWhoseObjectResolvesIsKept() throws Exception { + File pack = pack("piece-ok"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + data = IrisData.get(pack); + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, "good", false); + + assertNotNull(piece); + assertFalse(piece.isCompatExcluded()); + } + + @Test + public void aPoolKeepsGeneratingWhileOnePieceSurvives() throws Exception { + File pack = pack("pool-partial"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"good\",\"weight\":1},{\"piece\":\"bad\",\"weight\":1}]}"); + data = IrisData.get(pack); + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, "pool", false); + + assertNotNull(pool); + assertFalse(pool.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> finding.action() == CompatAction.DROPPED + && "jigsaw pool".equals(finding.subjectType()))); + } + + @Test + public void pieceExclusionCascadesToThePoolAndTheStructure() throws Exception { + File pack = pack("cascade"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"bad\",\"weight\":1}]}"); + CompatTestPlatform.write(pack, "structures/keep.json", "{\"startPool\":\"pool\"}"); + data = IrisData.get(pack); + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, "pool", false); + IrisStructure structure = data.load(IrisStructure.class, "keep", false); + + assertNotNull(pool); + assertNotNull(structure); + assertTrue(pool.isCompatExcluded()); + assertTrue(structure.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> "structure".equals(finding.subjectType()) + && finding.action() == CompatAction.EXCLUDED + && "minecraft:sulfur".equals(finding.key()))); + } + + @Test + public void aStructureOverAHealthyPoolIsKept() throws Exception { + File pack = pack("structure-ok"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"good\",\"weight\":1}]}"); + CompatTestPlatform.write(pack, "structures/keep.json", "{\"startPool\":\"pool\"}"); + data = IrisData.get(pack); + + IrisStructure structure = data.load(IrisStructure.class, "keep", false); + + assertNotNull(structure); + assertFalse(structure.isCompatExcluded()); + assertTrue(data.getCompatReport().isEmpty()); + } + + private File pack(String name) throws Exception { + return temporaryFolder.newFolder(name); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java new file mode 100644 index 000000000..6212b714f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static art.arcane.iris.engine.object.CompatFixtures.lootTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisLootTableCompatTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisLootTable... tables) { + IrisData data = CompatFixtures.data(report); + ResourceLoader lootLoader = mock(ResourceLoader.class); + when(data.getLootLoader()).thenReturn(lootLoader); + for (IrisLootTable table : tables) { + when(lootLoader.load(table.getLoadKey())).thenReturn(table); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void evaluateCompatExcludesTableWhoseEntriesWereAllDropped() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/sulfur-cache"); + table.setLoader(CompatFixtures.data(report)); + // The gate walker drops every entry with a missing item and hands the cascade its reasons. + CompatStatus walked = new CompatStatus(false, List.of(new CompatFinding(CompatRegistry.ITEM, + "minecraft:sulfur_dust", CompatAction.DROPPED, "loot", "chest/sulfur-cache", "loot[0]"))); + + CompatStatus status = table.cascadeEmptyLoot(walked); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "loot", "chest/sulfur-cache"); + assertNotNull(cascade); + assertEquals("minecraft:sulfur_dust", cascade.key()); + assertEquals(CompatRegistry.ITEM, cascade.registry()); + assertEquals("no loot entries remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsTableWithSurvivingEntries() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/mixed"); + table.setLoader(CompatFixtures.data(report)); + table.setLoot(new KList<>(new IrisLoot())); + CompatStatus walked = new CompatStatus(false, List.of(new CompatFinding(CompatRegistry.ITEM, + "minecraft:sulfur_dust", CompatAction.DROPPED, "loot", "chest/mixed", "loot[1]"))); + + assertFalse(table.cascadeEmptyLoot(walked).excluded()); + } + + @Test + public void evaluateCompatKeepsTableThatNeverDeclaredLoot() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/empty"); + table.setLoader(CompatFixtures.data(report)); + + assertFalse(table.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } + + @Test + public void lootReferenceSkipsExcludedAndUnloadableTables() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable kept = lootTable("chest/common"); + IrisLootTable excluded = CompatFixtures.exclude(lootTable("chest/sulfur-cache"), + CompatRegistry.ITEM, "minecraft:sulfur_dust"); + IrisData data = dataWith(report, kept, excluded); + IrisLootReference reference = new IrisLootReference() + .setTables(new KList<>("chest/common", "chest/sulfur-cache", "chest/gone")); + + KList tables = reference.getLootTables(() -> data); + + assertEquals(1, tables.size()); + assertEquals("chest/common", tables.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "loot reference", "chest/sulfur-cache"); + assertNotNull(dropped); + assertEquals("minecraft:sulfur_dust", dropped.key()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java new file mode 100644 index 000000000..651178eb2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java @@ -0,0 +1,282 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +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.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisObjectPlacementCompatTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private CompatTestPlatform platform; + private IrisSettings previousSettings; + private IrisData data; + + @Before + public void bindPlatform() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + platform = CompatTestPlatform.bind(temporaryFolder.getRoot(), + List.of("minecraft:stone", "minecraft:cobblestone", "minecraft:air", "minecraft:oak_log")); + } + + @After + public void restorePlatform() { + if (data != null) { + data.close(); + data = null; + } + if (platform != null) { + platform.unbind(); + platform = null; + } + IrisSettings.settings = previousSettings; + } + + @Test + public void missingPaletteKeyDropsTheObjectAndRecordsAFinding() throws Exception { + File pack = pack("drop"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:stone", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good", "bad"); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("good"), placement.compatPlace(data)); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals("minecraft:sulfur", findings.getFirst().key()); + assertEquals(CompatAction.DROPPED, findings.getFirst().action()); + assertEquals("object", findings.getFirst().subjectType()); + assertEquals("bad", findings.getFirst().subjectKey()); + } + + @Test + public void editRuleWithFullChanceKeepsTheObject() throws Exception { + File pack = pack("edit-keeps"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone")); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("bad"), placement.compatPlace(data)); + assertTrue(data.getCompatReport().findings().toString(), data.getCompatReport().isEmpty()); + } + + @Test + public void editRuleBelowFullChanceDoesNotCoverTheMissingKey() throws Exception { + File pack = pack("edit-chance"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + placement.getEdit().add(replace(0.5F, false, "minecraft:sulfur", "minecraft:stone")); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + } + + @Test + public void exactEditRuleOnlyCoversTheMatchingState() throws Exception { + File pack = pack("edit-exact"); + CompatTestPlatform.writeObject(pack, "axisY", "minecraft:sulfur[axis=y]"); + CompatTestPlatform.writeObject(pack, "axisX", "minecraft:sulfur[axis=x]"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("axisY", "axisX"); + IrisObjectReplace rule = replace(1F, true, "minecraft:sulfur", "minecraft:stone"); + rule.getFind().getFirst().getData().put("axis", "y"); + placement.getEdit().add(rule); + + assertEquals(new KList().qadd("axisY"), placement.compatPlace(data)); + } + + @Test + public void nonExactEditRuleCoversEveryStateOfTheSameBlock() throws Exception { + File pack = pack("edit-non-exact"); + CompatTestPlatform.writeObject(pack, "axisX", "minecraft:sulfur[axis=x]"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("axisX"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone")); + + assertFalse(placement.isCompatExcluded(data)); + } + + @Test + public void missingReplacePaletteOptionExcludesTheWholePlacement() throws Exception { + File pack = pack("replace-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + placement.getEdit().add(replace(1F, false, "minecraft:stone", "minecraft:sulfur")); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals(CompatAction.EXCLUDED, findings.getFirst().action()); + assertEquals("placement", findings.getFirst().subjectType()); + assertTrue(findings.getFirst().detail(), findings.getFirst().detail().contains("edit[0].replace.palette[0]")); + } + + @Test + public void missingFindKeyOnTheMatchSideIsIgnored() throws Exception { + File pack = pack("find-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:cobblestone")); + + assertFalse(placement.isCompatExcluded(data)); + assertTrue(data.getCompatReport().findings().toString(), data.getCompatReport().isEmpty()); + } + + @Test + public void missingMarkerBlockIsMatchOnly() throws Exception { + File pack = pack("marker-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + IrisObjectMarker marker = new IrisObjectMarker(); + marker.setMarker("thing"); + marker.getMark().add(new IrisBlockData("minecraft:sulfur")); + placement.getMarkers().add(marker); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("good"), placement.compatPlace(data)); + assertTrue(data.getCompatReport().isEmpty()); + } + + @Test + public void dimensionFallbackRescuesTheObjectAndRecordsASubstitution() throws Exception { + File pack = pack("fallback"); + CompatTestPlatform.write(pack, "dimensions/main.json", + "{\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:cobblestone\"}}"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("bad"), placement.compatPlace(data)); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals(CompatAction.SUBSTITUTED, findings.getFirst().action()); + } + + @Test + public void blockBackupRescuesAReplacePaletteEntry() throws Exception { + File pack = pack("backup"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + IrisObjectReplace rule = replace(1F, false, "minecraft:stone", "minecraft:sulfur"); + rule.getReplace().getPalette().getFirst().setBackup(new IrisBlockData("minecraft:cobblestone")); + placement.getEdit().add(rule); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action()); + } + + @Test + public void everyObjectDroppedExcludesThePlacement() throws Exception { + File pack = pack("all-dropped"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> finding.action() == CompatAction.EXCLUDED + && "placement".equals(finding.subjectType()))); + } + + @Test + public void survivingPoolKeepsAuthoredOrderAndGetObjectOnlyDrawsSurvivors() throws Exception { + File pack = pack("draw"); + CompatTestPlatform.writeObject(pack, "a", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.writeObject(pack, "b", "minecraft:cobblestone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("a", "bad", "b"); + + assertEquals(new KList().qadd("a").qadd("b"), placement.compatPlace(data)); + + IrisData loaded = data; + DataProvider provider = () -> loaded; + for (int seed = 0; seed < 64; seed++) { + IrisObject drawn = placement.getObject(provider, new RNG(seed)); + assertTrue("Draw must never return the dropped object", + drawn != null && !"bad".equals(drawn.getLoadKey())); + } + } + + @Test + public void aCompletePackLeavesThePoolIdentical() throws Exception { + File pack = pack("complete"); + CompatTestPlatform.writeObject(pack, "a", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "b", "minecraft:cobblestone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("a", "b"); + + assertTrue("A complete pack must hand back the authored pool untouched", + placement.getPlace() == placement.compatPlace(data)); + assertTrue(data.getCompatReport().isEmpty()); + } + + private File pack(String name) throws Exception { + return temporaryFolder.newFolder(name); + } + + private static IrisObjectPlacement placement(String... place) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(new KList<>(place)); + return placement; + } + + private static IrisObjectReplace replace(float chance, boolean exact, String find, String to) { + IrisObjectReplace rule = new IrisObjectReplace(); + rule.setChance(chance); + rule.setExact(exact); + rule.getFind().add(new IrisBlockData(find)); + rule.getReplace().qclear(); + rule.getReplace().add(to); + return rule; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java new file mode 100644 index 000000000..e659ed70e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.biome; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisRegionCompatPoolTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisBiome... biomes) { + IrisData data = CompatFixtures.data(report); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + for (IrisBiome biome : biomes) { + when(biomeLoader.load(biome.getLoadKey())).thenReturn(biome); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void realLandBiomesDropsExcludedReferenceAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains", "cave/sulfur-grotto")); + + KList pool = region.getRealLandBiomes(() -> data); + + assertEquals(1, pool.size()); + assertEquals("land/plains", pool.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "region", "overworld"); + assertNotNull(dropped); + assertEquals(CompatRegistry.BLOCK, dropped.registry()); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("landBiomes[1] cave/sulfur-grotto", dropped.detail()); + } + + @Test + public void realPoolsSkipUnloadableReferences() { + PackCompatReport report = new PackCompatReport(); + IrisBiome shore = biome("shore/sand"); + IrisData data = dataWith(report, shore); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setSeaBiomes(new KList<>("sea/gone")); + region.setShoreBiomes(new KList<>("shore/sand")); + region.setCaveBiomes(new KList<>("cave/gone")); + + assertTrue(region.getRealSeaBiomes(() -> data).isEmpty()); + assertEquals(1, region.getRealShoreBiomes(() -> data).size()); + assertTrue(region.getRealCaveBiomes(() -> data).isEmpty()); + assertTrue(report.isEmpty()); + } + + @Test + public void naturalBiomesSkipExcludedBiomesAndTheirChildren() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + sulfur.setChildren(new KList<>("cave/sulfur-deep")); + IrisBiome sulfurDeep = biome("cave/sulfur-deep"); + IrisData data = dataWith(report, plains, sulfur, sulfurDeep); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setCaveBiomes(new KList<>("cave/sulfur-grotto")); + + KList natural = region.getNaturalBiomes(() -> data); + + assertEquals(1, natural.size()); + assertEquals("land/plains", natural.get(0).getLoadKey()); + } + + @Test + public void evaluateCompatExcludesRegionWithNoLandBiomesLeft() { + PackCompatReport report = new PackCompatReport(); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-flats")); + IrisData data = dataWith(report, sulfur); + IrisRegion region = CompatFixtures.region("sulfur-lands"); + region.setLoader(data); + region.setLandBiomes(new KList<>("cave/sulfur-flats")); + + CompatStatus status = region.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "region", "sulfur-lands"); + assertNotNull(cascade); + assertEquals(MISSING_BLOCK, cascade.key()); + assertEquals(CompatRegistry.BLOCK, cascade.registry()); + assertEquals("no land biomes remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsRegionWithSurvivingLandBiome() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-flats")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains", "cave/sulfur-flats")); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + } + + @Test + public void evaluateCompatDoesNotExcludeRegionThatNeverDeclaredLandBiomes() { + PackCompatReport report = new PackCompatReport(); + IrisData data = dataWith(report); + IrisRegion region = CompatFixtures.region("empty"); + region.setLoader(data); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java new file mode 100644 index 000000000..5d73e237f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_ENTITY; +import static art.arcane.iris.engine.object.CompatFixtures.entity; +import static art.arcane.iris.engine.object.CompatFixtures.excludeEntity; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisSpawnerCompatTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisEntity... entities) { + IrisData data = CompatFixtures.data(report); + ResourceLoader entityLoader = mock(ResourceLoader.class); + when(data.getEntityLoader()).thenReturn(entityLoader); + for (IrisEntity entity : entities) { + when(entityLoader.load(entity.getLoadKey())).thenReturn(entity); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void evaluateCompatDropsSpawnsWhoseEntityIsExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisEntity zombie = entity("zombie"); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, zombie, camel); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("desert"); + spawner.setLoader(data); + spawner.setSpawns(new KList<>( + new IrisEntitySpawn().setEntity("zombie"), + new IrisEntitySpawn().setEntity("camel"))); + + CompatStatus status = spawner.evaluateCompat(gate(report)); + + assertFalse(status.excluded()); + assertEquals(1, spawner.getSpawns().size()); + assertEquals("zombie", spawner.getSpawns().get(0).getEntity()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "spawner", "desert"); + assertNotNull(dropped); + assertEquals(CompatRegistry.ENTITY, dropped.registry()); + assertEquals(MISSING_ENTITY, dropped.key()); + assertEquals("spawns[1] camel", dropped.detail()); + } + + @Test + public void evaluateCompatExcludesSpawnerWithNoSpawnsLeft() { + PackCompatReport report = new PackCompatReport(); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, camel); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("desert"); + spawner.setLoader(data); + spawner.setSpawns(new KList<>(new IrisEntitySpawn().setEntity("camel"))); + spawner.setInitialSpawns(new KList<>(new IrisEntitySpawn().setEntity("camel"))); + + CompatStatus status = spawner.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + assertTrue(spawner.getSpawns().isEmpty()); + assertTrue(spawner.getInitialSpawns().isEmpty()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "spawner", "desert"); + assertNotNull(cascade); + assertEquals(MISSING_ENTITY, cascade.key()); + assertEquals("no entity spawns remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsSpawnerThatNeverDeclaredSpawns() { + PackCompatReport report = new PackCompatReport(); + IrisData data = dataWith(report); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("empty"); + spawner.setLoader(data); + + assertFalse(spawner.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } + + @Test + public void entitySpawnResolvesNoEntityWhenExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, camel); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + + assertNull(new IrisEntitySpawn().setEntity("camel").getRealEntity(engine)); + } + + @Test + public void entitySpawnResolvesPresentEntity() { + PackCompatReport report = new PackCompatReport(); + IrisEntity zombie = entity("zombie"); + IrisData data = dataWith(report, zombie); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + + assertSame(zombie, new IrisEntitySpawn().setEntity("zombie").getRealEntity(engine)); + } +}