diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java index 85fa84155..62e3b3fe1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -45,6 +45,9 @@ public final class ModdedMixinAudit { private static final AtomicBoolean AUDITED = new AtomicBoolean(false); static final List EXPECTED = List.of( + new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin", + NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames", + false, NativeMixinFlags::registryClientNamesRan), new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", false, NativeMixinFlags::entityPersistenceRan), @@ -108,7 +111,7 @@ public final class ModdedMixinAudit { ModdedIrisLog.error(" missing: {}", entry); } ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); - ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, or Iris world-type labels are disabled until this is fixed."); + ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed."); ModdedIrisLog.error("==============================================================="); } diff --git a/core/build.gradle b/core/build.gradle index 5580b0c22..e5e809caf 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -168,6 +168,8 @@ dependencies { testImplementation(libs.log4j.core) testRuntimeOnly(libs.paper.api) testImplementation(libs.multiverseCore) + testImplementation(libs.annotations) + testImplementation(libs.mythic) } tasks.named('test').configure { diff --git a/core/src/main/java/art/arcane/iris/integration/data/MythicMobsDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/MythicMobsDataProvider.java index 786d2b13c..5fcb365fb 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/MythicMobsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/MythicMobsDataProvider.java @@ -3,6 +3,7 @@ package art.arcane.iris.integration.data; import art.arcane.iris.integration.ExternalDataProvider; import art.arcane.iris.integration.Identifier; import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisRegion; @@ -110,10 +111,17 @@ public class MythicMobsDataProvider extends ExternalDataProvider implements List if (access == null) return false; Engine engine = access.getEngine(); if (engine == null) return false; - IrisBiome biome = surface ? - engine.getSurfaceBiome(target.getBlockX(), target.getBlockZ()) : - engine.getBiomeOrMantle(target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); - return biomes.contains(biome.getLoadKey()); + try { + IrisBiome biome = surface ? + engine.getSurfaceBiome(target.getBlockX(), target.getBlockZ()) : + engine.getBiomeOrMantle(target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); + return biomes.contains(biome.getLoadKey()); + } catch (SavedBiomeUnavailableException unavailable) { + if (unavailable.isLoading() && unavailable.getSuppressed().length == 0) { + return false; + } + throw unavailable; + } } } @@ -134,9 +142,16 @@ public class MythicMobsDataProvider extends ExternalDataProvider implements List if (access == null) return false; Engine engine = access.getEngine(); if (engine == null) return false; - IrisRegion region = engine.getRegion( - target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); - return regions.contains(region.getLoadKey()); + try { + IrisRegion region = engine.getRegion( + target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); + return regions.contains(region.getLoadKey()); + } catch (SavedBiomeUnavailableException unavailable) { + if (unavailable.isLoading() && unavailable.getSuppressed().length == 0) { + return false; + } + throw unavailable; + } } } } diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java index 2b035ab23..2eaea8c63 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java @@ -23,6 +23,7 @@ import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.structure.placement.IrisStructurePlacement; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.volmlib.nativelib.terrain.RegistryClientNames; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import com.google.gson.JsonArray; @@ -253,6 +254,7 @@ public final class GenerationRegistryContractFactory { requiredBiome.getId() ); String resourceKey = requiredRegistry.customBiomeResourceKey(identityFingerprint); + registerCustomBiomeName(requiredDimension.getLoadKey(), requiredBiome.getId(), resourceKey); GenerationRegistryContract.PhysicalResourceKey physicalKey = key(BIOME_REGISTRY, resourceKey); PlatformGenerationRegistry.Definition canonicalDefinition = requiredRegistry.canonicalDefinition( BIOME_REGISTRY, @@ -285,13 +287,15 @@ public final class GenerationRegistryContractFactory { ContentGate contentGate ) { IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); - return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey( + String resourceKey = PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey( fingerprintCustomBiomeIdentity( fingerprintCustomBiomeEffectiveDefinition(requiredBiome, contentGate), dimensionKey, requiredBiome.getId() ) ); + registerCustomBiomeName(dimensionKey, requiredBiome.getId(), resourceKey); + return resourceKey; } public static String customBiomeResourceKey( @@ -310,13 +314,15 @@ public final class GenerationRegistryContractFactory { ) { IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); PlatformGenerationRegistry requiredRegistry = Objects.requireNonNull(registry, "registry"); - return requiredRegistry.customBiomeResourceKey( + String resourceKey = requiredRegistry.customBiomeResourceKey( fingerprintCustomBiomeIdentity( fingerprintCustomBiomeEffectiveDefinition(requiredBiome, contentGate), dimensionKey, requiredBiome.getId() ) ); + registerCustomBiomeName(dimensionKey, requiredBiome.getId(), resourceKey); + return resourceKey; } public static String fingerprintCustomBiomeIdentity( @@ -387,6 +393,7 @@ public final class GenerationRegistryContractFactory { } } if (selectedKey != null) { + registerCustomBiomeName(dimensionKey, customBiomeId, selectedKey); return selectedKey; } throw new IOException("Historical generation registry contract has no custom biome mapping for " @@ -1539,6 +1546,14 @@ public final class GenerationRegistryContractFactory { } } + private static void registerCustomBiomeName(String dimensionKey, String biomeId, String resourceKey) { + String dimension = requireText(dimensionKey, "Dimension load key").toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9/._-]", "_"); + String biome = requireText(biomeId, "Custom biome ID").toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9/._-]", "_"); + RegistryClientNames.register(BIOME_REGISTRY, resourceKey, "iris:" + dimension + "/" + biome); + } + private static void updateString(MessageDigest digest, String value) { byte[] bytes = value.getBytes(StandardCharsets.UTF_8); updateInt(digest, bytes.length); @@ -1608,6 +1623,7 @@ public final class GenerationRegistryContractFactory { + dimension + ":" + requiredBiome.getId() + "."); } requireGeneratedSemanticFingerprint(physicalKey, source, contract.definitions().get(physicalKey)); + registerCustomBiomeName(dimension, requiredBiome.getId(), resourceKey); dimensionKeys.put(requiredBiome, resourceKey); return resourceKey; } diff --git a/core/src/test/java/art/arcane/iris/integration/data/MythicMobsDataProviderTest.java b/core/src/test/java/art/arcane/iris/integration/data/MythicMobsDataProviderTest.java new file mode 100644 index 000000000..3820f9608 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/integration/data/MythicMobsDataProviderTest.java @@ -0,0 +1,192 @@ +package art.arcane.iris.integration.data; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.platform.generation.PlatformChunkGenerator; +import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import io.lumine.mythic.api.adapters.AbstractLocation; +import io.lumine.mythic.api.config.MythicLineConfig; +import io.lumine.mythic.api.skills.conditions.ILocationCondition; +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.bukkit.adapters.BukkitWorld; +import io.lumine.mythic.core.skills.SkillExecutor; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MythicMobsDataProviderTest { + @Test + public void loadingChecksRetryTheSameLocationOnceSavedEnvironmentIsReady() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + fixture.failure = new SavedBiomeUnavailableException("Saved biome information is loading", true); + + assertFalse(condition.check(fixture.target)); + + fixture.failure = null; + assertTrue(condition.check(fixture.target)); + } + } + } + + @Test + public void readyEnvironmentMustMatchTheConfiguredName() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + when(fixture.biome.getLoadKey()).thenReturn("other"); + when(fixture.region.getLoadKey()).thenReturn("other"); + + assertFalse(condition.check(fixture.target)); + } + } + } + + @Test + public void locationsWithoutAnIrisGeneratorOrEngineDoNotMatch() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + fixture.toolbelt.when(() -> IrisToolbelt.access(fixture.world)).thenReturn(null); + assertFalse(condition.check(fixture.target)); + + fixture.toolbelt.when(() -> IrisToolbelt.access(fixture.world)).thenReturn(fixture.generator); + when(fixture.generator.getEngine()).thenReturn(null); + assertFalse(condition.check(fixture.target)); + } + } + } + + @Test + public void permanentSavedEnvironmentFailuresPropagateUnchanged() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + fixture.failure = new SavedBiomeUnavailableException("Saved epoch is missing", false); + + assertSame(fixture.failure, assertThrows(SavedBiomeUnavailableException.class, + () -> condition.check(fixture.target))); + } + } + } + + @Test + public void loadingFailuresWithSuppressedErrorsRemainVisible() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + fixture.failure = new SavedBiomeUnavailableException("Saved biome information is loading", true); + fixture.failure.addSuppressed(new IllegalStateException("failed to close saved epoch")); + + assertSame(fixture.failure, assertThrows(SavedBiomeUnavailableException.class, + () -> condition.check(fixture.target))); + } + } + } + + @Test + public void unrelatedRuntimeFailuresAreNotHidden() { + for (Query query : Query.values()) { + try (Fixture fixture = new Fixture()) { + ILocationCondition condition = fixture.condition(query); + fixture.failure = new IllegalStateException("broken integration query"); + + assertSame(fixture.failure, assertThrows(IllegalStateException.class, + () -> condition.check(fixture.target))); + } + } + } + + @Test + public void volumeAndRegionQueriesUseWorldHeightWhileSurfaceUsesHorizontalCoordinates() { + try (Fixture fixture = new Fixture()) { + assertTrue(fixture.condition(Query.VOLUME).check(fixture.target)); + assertTrue(fixture.condition(Query.SURFACE).check(fixture.target)); + assertTrue(fixture.condition(Query.REGION).check(fixture.target)); + + verify(fixture.engine).getBiomeOrMantle(12, 134, -18); + verify(fixture.engine).getSurfaceBiome(12, -18); + verify(fixture.engine).getRegion(12, 134, -18); + } + } + + private enum Query { + VOLUME, SURFACE, REGION + } + + private static final class Fixture implements AutoCloseable { + private final World world = mock(World.class); + private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + private final Engine engine = mock(Engine.class); + private final IrisBiome biome = mock(IrisBiome.class); + private final IrisRegion region = mock(IrisRegion.class); + private final AbstractLocation target = mock(AbstractLocation.class); + private final MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + private final MockedStatic mythic = mockStatic(MythicBukkit.class); + private RuntimeException failure; + + private Fixture() { + MythicBukkit plugin = mock(MythicBukkit.class); + when(plugin.getSkillManager()).thenReturn(mock(SkillExecutor.class)); + mythic.when(MythicBukkit::inst).thenReturn(plugin); + BukkitWorld targetWorld = mock(BukkitWorld.class); + when(targetWorld.getBukkitWorld()).thenReturn(world); + when(target.getWorld()).thenReturn(targetWorld); + when(target.getBlockX()).thenReturn(12); + when(target.getBlockY()).thenReturn(70); + when(target.getBlockZ()).thenReturn(-18); + toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator); + when(generator.getEngine()).thenReturn(engine); + when(engine.getMinHeight()).thenReturn(-64); + when(biome.getLoadKey()).thenReturn("matching"); + when(region.getLoadKey()).thenReturn("matching"); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> resolvedBiome()); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenAnswer(invocation -> resolvedBiome()); + when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> resolvedRegion()); + } + + private ILocationCondition condition(Query query) { + MythicLineConfig config = mock(MythicLineConfig.class); + when(config.getString(any(String[].class), eq(""))).thenReturn("matching"); + when(config.getBoolean(any(String[].class), eq(false))).thenReturn(query == Query.SURFACE); + return query == Query.REGION + ? new MythicMobsDataProvider.IrisRegionCondition("irisregion", config) + : new MythicMobsDataProvider.IrisBiomeCondition("irisbiome", config); + } + + private IrisBiome resolvedBiome() { + if (failure != null) { + throw failure; + } + return biome; + } + + private IrisRegion resolvedRegion() { + if (failure != null) { + throw failure; + } + return region; + } + + @Override + public void close() { + mythic.close(); + toolbelt.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java index 0a70f0edf..46ff8934c 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java @@ -19,6 +19,7 @@ import art.arcane.iris.generation.terrain.IrisDimensionTypeOptions; import art.arcane.iris.structure.nativegen.IrisNativeStructure; import art.arcane.iris.structure.placement.IrisStructurePlacement; import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.volmlib.nativelib.terrain.RegistryClientNames; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; @@ -195,6 +196,9 @@ public class GenerationRegistryContractFactoryTest { new GenerationRegistryContractFactory.CustomBiomeResourceResolver(contract, registry::customBiomeResourceKey); assertEquals(plateauKey, resolver.resolve("overworld", plateau)); assertEquals(vanillaKey, resolver.resolve("overworld", vanilla)); + assertEquals(Set.of("iris:overworld/savanna_plateau", "iris_2:overworld/savanna_plateau"), + Set.copyOf(RegistryClientNames.resolve(GenerationRegistryContractFactory.BIOME_REGISTRY, + List.of(plateauKey, vanillaKey)))); assertNotEquals(plateauKey, GenerationRegistryContractFactory.customBiomeResourceKey("overworld", plateau, registry)); assertThrows(IOException.class, () -> resolver.resolve("overworld", new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#FF0000"))); @@ -236,6 +240,25 @@ public class GenerationRegistryContractFactoryTest { PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(first)); } + @Test + public void clientBiomeNamesUseAuthoredIdsWhilePhysicalIdentitiesStayDistinct() { + IrisBiomeCustom first = new IrisBiomeCustom().setId("Mist").setTemperature(0.5D); + IrisBiomeCustom second = new IrisBiomeCustom().setId("Mist").setTemperature(0.7D); + String firstKey = GenerationRegistryContractFactory.customBiomeResourceKey("Overworld", first); + String secondKey = GenerationRegistryContractFactory.customBiomeResourceKey("Overworld", second); + List physicalKeys = List.of("minecraft:plains", firstKey, secondKey); + + List clientKeys = RegistryClientNames.resolve( + GenerationRegistryContractFactory.BIOME_REGISTRY, physicalKeys); + + assertNotEquals(firstKey, secondKey); + assertEquals("minecraft:plains", clientKeys.getFirst()); + assertEquals(Set.of("iris:overworld/mist", "iris_2:overworld/mist"), + Set.of(clientKeys.get(1), clientKeys.get(2))); + assertTrue(firstKey.matches("iris:biomes/[a-f0-9]{64}")); + assertEquals(List.of("minecraft:plains", firstKey, secondKey), physicalKeys); + } + @Test public void contentGateEffectsParticipateInCustomBiomeIdentity() { IrisBiomeCustom custom = new IrisBiomeCustom()