From d3245aa1c27881cefeb93f6f5892f3680e090318 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 7 Sep 2026 19:22:59 -0400 Subject: [PATCH] IA Fixes - Thsi fixes an integration between Itemsadder and Oraxen. - Waiting on oraxen folks --- .../nativegen/NativeTransitionColumnTest.java | 32 ++ .../src/main/java/art/arcane/iris/Iris.java | 21 +- .../iris/core/IrisWorldGeneratorResolver.java | 197 ++++++++- .../src/main/resources/paper-plugin.yml | 4 + .../plugin/src/main/resources/plugin.yml | 1 + .../arcane/iris/PaperPluginMetadataTest.java | 1 + .../iris/core/IrisStartupOrderingTest.java | 11 + .../core/IrisWorldGeneratorResolverTest.java | 57 +++ .../nativegen/NativeTransitionColumn.java | 12 +- build.gradle | 1 + core/build.gradle | 26 ++ core/purity-allowlist.txt | 1 + .../link/data/ItemAdderDataProviderTest.java | 190 +++++++++ .../link/data/OraxenDataProviderTest.java | 91 ++++ .../arcane/iris/core/compat/ContentGate.java | 9 +- .../core/link/data/ItemAdderDataProvider.java | 86 ++-- .../link/data/KGeneratorsDataProvider.java | 6 +- .../core/link/data/MMOItemsDataProvider.java | 16 +- .../link/data/MythicCrucibleDataProvider.java | 10 +- .../iris/core/link/data/NexoDataProvider.java | 10 +- .../core/link/data/OraxenDataProvider.java | 90 ++++ .../art/arcane/iris/core/loader/IrisData.java | 15 + .../iris/core/pack/ContentKeyValidator.java | 4 +- .../iris/core/pack/PackValidationCache.java | 1 + .../iris/core/project/SchemaBuilder.java | 8 +- .../iris/core/service/ExternalDataSVC.java | 199 +++++++-- .../iris/engine/EngineShutdownSequence.java | 31 +- .../arcane/iris/engine/IrisEngineMantle.java | 2 +- .../arcane/iris/engine/IrisWorldManager.java | 10 +- .../iris/engine/WorldBlockDropRouter.java | 33 +- .../iris/engine/WorldChunkMaintenance.java | 255 +++++++++++- .../IrisTransitionGeometryActuator.java | 3 +- .../engine/history/GenerationHistory.java | 12 +- .../GenerationHistoryRuntimeRouter.java | 336 ++++++++++++++- .../iris/engine/object/IrisBlockData.java | 38 +- .../engine/platform/BukkitChunkGenerator.java | 87 +++- .../iris/engine/platform/EngineBukkitOps.java | 7 +- .../platform/bukkit/BukkitBlockState.java | 13 +- .../iris/core/compat/CompatFixtures.java | 8 +- .../iris/core/compat/ContentGateTest.java | 2 + .../compat/IrisBlockDataResolutionTest.java | 120 ++++++ .../core/pack/ContentKeyValidatorTest.java | 35 +- .../core/pack/PackValidationCacheTest.java | 30 ++ .../core/project/ExternalBlockSchemaTest.java | 77 ++++ .../ExternalDataSVCBlockRoutingTest.java | 179 ++++++++ .../ExternalDataSVCContentChangeTest.java | 116 ++++++ .../service/ExternalDataSVCRegistryTest.java | 1 + .../iris/engine/EngineShutdownDrainTest.java | 44 ++ .../engine/IrisWorldManagerNativePoiTest.java | 7 +- .../engine/ResolvedTerrainProviderTest.java | 52 +++ .../iris/engine/WorldBlockDropRouterTest.java | 266 ++++++++++++ .../WorldChunkMaintenanceRoutingTest.java | 391 ++++++++++++++++++ .../WorldChunkMaintenanceSavedBiomeTest.java | 2 +- .../IrisTransitionGeometryActuatorTest.java | 32 ++ .../GenerationHistoryRuntimeRouterTest.java | 136 ++++++ .../BukkitChunkGeneratorCloseTest.java | 1 + ...ukkitChunkGeneratorStartupContentTest.java | 139 +++++++ ...kkitChunkGeneratorStudioSelectionTest.java | 3 + ...eBukkitOpsDeferredMaterializationTest.java | 48 +++ .../bukkit/BukkitSpiConformanceTest.java | 40 ++ gradle/libs.versions.toml | 2 + 61 files changed, 3497 insertions(+), 160 deletions(-) create mode 100644 core/src/externalProviderTest/java/art/arcane/iris/core/link/data/ItemAdderDataProviderTest.java create mode 100644 core/src/externalProviderTest/java/art/arcane/iris/core/link/data/OraxenDataProviderTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/link/data/OraxenDataProvider.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/ExternalBlockSchemaTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCContentChangeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/WorldBlockDropRouterTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceRoutingTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStartupContentTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTransitionColumnTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTransitionColumnTest.java index 38299c1f0..23b0e5fa8 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTransitionColumnTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTransitionColumnTest.java @@ -2,6 +2,10 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.history.BoundaryColumnGeometry; import art.arcane.iris.engine.history.TerrainBoundarySignature; +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 net.minecraft.SharedConstants; import net.minecraft.core.Direction; import net.minecraft.server.Bootstrap; @@ -13,6 +17,7 @@ import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.levelgen.Heightmap; import org.junit.BeforeClass; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.List; import java.util.OptionalInt; @@ -20,6 +25,9 @@ import java.util.OptionalInt; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public final class NativeTransitionColumnTest { @BeforeClass @@ -28,6 +36,30 @@ public final class NativeTransitionColumnTest { Bootstrap.bootStrap(); } + @Test + public void customKeysUseCarrierForNativeColumnsAndHeightmaps() { + String key = "itemsadder:rocks/ruby_ore"; + PlatformBlockState custom = mock(PlatformBlockState.class); + PlatformBlockState carrier = mock(PlatformBlockState.class); + when(custom.placementBaseState()).thenReturn(carrier); + when(carrier.key()).thenReturn("minecraft:oak_log[axis=z]"); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockOrNull(key)).thenReturn(custom); + LevelHeightAccessor height = LevelHeightAccessor.create(-16, 32); + TerrainBoundarySignature signature = signature(key); + + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + platforms.when(IrisPlatforms::get).thenReturn(platform); + NoiseColumn column = NativeTransitionColumn.column(signature, height); + + assertEquals(Direction.Axis.Z, column.getBlock(-15).getValue(RotatedPillarBlock.AXIS)); + assertEquals(-14, NativeTransitionColumn.height(signature, Heightmap.Types.OCEAN_FLOOR_WG, height)); + } + } + @Test public void preservesNativePropertiesCavesAndFluids() { NoiseColumn column = NativeTransitionColumn.column(signature("minecraft:oak_log[axis=x]"), diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 19568ca4a..b7ceb4e6f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -671,9 +671,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(IrisCompat.class, compat); ServerConfigurator.configure(); StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup(); - if (datapackValidation == StartupValidationOutcome.READY) { - generatorResolver.validateAllPacks(); - } IrisSafeguard.execute(); getSender().setTag(getTag()); // A cosmetic banner must never abort the bootstrap. @@ -727,12 +724,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e); } } + if (datapackValidation == StartupValidationOutcome.READY) { + IrisServices.get(ExternalDataSVC.class).setContentChangeListener(generatorResolver::requestExternalContentRefresh); + generatorResolver.validateAllPacks(); + } addShutdownHook(); pendingWorldReplacements.processPendingStartupReplacements(); pendingWorldDeletes.processPendingStartupWorldDeletes(); if (J.isFolia() && IrisStartupValidation.isReady()) { - J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true), 1); + J.s(this::reconcileStartupWorlds, 1); } J.s(() -> { @@ -747,7 +748,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { autoStartStudio(); } if (!J.isFolia() && IrisStartupValidation.isReady()) { - worldReconciler.checkForBukkitWorlds(s -> true); + reconcileStartupWorlds(); } IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName()); // The mantle stores block values as PlatformBlockState, so a BlockData retention can never @@ -757,6 +758,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return true; } + private void reconcileStartupWorlds() { + generatorResolver.startupWorldsReady().whenComplete((ignored, failure) -> { + if (failure != null) { + Iris.reportError("Could not resume Iris startup world reconciliation.", failure); + return; + } + J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true)); + }); + } + public void addShutdownHook() { removeShutdownHook(); serverLifecycleThread = Thread.currentThread(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 6ace9bb89..2171388af 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -20,6 +20,7 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; +import art.arcane.iris.core.compat.CompatRegistry; import art.arcane.iris.core.lifecycle.WorldReplacementSeed; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.BrokenPackException; @@ -29,8 +30,11 @@ import art.arcane.iris.core.pack.PackValidationCache; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.history.GenerationEpoch; @@ -42,6 +46,7 @@ import art.arcane.iris.engine.history.GenerationRegistryContract; import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.util.common.plugin.VolmitPlugin; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.bukkit.WorldIdentity; import lombok.NonNull; import org.bukkit.Bukkit; @@ -60,6 +65,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; /** @@ -72,17 +81,102 @@ public final class IrisWorldGeneratorResolver { private static final Object SNAPSHOT_VALIDATION_LOCK = new Object(); private static final String IRIS_DIMENSION_NAMESPACE = "iris"; private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator"; + private static final long EXTERNAL_CONTENT_STARTUP_TIMEOUT_SECONDS = 120L; private final VolmitPlugin plugin; + private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean(); + private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean(); + private final Map pendingSnapshots = new ConcurrentHashMap<>(); public IrisWorldGeneratorResolver(VolmitPlugin plugin) { this.plugin = plugin; } public void validateAllPacks() { + validateAllPacks(false); + } + + public void requestExternalContentRefresh() { + externalContentRefreshRequested.set(true); + if (!externalContentRefreshQueued.compareAndSet(false, true)) { + return; + } + try { + J.a(this::runExternalContentRefresh); + } catch (RuntimeException | LinkageError error) { + externalContentRefreshQueued.set(false); + throw error; + } + } + + public CompletableFuture startupWorldsReady() { + return CompletableFuture.allOf(pendingSnapshots.values().stream() + .map(PendingSnapshot::ready) + .toArray(CompletableFuture[]::new)); + } + + private void runExternalContentRefresh() { + externalContentRefreshRequested.set(false); + try { + retryPendingSnapshots(); + refreshExternalContent(); + } catch (RuntimeException | LinkageError error) { + Iris.reportError("Failed to revalidate Iris packs after external content changed.", error); + } finally { + externalContentRefreshQueued.set(false); + if (externalContentRefreshRequested.get()) { + requestExternalContentRefresh(); + } + } + } + + private void retryPendingSnapshots() { + for (Map.Entry entry : pendingSnapshots.entrySet()) { + PendingSnapshot pending = entry.getValue(); + if (pending.contentReady().isDone()) { + continue; + } + try { + ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class); + long revision = external == null ? 0L : external.contentRevision(); + boolean wasPending = hasPendingExternalContent(PackValidationRegistry.get(entry.getKey()), external); + PackValidationRegistry.remove(entry.getKey()); + IrisData.invalidateLoadedContentRegistries(entry.getKey().toFile()); + PackValidationResult validation = validateSnapshot(entry.getKey().toFile()); + if (external != null && revision != external.contentRevision()) { + requestExternalContentRefresh(); + continue; + } + boolean stillPending = hasPendingExternalContent(validation, external); + if (wasPending && !stillPending) { + requestExternalContentRefresh(); + continue; + } + if (stillPending) { + continue; + } + PackValidationRegistry.requireLoadable(entry.getKey()); + requireHistoricalDimension(pending.history(), entry.getKey().toFile(), pending.dimensionKey()); + pending.contentReady().complete(null); + } catch (RuntimeException | LinkageError failure) { + pending.contentReady().completeExceptionally(failure); + } + } + } + + synchronized void refreshExternalContent() { + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(plugin.getDataFolder("packs"))) { + IrisData.invalidateLoadedContentRegistries(pack); + } + validateAllPacks(true); + } + + private synchronized void validateAllPacks(boolean externalContentChanged) { File packsRoot = plugin.getDataFolder("packs"); List packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); - PackValidationRegistry.clear(); + if (!externalContentChanged) { + PackValidationRegistry.clear(); + } List packNames = packDirs.stream().map(File::getName).sorted().toList(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); ServerConfigurator.PackContentSnapshot contentSnapshot = @@ -92,11 +186,13 @@ public final class IrisWorldGeneratorResolver { try { contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contextFingerprint = PackValidationCache.contextFingerprint(); - cached = PackValidationCache.load( - cacheFile, - contentSnapshot.content(), - contextFingerprint, - packNames); + if (!externalContentChanged) { + cached = PackValidationCache.load( + cacheFile, + contentSnapshot.content(), + contextFingerprint, + packNames); + } } catch (RuntimeException exception) { Iris.reportError("Could not evaluate the persisted pack-validation cache", exception); } @@ -136,6 +232,12 @@ public final class IrisWorldGeneratorResolver { String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; if (!result.isLoadable()) { + if (hasPendingExternalContent(result, IrisServices.getOrNull(ExternalDataSVC.class)) + && packDirectory != null + && PackValidator.validateForDatapackBootstrap(packDirectory).isLoadable()) { + Iris.info("Pack '" + result.getPackName() + "' is waiting for external block providers to initialize."); + continue; + } Iris.error("Pack '" + result.getPackName() + "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:"); for (String reason : result.getBlockingErrors()) { @@ -226,6 +328,11 @@ public final class IrisWorldGeneratorResolver { } static PackValidationResult requireSnapshotLoadable(File packRoot) { + validateSnapshot(packRoot); + return PackValidationRegistry.requireLoadable(packRoot.toPath()); + } + + private static PackValidationResult validateSnapshot(File packRoot) { Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize(); PackValidationResult result = PackValidationRegistry.get(normalizedRoot); if (result == null) { @@ -255,7 +362,7 @@ public final class IrisWorldGeneratorResolver { } } } - return PackValidationRegistry.requireLoadable(normalizedRoot); + return result; } @Nullable @@ -493,6 +600,10 @@ public final class IrisWorldGeneratorResolver { long worldSeed = requireStoredWorldSeed(worldName, dimensionRoot); GenerationHistory history = requireGenerationHistory(dimensionRoot, id, worldSeed); File snapshotRoot = requireActivePack(history); + PackValidationResult validation = validateSnapshot(snapshotRoot); + if (hasPendingExternalContent(validation, IrisServices.getOrNull(ExternalDataSVC.class))) { + return deferFrozenWorldGenerator(worldName, worldKey, dimensionRoot, snapshotRoot, id, history); + } try { requireSnapshotLoadable(snapshotRoot); } catch (BrokenPackException exception) { @@ -522,6 +633,61 @@ public final class IrisWorldGeneratorResolver { return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey(), history); } + static boolean hasPendingExternalContent(PackValidationResult validation, ExternalDataSVC external) { + return validation != null && external != null && validation.getCompatFindings().stream() + .anyMatch(finding -> finding.registry() == CompatRegistry.BLOCK + && external.hasPendingBlockProvider(finding.key())); + } + + private BukkitChunkGenerator deferFrozenWorldGenerator( + String worldName, + NamespacedKey worldKey, + File dimensionRoot, + File snapshotRoot, + String dimensionKey, + GenerationHistory history + ) { + PackValidationResult structural = PackValidator.validateForDatapackBootstrap(snapshotRoot); + if (!structural.isLoadable()) { + throw new BrokenPackException(snapshotRoot.toString(), structural.getBlockingErrors()); + } + IrisData data = IrisData.openDatapackCompiler(snapshotRoot); + try { + IrisDimension dimension = requireHistoricalDimension(history, data, dimensionKey); + GenerationEpoch.DimensionContract contract = history.activeEpoch().dimensionContract(); + IrisWorld world = IrisWorld.builder() + .platformIdentity(worldKey.toString()) + .name(worldName) + .seed(history.activeEpoch().worldSeed()) + .worldFolder(dimensionRoot) + .minHeight(contract.minHeight()) + .maxHeight(Math.addExact(contract.minHeight(), contract.height())) + .build(); + Path snapshotPath = snapshotRoot.toPath().toAbsolutePath().normalize(); + CompletableFuture contentReady = new CompletableFuture<>(); + BukkitChunkGenerator generator = new BukkitChunkGenerator( + world, false, snapshotRoot, dimensionKey, history); + generator.deferStartup(new EngineTarget(world, dimension, data), contentReady); + PendingSnapshot pending = new PendingSnapshot(history, dimensionKey, contentReady, generator.getStartupReady()); + if (pendingSnapshots.putIfAbsent(snapshotPath, pending) != null) { + generator.closeAsync(); + throw new IllegalStateException("Iris snapshot already has a deferred startup: " + snapshotPath); + } + contentReady.orTimeout(EXTERNAL_CONTENT_STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + pending.ready().whenComplete((ignored, failure) -> { + if (failure == null) { + pendingSnapshots.remove(snapshotPath, pending); + } + }); + Iris.info("Waiting for external block providers before initializing Iris world '" + worldName + "'."); + requestExternalContentRefresh(); + return generator; + } catch (RuntimeException | LinkageError failure) { + data.close(); + throw failure; + } + } + private static GenerationHistory requireGenerationHistory( File dimensionRoot, String dimensionKey, @@ -587,6 +753,14 @@ public final class IrisWorldGeneratorResolver { GenerationHistory history, File packRoot, String dimensionKey + ) { + return requireHistoricalDimension(history, IrisData.get(packRoot), dimensionKey); + } + + private static IrisDimension requireHistoricalDimension( + GenerationHistory history, + IrisData data, + String dimensionKey ) { GenerationEpoch epoch = history.activeEpoch(); if (!epoch.dimensionContract().dimensionKey().equals(dimensionKey)) { @@ -594,21 +768,24 @@ public final class IrisWorldGeneratorResolver { + " does not match active generation history dimension " + epoch.dimensionContract().dimensionKey() + "."); } - IrisData data = IrisData.get(packRoot); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); if (dimension == null) { - throw new IllegalStateException("Immutable Iris generation pack at " + packRoot + throw new IllegalStateException("Immutable Iris generation pack at " + data.getDataFolder() + " does not contain dimension " + dimensionKey + "."); } GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch( dimension, dimensionTypeKey(data, dimension), epoch); if (!epoch.dimensionContract().equals(actualContract)) { throw new IllegalStateException("Immutable Iris generation pack dimension contract changed at " - + packRoot + "."); + + data.getDataFolder() + "."); } return dimension; } + private record PendingSnapshot(GenerationHistory history, String dimensionKey, + CompletableFuture contentReady, CompletableFuture ready) { + } + private static GenerationEpoch.DimensionContract captureDimensionContract( IrisData data, IrisDimension dimension diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index c8bebaf82..b23f9db72 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -36,6 +36,10 @@ dependencies: load: BEFORE required: false join-classpath: true + Oraxen: + load: BEFORE + required: false + join-classpath: true ItemsAdder: load: BEFORE required: false diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index 1cf377ae4..cc5c3df03 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ softdepend: - PlaceholderAPI - CraftEngine - Nexo + - Oraxen - ItemsAdder - SCore - ExecutableItems diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index 2f9c7158e..4fa463c9d 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -24,6 +24,7 @@ public class PaperPluginMetadataTest { "PlaceholderAPI", "CraftEngine", "Nexo", + "Oraxen", "ItemsAdder", "SCore", "ExecutableItems", diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java index feae5cf6e..00365eba2 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java @@ -29,6 +29,17 @@ public class IrisStartupOrderingTest { "generatorResolver.validateAllPacks();"); } + @Test + public void externalProvidersEnableBeforePackValidation() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); + String enable = section(source, "private boolean enable()", "public void addShutdownHook()"); + + assertOrdered(enable, + "service.onEnable();", + "setContentChangeListener(generatorResolver::requestExternalContentRefresh)", + "generatorResolver.validateAllPacks();"); + } + @Test public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception { String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index e86eaca7e..4b57daca7 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -2,9 +2,15 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; +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.service.ExternalDataSVC; import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.util.common.plugin.VolmitPlugin; +import art.arcane.iris.util.common.scheduling.J; import org.junit.After; import org.junit.Rule; import org.junit.Test; @@ -22,7 +28,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.ArrayList; import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import static org.junit.Assert.assertEquals; @@ -36,12 +44,31 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; public class IrisWorldGeneratorResolverTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void snapshotDeferralRequiresAnUnresolvedBlockWithAPendingProvider() { + ExternalDataSVC external = mock(ExternalDataSVC.class); + when(external.hasPendingBlockProvider("itemsadder:example/stone")).thenReturn(true); + PackValidationResult unavailable = new PackValidationResult("pack", List.of("unavailable"), List.of(), 1L, + List.of(new CompatFinding(CompatRegistry.BLOCK, "itemsadder:example/stone", + CompatAction.EXCLUDED, "biome", "main", "layers[0]")), "26.2"); + + assertTrue(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, external)); + when(external.hasPendingBlockProvider("itemsadder:example/stone")).thenReturn(false); + assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, external)); + assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, null)); + assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent( + new PackValidationResult("vanilla", List.of(), List.of(), 1L), external)); + } + @After public void clearValidationState() { PackValidationRegistry.clear(); @@ -83,6 +110,36 @@ public class IrisWorldGeneratorResolverTest { assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable()); } + @Test + public void providerEventsCoalesceAndRefreshAgainAfterAnInFlightChange() { + IrisWorldGeneratorResolver resolver = spy(new IrisWorldGeneratorResolver(mock(VolmitPlugin.class))); + List tasks = new ArrayList<>(); + AtomicInteger passes = new AtomicInteger(); + doAnswer(invocation -> { + if (passes.incrementAndGet() == 1) { + resolver.requestExternalContentRefresh(); + assertTrue(tasks.isEmpty()); + } + return null; + }).when(resolver).refreshExternalContent(); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { + tasks.add(invocation.getArgument(0, Runnable.class)); + return null; + }); + + resolver.requestExternalContentRefresh(); + resolver.requestExternalContentRefresh(); + assertEquals(1, tasks.size()); + tasks.removeFirst().run(); + assertEquals(1, passes.get()); + assertEquals(1, tasks.size()); + tasks.removeFirst().run(); + assertEquals(2, passes.get()); + assertTrue(tasks.isEmpty()); + } + } + @Test public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception { String source = Files.readString(Path.of( diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTransitionColumn.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTransitionColumn.java index 731d3aff2..ce26f6dff 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTransitionColumn.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTransitionColumn.java @@ -1,6 +1,8 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.exceptions.CommandSyntaxException; import net.minecraft.commands.arguments.blocks.BlockStateParser; @@ -39,7 +41,15 @@ public final class NativeTransitionColumn { } private static BlockState parse(String key) { - StringReader reader = new StringReader(key); + String nativeKey = key; + if (!key.startsWith("minecraft:") && IrisPlatforms.isBound()) { + PlatformBlockState logicalState = IrisPlatforms.get().registries().blockOrNull(key); + PlatformBlockState baseState = logicalState == null ? null : logicalState.placementBaseState(); + if (baseState != null) { + nativeKey = baseState.key(); + } + } + StringReader reader = new StringReader(nativeKey); try { BlockState state = BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, reader, false).blockState(); if (reader.canRead()) { diff --git a/build.gradle b/build.gradle index 562cdcc55..082105cda 100644 --- a/build.gradle +++ b/build.gradle @@ -703,6 +703,7 @@ allprojects { maven { url = uri('https://jitpack.io') } // score maven { url = uri('https://repo.auxilor.io/repository/maven-releases/') } // eco maven { url = uri('https://repo.nexomc.com/releases/') } // nexo + maven { url = uri('https://repo.oraxen.com/releases/') } maven { url = uri('https://maven.devs.beer/') } // itemsadder maven { url = uri('https://repo.extendedclip.com/releases/') } // placeholderapi maven { url = uri('https://mvn.lumine.io/repository/maven-public/') } // mythic diff --git a/core/build.gradle b/core/build.gradle index 9da211f23..7d28e7ec5 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -81,6 +81,9 @@ dependencies { // Third Party Integrations compileOnly(libs.nexo) + compileOnly(libs.oraxen) { + transitive = false + } compileOnly(libs.itemsadder) compileOnly(libs.score) compileOnly(libs.mmoitems) @@ -481,6 +484,29 @@ sourceSets { srcDir(generateTemplates.map { it.outputs }) } } + externalProviderTest { + compileClasspath += sourceSets.test.compileClasspath + sourceSets.main.output + runtimeClasspath += sourceSets.test.runtimeClasspath + sourceSets.main.output + } +} + +dependencies { + externalProviderTestImplementation(libs.oraxen) { + transitive = false + } + externalProviderTestImplementation(libs.itemsadder) { + transitive = false + } +} + +tasks.register('externalProviderTest', Test) { + testClassesDirs = sourceSets.externalProviderTest.output.classesDirs + classpath = sourceSets.externalProviderTest.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector') +} + +tasks.named('check').configure { + dependsOn('externalProviderTest') } tasks.register('bukkitPurityRatchet') { diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 3b45a00b4..8add17141 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -35,6 +35,7 @@ art/arcane/iris/core/link/data/MMOItemsDataProvider.java art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java art/arcane/iris/core/link/data/MythicMobsDataProvider.java art/arcane/iris/core/link/data/NexoDataProvider.java +art/arcane/iris/core/link/data/OraxenDataProvider.java art/arcane/iris/core/nms/INMS.java art/arcane/iris/core/nms/INMSBinding.java art/arcane/iris/core/nms/MinecraftVersion.java diff --git a/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/ItemAdderDataProviderTest.java b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/ItemAdderDataProviderTest.java new file mode 100644 index 000000000..056849caf --- /dev/null +++ b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/ItemAdderDataProviderTest.java @@ -0,0 +1,190 @@ +package art.arcane.iris.core.link.data; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.volmlib.util.collection.KMap; +import dev.lone.itemsadder.api.CustomBlock; +import dev.lone.itemsadder.api.CustomStack; +import dev.lone.itemsadder.api.Events.ItemsAdderLoadDataEvent; +import dev.lone.itemsadder.api.ItemsAdder; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.plugin.Plugin; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.MissingResourceException; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +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.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class ItemAdderDataProviderTest { + @Test + public void loadedEmptyRegistryIsReadyAndDisabledPluginIsNot() { + ItemAdderDataProvider provider = spy(new ItemAdderDataProvider()); + Plugin plugin = mock(Plugin.class); + doReturn(plugin).when(provider).getPlugin(); + when(plugin.isEnabled()).thenReturn(true); + try (MockedStatic api = mockStatic(ItemsAdder.class); + MockedStatic items = mockStatic(CustomStack.class); + MockedStatic blocks = mockStatic(CustomBlock.class)) { + api.when(ItemsAdder::areItemsLoaded).thenReturn(false); + provider.init(); + assertFalse(provider.isReady()); + + items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of()); + blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of()); + provider.onLoadData(mock(ItemsAdderLoadDataEvent.class)); + + assertTrue(provider.isReady()); + assertTrue(provider.getTypes(DataType.BLOCK).isEmpty()); + when(plugin.isEnabled()).thenReturn(false); + assertFalse(provider.isReady()); + } + } + + @Test + public void startupWaitsForLoadEventBeforeReadingRegistries() { + ItemAdderDataProvider provider = new ItemAdderDataProvider(); + try (MockedStatic api = mockStatic(ItemsAdder.class); + MockedStatic items = mockStatic(CustomStack.class); + MockedStatic blocks = mockStatic(CustomBlock.class)) { + api.when(ItemsAdder::areItemsLoaded).thenReturn(false); + + provider.init(); + + assertTrue(provider.getTypes(DataType.ITEM).isEmpty()); + assertTrue(provider.getTypes(DataType.BLOCK).isEmpty()); + assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK)); + items.verifyNoInteractions(); + blocks.verifyNoInteractions(); + + items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore")); + blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore")); + provider.onLoadData(mock(ItemsAdderLoadDataEvent.class)); + + assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.ITEM)); + assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.BLOCK)); + assertTrue(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK)); + } + } + + @Test + public void blockClaimsUseExactRegistryIds() { + ItemAdderDataProvider provider = new ItemAdderDataProvider(); + try (MockedStatic api = mockStatic(ItemsAdder.class); + MockedStatic items = mockStatic(CustomStack.class); + MockedStatic blocks = mockStatic(CustomBlock.class)) { + api.when(ItemsAdder::areItemsLoaded).thenReturn(true); + items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore", "rocks:ruby_sword")); + blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore")); + + provider.init(); + + assertTrue(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_sword"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("rocks", "unknown"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("other", "ruby_ore"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.ENTITY)); + } + } + + @Test + public void loadEventReplacesBlockClaimsAfterRegistryChanges() { + ItemAdderDataProvider provider = new ItemAdderDataProvider(); + try (MockedStatic api = mockStatic(ItemsAdder.class); + MockedStatic items = mockStatic(CustomStack.class); + MockedStatic blocks = mockStatic(CustomBlock.class)) { + api.when(ItemsAdder::areItemsLoaded).thenReturn(true); + items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore")); + blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore")); + provider.init(); + items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("gems:sapphire_ore")); + blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:sapphire_ore")); + + assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.ITEM)); + assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.BLOCK)); + items.verify(CustomStack::getNamespacedIdsInRegistry); + blocks.verify(CustomBlock::getNamespacedIdsInRegistry); + + provider.onLoadData(mock(ItemsAdderLoadDataEvent.class)); + + assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK)); + assertTrue(provider.isValidProvider(new Identifier("rocks", "sapphire_ore"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.ITEM)); + assertTrue(provider.isValidProvider(new Identifier("gems", "sapphire_ore"), DataType.ITEM)); + assertEquals(Set.of(new Identifier("gems", "sapphire_ore")), provider.getTypes(DataType.ITEM)); + assertEquals(Set.of(new Identifier("rocks", "sapphire_ore")), provider.getTypes(DataType.BLOCK)); + } + } + + @Test + public void resolutionRetainsCarrierAndNativeIdentifier() { + Identifier identifier = new Identifier("rocks", "ruby_ore"); + CustomBlock customBlock = mock(CustomBlock.class); + BlockData carrier = mock(BlockData.class); + when(customBlock.getBaseBlockData()).thenReturn(carrier); + try (MockedStatic blocks = mockStatic(CustomBlock.class)) { + blocks.when(() -> CustomBlock.getInstance("rocks:ruby_ore")).thenReturn(customBlock); + + BlockData result = new ItemAdderDataProvider().getBlockData(identifier, new KMap<>()); + + assertTrue(result instanceof IrisCustomData); + IrisCustomData custom = (IrisCustomData) result; + assertSame(carrier, custom.getBase()); + assertEquals(identifier, custom.getCustom()); + } + } + + @Test + public void placementDoesNotOverwriteProviderInitializedBlock() { + Block block = mock(Block.class); + Location location = new Location(null, 7, 81, -6); + CustomBlock placed = mock(CustomBlock.class); + when(block.getLocation()).thenReturn(location); + try (MockedStatic blocks = mockStatic(CustomBlock.class)) { + blocks.when(() -> CustomBlock.place("rocks:ruby_ore", location)).thenReturn(placed); + + new ItemAdderDataProvider().processUpdate(mock(Engine.class), block, new Identifier("rocks", "ruby_ore")); + + blocks.verify(() -> CustomBlock.place("rocks:ruby_ore", location)); + verify(block).getLocation(); + verifyNoMoreInteractions(block, placed); + } + } + + @Test + public void missingPlacementReportsFailure() { + Block block = mock(Block.class); + when(block.getLocation()).thenReturn(new Location(null, 7, 81, -6)); + try (MockedStatic blocks = mockStatic(CustomBlock.class)) { + assertThrows(MissingResourceException.class, () -> new ItemAdderDataProvider().processUpdate( + mock(Engine.class), block, new Identifier("rocks", "unknown"))); + } + } + + @Test + public void unsupportedStateDoesNotResolveDefaultBlockSilently() { + KMap state = new KMap<>(); + state.put("facing", "north"); + try (MockedStatic blocks = mockStatic(CustomBlock.class)) { + assertThrows(MissingResourceException.class, () -> new ItemAdderDataProvider().getBlockData( + new Identifier("rocks", "ruby_ore"), state)); + + blocks.verifyNoInteractions(); + } + } +} diff --git a/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/OraxenDataProviderTest.java b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/OraxenDataProviderTest.java new file mode 100644 index 000000000..8603846f3 --- /dev/null +++ b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/OraxenDataProviderTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.core.link.data; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.volmlib.util.collection.KMap; +import io.th0rgal.oraxen.api.OraxenBlocks; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.MissingResourceException; + +import static org.junit.Assert.assertEquals; +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.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class OraxenDataProviderTest { + @Test + public void blockResolutionRetainsProviderIdentityForDeferredPlacement() { + Identifier identifier = new Identifier("oraxen", "ruby_ore"); + BlockData carrier = mock(BlockData.class); + OraxenDataProvider provider = new OraxenDataProvider(); + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + blocks.when(() -> OraxenBlocks.getOraxenBlockData("ruby_ore")).thenReturn(carrier); + + BlockData result = provider.getBlockData(identifier, new KMap<>()); + + assertTrue(result instanceof IrisCustomData); + IrisCustomData custom = (IrisCustomData) result; + assertSame(carrier, custom.getBase()); + assertEquals(identifier, custom.getCustom()); + } + } + + @Test + public void missingBlockDoesNotProduceCarrierData() { + OraxenDataProvider provider = new OraxenDataProvider(); + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + assertThrows(MissingResourceException.class, + () -> provider.getBlockData(new Identifier("oraxen", "unknown"), new KMap<>())); + } + } + + @Test + public void unsupportedStateDoesNotResolveDefaultBlockSilently() { + OraxenDataProvider provider = new OraxenDataProvider(); + KMap state = new KMap<>(); + state.put("facing", "north"); + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + assertThrows(MissingResourceException.class, + () -> provider.getBlockData(new Identifier("oraxen", "ruby_ore"), state)); + + blocks.verifyNoInteractions(); + } + } + + @Test + public void placementUsesProviderApiAtTargetLocation() { + OraxenDataProvider provider = new OraxenDataProvider(); + Block block = mock(Block.class); + Location location = new Location(null, 31, 80, -18); + when(block.getLocation()).thenReturn(location); + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + provider.processUpdate(mock(Engine.class), block, new Identifier("oraxen", "ruby_ore")); + + blocks.verify(() -> OraxenBlocks.place("ruby_ore", location)); + } + } + + @Test + public void blockClaimsRequireExactRegisteredMechanic() { + OraxenDataProvider provider = new OraxenDataProvider(); + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + blocks.when(() -> OraxenBlocks.isOraxenBlock("ruby_ore")).thenReturn(true); + + assertTrue(provider.isValidProvider(new Identifier("oraxen", "ruby_ore"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("oraxen", "ruby_sword"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("nexo", "ruby_ore"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("oraxen", "ruby_ore"), DataType.ENTITY)); + assertTrue(provider.getTypes(DataType.ENTITY).isEmpty()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java b/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java index 71cf42e42..b84761b33 100644 --- a/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java +++ b/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java @@ -382,21 +382,24 @@ public final class ContentGate { return merged.isEmpty() ? Map.of() : Collections.unmodifiableMap(merged); } - /** {@code namespace:name[props]} lowercased with the default namespace applied; null for blank input. */ + /** Normalizes block names and vanilla properties, preserving external property case; null for blank input. */ public static String normalizeState(String state) { if (state == null) { return null; } - String trimmed = state.trim().toLowerCase(Locale.ROOT); + String trimmed = state.trim(); if (trimmed.isEmpty()) { return null; } int props = trimmed.indexOf('['); - String base = props < 0 ? trimmed : trimmed.substring(0, props); + String base = (props < 0 ? trimmed : trimmed.substring(0, props)).toLowerCase(Locale.ROOT); String rest = props < 0 ? "" : trimmed.substring(props); if (base.indexOf(':') < 0) { base = DEFAULT_NAMESPACE + ':' + base; } + if (base.startsWith(DEFAULT_NAMESPACE + ':')) { + rest = rest.toLowerCase(Locale.ROOT); + } return base + rest; } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java index 1910670cd..5410458f4 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java @@ -3,12 +3,15 @@ package art.arcane.iris.core.link.data; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.link.ExternalDataProvider; import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.IrisServices; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.IrisCustomData; import dev.lone.itemsadder.api.CustomBlock; import dev.lone.itemsadder.api.CustomStack; import dev.lone.itemsadder.api.Events.ItemsAdderLoadDataEvent; +import dev.lone.itemsadder.api.ItemsAdder; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; import org.bukkit.event.EventHandler; @@ -24,8 +27,7 @@ import java.util.stream.Collectors; public class ItemAdderDataProvider extends ExternalDataProvider implements Listener { - private volatile Set itemNamespaces = Set.of(); - private volatile Set blockNamespaces = Set.of(); + private volatile ContentRegistry content = new ContentRegistry(Set.of(), Set.of(), Set.of(), false); public ItemAdderDataProvider() { super("ItemsAdder"); @@ -33,22 +35,42 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste @Override public void init() { - updateNamespaces(); + if (ItemsAdder.areItemsLoaded()) { + updateContent(); + } + } + + @Override + public boolean isReady() { + return super.isReady() && content.ready(); } @EventHandler public void onLoadData(ItemsAdderLoadDataEvent event) { - updateNamespaces(); + if (!updateContent()) { + return; + } + ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class); + if (service != null) { + service.notifyContentChanged(); + } } @NotNull @Override public BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap state) throws MissingResourceException { + if (!state.isEmpty()) { + throw new MissingResourceException("ItemsAdder blocks do not expose Iris block properties.", blockId.namespace(), blockId.key()); + } CustomBlock block = CustomBlock.getInstance(blockId.toString()); if (block == null) { throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); } - return IrisCustomData.of(block.getBaseBlockData(), blockId); + BlockData base = block.getBaseBlockData(); + if (base == null) { + throw new MissingResourceException("Failed to find ItemsAdder block data.", blockId.namespace(), blockId.key()); + } + return IrisCustomData.of(base, blockId); } @NotNull @@ -63,46 +85,42 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste @Override public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - CustomBlock custom; - if ((custom = CustomBlock.place(blockId.toString(), block.getLocation())) == null) - return; - block.setBlockData(custom.getBaseBlockData(), false); + if (CustomBlock.place(blockId.toString(), block.getLocation()) == null) { + throw new MissingResourceException("Failed to place ItemsAdder block.", blockId.namespace(), blockId.key()); + } } @Override public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { return switch (dataType) { case ENTITY -> List.of(); - case ITEM -> CustomStack.getNamespacedIdsInRegistry() - .stream() - .map(Identifier::fromString) - .toList(); - case BLOCK -> CustomBlock.getNamespacedIdsInRegistry() - .stream() - .map(Identifier::fromString) - .toList(); + case ITEM -> content.items(); + case BLOCK -> content.blocks(); }; } - private void updateNamespaces() { - try { - updateNamespaces(DataType.ITEM); - updateNamespaces(DataType.BLOCK); - } catch (Throwable e) { - IrisLogging.warn("Failed to update ItemAdder namespaces: " + e.getMessage()); - } - } - - private void updateNamespaces(DataType dataType) { - Set namespaces = getTypes(dataType).stream().map(Identifier::namespace).collect(Collectors.toSet()); - if (dataType == DataType.ITEM) itemNamespaces = namespaces; - else blockNamespaces = namespaces; - IrisLogging.debug("Updated ItemAdder namespaces: " + dataType + " - " + namespaces); - } - @Override public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { if (dataType == DataType.ENTITY) return false; - return dataType == DataType.ITEM ? itemNamespaces.contains(id.namespace()) : blockNamespaces.contains(id.namespace()); + return dataType == DataType.ITEM ? content.itemNamespaces().contains(id.namespace()) : content.blocks().contains(id); + } + + private boolean updateContent() { + try { + Set items = CustomStack.getNamespacedIdsInRegistry().stream() + .map(Identifier::fromString).collect(Collectors.toUnmodifiableSet()); + Set blocks = CustomBlock.getNamespacedIdsInRegistry().stream() + .map(Identifier::fromString).collect(Collectors.toUnmodifiableSet()); + Set itemNamespaces = items.stream().map(Identifier::namespace).collect(Collectors.toUnmodifiableSet()); + content = new ContentRegistry(items, blocks, itemNamespaces, true); + IrisLogging.debug("Updated ItemsAdder content registry: " + items.size() + " items, " + blocks.size() + " blocks"); + return true; + } catch (RuntimeException | LinkageError e) { + IrisLogging.reportError("Failed to update ItemsAdder content registry.", e); + return false; + } + } + + private record ContentRegistry(Set items, Set blocks, Set itemNamespaces, boolean ready) { } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java index ec4846a25..4cdb4885e 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java @@ -71,7 +71,9 @@ public class KGeneratorsDataProvider extends ExternalDataProvider { @Override public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { - if (dataType == DataType.ENTITY) return false; - return "kgenerators".equalsIgnoreCase(id.namespace()); + if (dataType == DataType.ENTITY || !"kgenerators".equalsIgnoreCase(id.namespace())) { + return false; + } + return dataType == DataType.ITEM || Main.getGenerators().get(id.key()) != null; } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java index 99f517df3..6f5e76c71 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java @@ -116,8 +116,20 @@ public class MMOItemsDataProvider extends ExternalDataProvider { @Override public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { - if (dataType == DataType.ENTITY) return false; - return dataType == DataType.ITEM ? id.namespace().split("_", 2).length == 2 : id.namespace().equals("mmoitems"); + if (dataType == DataType.ENTITY) { + return false; + } + if (dataType == DataType.ITEM) { + return id.namespace().split("_", 2).length == 2; + } + if (!id.namespace().equals("mmoitems")) { + return false; + } + try { + return api().getCustomBlocks().getBlock(Integer.parseInt(id.key())) != null; + } catch (NumberFormatException ignored) { + return false; + } } private MMOItems api() { diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java index 4b5317d10..27d7358c8 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java @@ -138,7 +138,13 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { @Override public boolean isValidProvider(@NotNull Identifier key, DataType dataType) { - if (dataType == DataType.ENTITY) return false; - return key.namespace().equalsIgnoreCase("crucible"); + if (dataType == DataType.ENTITY || !key.namespace().equalsIgnoreCase("crucible")) { + return false; + } + if (dataType == DataType.ITEM) { + return true; + } + CrucibleItem item = itemManager.getItem(key.key()).orElse(null); + return item != null && (item.getBlockData() != null || item.getFurnitureData() != null); } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java index e8bccf17a..edf9d9b53 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java @@ -140,7 +140,13 @@ public class NexoDataProvider extends ExternalDataProvider { @Override public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { - if (dataType == DataType.ENTITY) return false; - return "nexo".equalsIgnoreCase(id.namespace()); + if (!"nexo".equalsIgnoreCase(id.namespace())) { + return false; + } + return switch (dataType) { + case BLOCK -> NexoBlocks.isCustomBlock(id.key()) || NexoFurniture.isFurniture(id.key()); + case ITEM -> NexoItems.exists(id.key()); + case ENTITY -> false; + }; } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/OraxenDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/OraxenDataProvider.java new file mode 100644 index 000000000..f434e8f2b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/link/data/OraxenDataProvider.java @@ -0,0 +1,90 @@ +package art.arcane.iris.core.link.data; + +import art.arcane.iris.core.link.ExternalDataProvider; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.volmlib.util.collection.KMap; +import io.th0rgal.oraxen.api.OraxenBlocks; +import io.th0rgal.oraxen.api.OraxenItems; +import io.th0rgal.oraxen.api.events.OraxenItemsLoadedEvent; +import io.th0rgal.oraxen.items.ItemBuilder; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.inventory.ItemStack; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.List; +import java.util.MissingResourceException; + +public final class OraxenDataProvider extends ExternalDataProvider implements Listener { + public OraxenDataProvider() { + super("Oraxen"); + } + + @Override + public void init() { + } + + @EventHandler + public void onItemsLoaded(OraxenItemsLoadedEvent event) { + ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class); + if (service != null) { + service.notifyContentChanged(); + } + } + + @Override + public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap state) { + if (!state.isEmpty()) { + throw new MissingResourceException("Oraxen blocks do not expose Iris block properties.", blockId.namespace(), blockId.key()); + } + BlockData data = OraxenBlocks.getOraxenBlockData(blockId.key()); + if (data == null) { + throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); + } + return IrisCustomData.of(data, blockId); + } + + @Override + public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) { + ItemBuilder builder = OraxenItems.getItemById(itemId.key()); + if (builder == null) { + throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); + } + return builder.build(); + } + + @Override + public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + OraxenBlocks.place(blockId.key(), block.getLocation()); + } + + @Override + public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { + if (dataType == DataType.ENTITY) { + return List.of(); + } + return OraxenItems.getNames().stream() + .filter(id -> dataType == DataType.ITEM || OraxenBlocks.isOraxenBlock(id)) + .map(id -> new Identifier("oraxen", id)) + .toList(); + } + + @Override + public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { + if (!"oraxen".equalsIgnoreCase(id.namespace())) { + return false; + } + return switch (dataType) { + case BLOCK -> OraxenBlocks.isOraxenBlock(id.key()); + case ITEM -> OraxenItems.exists(id.key()); + case ENTITY -> false; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 302951712..8445c923b 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -198,6 +198,21 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } } + public static void invalidateLoadedContentRegistries(File source) { + Path requested = dataFolderIdentity(Objects.requireNonNull(source, "authoring pack source")); + for (Map.Entry entry : dataLoaders.entrySet()) { + IrisData data = entry.getValue(); + if (dataFolderIdentity(entry.getKey()).equals(requested)) { + synchronized (data) { + if (data.generationRegistryContract == null && data.getEngines().isEmpty()) { + data.invalidateAuthoringResources(); + data.compatReport.clear(); + } + } + } + } + } + private synchronized void invalidateAuthoringResources() { if (generationRegistryContract != null) { throw new IllegalStateException("Cannot invalidate an immutable generation pack as an authoring source."); diff --git a/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java index 707860655..35f0ab405 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java @@ -88,7 +88,7 @@ public final class ContentKeyValidator { return List.of(); } - List blockKeys = normalizeKeyList(registries.blockKeys()); + List blockKeys = normalizeKeyList(registries.blockTypeKeys()); List itemKeys = normalizeKeyList(registries.itemKeys()); List entityKeys = normalizeKeyList(registries.entityKeys()); @@ -432,7 +432,7 @@ public final class ContentKeyValidator { if (registries == null) { return ContentKeyIssues.none(); } - List blockKeys = registries.blockKeys(); + List blockKeys = registries.blockTypeKeys(); List itemKeys = registries.itemKeys(); List entityKeys = registries.entityKeys(); if (blockKeys == null || blockKeys.isEmpty() || itemKeys == null || itemKeys.isEmpty() || entityKeys == null || entityKeys.isEmpty()) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java index a49c7ca6b..f820fd19e 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java @@ -57,6 +57,7 @@ public final class PackValidationCache { PlatformRegistries registries = Objects.requireNonNull( platform.registries(), "Pack validation platform registries"); updateSorted(digest, registries.blockKeys()); + updateSorted(digest, registries.blockTypeKeys()); updateSorted(digest, registries.biomeKeys()); updateSorted(digest, registries.itemKeys()); updateSorted(digest, registries.entityKeys()); diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index 690b40f63..a311eb111 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -314,6 +314,10 @@ public class SchemaBuilder { return a; } + private static String definitionReference(String key) { + return "#/definitions/" + key.replace("~", "~0").replace("/", "~1"); + } + private static String stripNamespace(String key) { int idx = key.indexOf(':'); return idx < 0 ? key : key.substring(idx + 1); @@ -566,12 +570,12 @@ public class SchemaBuilder { .put("properties", new JSONObject() .put(blockType, new JSONObject() .put("type", "string") - .put("$ref", "#/definitions/" + enumKey)))) + .put("$ref", definitionReference(enumKey))))) .put("then", new JSONObject() .put("properties", new JSONObject() .put(k.getName(), new JSONObject() .put("type", "object") - .put("$ref", "#/definitions/" + propertiesKey)))) + .put("$ref", definitionReference(propertiesKey))))) .put("else", false)); if (!definitions.containsKey(enumKey)) { diff --git a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java index 00b291701..bf590f3db 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java @@ -26,7 +26,7 @@ import art.arcane.iris.core.link.data.DataType; import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.framework.Engine; -import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.plugin.IrisService; import org.bukkit.Bukkit; @@ -41,10 +41,16 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import java.util.Collection; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.MissingResourceException; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; public class ExternalDataSVC implements IrisService { @@ -52,6 +58,7 @@ public class ExternalDataSVC implements IrisService { private static final List BUILT_IN_PROVIDERS = List.of( new ProviderDefinition("CraftEngine", PROVIDER_PACKAGE + "CraftEngineDataProvider"), new ProviderDefinition("Nexo", PROVIDER_PACKAGE + "NexoDataProvider"), + new ProviderDefinition("Oraxen", PROVIDER_PACKAGE + "OraxenDataProvider"), new ProviderDefinition("ItemsAdder", PROVIDER_PACKAGE + "ItemAdderDataProvider"), new ProviderDefinition("ExecutableItems", PROVIDER_PACKAGE + "ExecutableItemsDataProvider"), new ProviderDefinition("MMOItems", PROVIDER_PACKAGE + "MMOItemsDataProvider"), @@ -60,9 +67,13 @@ public class ExternalDataSVC implements IrisService { new ProviderDefinition("MythicCrucible", PROVIDER_PACKAGE + "MythicCrucibleDataProvider"), new ProviderDefinition("KGenerators", PROVIDER_PACKAGE + "KGeneratorsDataProvider") ); + private static final Set BLOCK_PROVIDER_PLUGINS = Set.of( + "craftengine", "nexo", "oraxen", "itemsadder", "mmoitems", "mythiccrucible", "kgenerators"); - private final KList providers = new KList<>(); - private final KList activeProviders = new KList<>(); + private final List providers = new CopyOnWriteArrayList<>(); + private final List activeProviders = new CopyOnWriteArrayList<>(); + private final AtomicLong contentRevision = new AtomicLong(); + private volatile Runnable contentChangeListener; @Override public void onEnable() { @@ -77,10 +88,67 @@ public class ExternalDataSVC implements IrisService { @Override public void onDisable() { + contentChangeListener = null; activeProviders.clear(); providers.clear(); } + public void setContentChangeListener(Runnable listener) { + contentChangeListener = Objects.requireNonNull(listener); + } + + public void notifyContentChanged() { + contentRevision.incrementAndGet(); + Runnable listener = contentChangeListener; + if (listener != null) { + listener.run(); + } + } + + public long contentRevision() { + return contentRevision.get(); + } + + public boolean hasPendingBlockProvider(String blockKey) { + Identifier identifier = Identifier.fromString(blockKey); + if ("minecraft".equals(identifier.namespace())) { + return false; + } + String namespace = identifier.namespace().toLowerCase(Locale.ROOT); + Optional qualified = findProvider(namespace); + if (qualified.isPresent()) { + return !qualified.get().isReady(); + } + if (BLOCK_PROVIDER_PLUGINS.contains(namespace)) { + return isInstalledProviderPending(namespace); + } + for (String pluginId : BLOCK_PROVIDER_PLUGINS) { + if (isInstalledProviderPending(pluginId)) { + return true; + } + } + for (ExternalDataProvider provider : providers) { + if (!provider.isReady()) { + return true; + } + } + return false; + } + + private boolean isInstalledProviderPending(String pluginId) { + String installedName = BUILT_IN_PROVIDERS.stream() + .map(ProviderDefinition::pluginId) + .filter(name -> name.equalsIgnoreCase(pluginId)) + .findFirst() + .orElse(pluginId); + Plugin plugin = Bukkit.getPluginManager().getPlugin(installedName); + if (plugin == null) { + return false; + } + Optional provider = findProvider(pluginId); + return !plugin.isEnabled() || provider.isEmpty() || !provider.get().isReady(); + } + @EventHandler public void onPluginEnable(PluginEnableEvent event) { String pluginId = event.getPlugin().getName(); @@ -106,7 +174,8 @@ public class ExternalDataSVC implements IrisService { } providers.add(registeredProvider); - if (registeredProvider.isReady()) { + Plugin plugin = registeredProvider.getPlugin(); + if (plugin != null && plugin.isEnabled()) { activateProvider(registeredProvider); } } @@ -119,28 +188,35 @@ public class ExternalDataSVC implements IrisService { IrisLogging.error(e.getMessage()); return Optional.empty(); } - Identifier mod = pair.getA(); - - Optional provider = activeProviders.stream().filter(p -> p.isValidProvider(mod, DataType.BLOCK)).findFirst(); - if (provider.isEmpty()) - return Optional.empty(); try { - return Optional.of(provider.get().getBlockData(mod, pair.getB())); + BlockProvider match = findBlockProvider(pair.getA()); + if (match == null) { + return Optional.empty(); + } + BlockData resolved = match.provider().getBlockData(match.identifier(), pair.getB()); + if (resolved instanceof IrisCustomData custom) { + resolved = IrisCustomData.of(custom.getBase(), qualifyBlockId(match.provider(), custom.getCustom())); + } + return Optional.of(resolved); } catch (MissingResourceException e) { IrisLogging.error(e.getMessage() + " - [" + e.getClassName() + ":" + e.getKey() + "]"); return Optional.empty(); + } catch (RuntimeException | LinkageError e) { + IrisLogging.reportError("Failed to resolve external block " + key + ".", e); + return Optional.empty(); } } public Optional> getBlockProperties(final Identifier key) { - Optional provider = activeProviders.stream().filter(p -> p.isValidProvider(key, DataType.BLOCK)).findFirst(); - if (provider.isEmpty()) - return Optional.empty(); try { - return Optional.of(provider.get().getBlockProperties(key)); + BlockProvider match = findBlockProvider(parseState(key).getA()); + return match == null ? Optional.empty() : Optional.of(match.provider().getBlockProperties(match.identifier())); } catch (MissingResourceException e) { IrisLogging.error(e.getMessage() + " - [" + e.getClassName() + ":" + e.getKey() + "]"); return Optional.empty(); + } catch (RuntimeException | LinkageError e) { + IrisLogging.reportError("Failed to resolve external block properties for " + key + ".", e); + return Optional.empty(); } } @@ -160,13 +236,12 @@ public class ExternalDataSVC implements IrisService { } public void processUpdate(Engine engine, Block block, Identifier blockId) { - Optional provider = activeProviders.stream().filter(p -> p.isValidProvider(blockId, DataType.BLOCK)).findFirst(); - if (provider.isEmpty()) { - IrisLogging.warnOnce("external-provider:block:" + blockId, - "No matching Provider found for modded material \"%s\"!", blockId); - return; + Pair> state = parseState(blockId); + BlockProvider match = findBlockProvider(state.getA()); + if (match == null) { + throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key()); } - provider.get().processUpdate(engine, block, blockId); + match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB())); } public Entity spawnMob(Location location, Identifier mobId) { @@ -185,36 +260,61 @@ public class ExternalDataSVC implements IrisService { } public Collection getAllIdentifiers(DataType dataType) { + if (dataType == DataType.BLOCK) { + Set identifiers = new LinkedHashSet<>(); + for (ExternalDataProvider provider : activeProviders) { + for (Identifier identifier : providerBlockTypes(provider)) { + identifiers.add(identifier); + identifiers.add(qualifyBlockId(provider, identifier)); + } + } + return List.copyOf(identifiers); + } return activeProviders.stream() .flatMap(p -> p.getTypes(dataType).stream()) .toList(); } + private Collection providerBlockTypes(ExternalDataProvider provider) { + try { + return provider.getTypes(DataType.BLOCK); + } catch (RuntimeException | LinkageError error) { + IrisLogging.reportError("Failed to enumerate blocks from " + provider.getPluginId() + ".", error); + return List.of(); + } + } + public Collection>> getAllBlockProperties() { - return activeProviders.stream() - .flatMap(p -> p.getTypes(DataType.BLOCK) - .stream() - .map(id -> new Pair<>(id, p.getBlockProperties(id)))) - .toList(); + List>> properties = new ArrayList<>(); + for (Identifier identifier : getAllIdentifiers(DataType.BLOCK)) { + getBlockProperties(identifier).ifPresent(values -> properties.add(new Pair<>(identifier, values))); + } + return properties; } public static Pair> parseState(Identifier key) { String raw = key.key(); int open = raw.indexOf('['); int close = raw.lastIndexOf(']'); - if (open < 0 || close < open) { + if (open < 0 && close < 0) { return new Pair<>(key, new KMap<>()); } + if (open <= 0 || close != raw.length() - 1 || close < open + || raw.indexOf('[', open + 1) >= 0 || raw.indexOf(']') != close) { + throw new IllegalArgumentException("Malformed block state \"" + key + "\" (expected block[key=value])"); + } String state = raw.substring(open + 1, close); KMap stateMap = new KMap<>(); if (!state.isEmpty()) { - for (String entry : state.split(",")) { + for (String entry : state.split(",", -1)) { String[] pair = entry.split("=", 2); if (pair.length != 2 || pair[0].isBlank() || pair[1].isBlank()) { throw new IllegalArgumentException("Malformed block state \"" + entry + "\" in \"" + key + "\" (expected key=value)"); } - stateMap.put(pair[0], pair[1]); + if (stateMap.put(pair[0].trim(), pair[1].trim()) != null) { + throw new IllegalArgumentException("Duplicate block property \"" + pair[0] + "\" in \"" + key + "\""); + } } } return new Pair<>(new Identifier(key.namespace(), raw.substring(0, open)), stateMap); @@ -237,6 +337,45 @@ public class ExternalDataSVC implements IrisService { .findFirst(); } + private BlockProvider findBlockProvider(Identifier identifier) { + int separator = identifier.key().indexOf('/'); + if (separator > 0) { + for (ExternalDataProvider provider : activeProviders) { + if (!provider.getPluginId().equalsIgnoreCase(identifier.namespace())) { + continue; + } + Identifier nativeId = new Identifier(identifier.key().substring(0, separator), identifier.key().substring(separator + 1)); + if (provider.isValidProvider(nativeId, DataType.BLOCK)) { + return new BlockProvider(provider, nativeId); + } + return provider.isValidProvider(identifier, DataType.BLOCK) ? new BlockProvider(provider, identifier) : null; + } + if (BUILT_IN_PROVIDERS.stream().anyMatch(definition -> definition.pluginId().equalsIgnoreCase(identifier.namespace())) + || findProvider(identifier.namespace()).isPresent()) { + return null; + } + } + + BlockProvider match = null; + for (ExternalDataProvider provider : activeProviders) { + if (!provider.isValidProvider(identifier, DataType.BLOCK)) { + continue; + } + if (match != null) { + IrisLogging.warnOnce("external-provider:ambiguous-block:" + identifier, + "Multiple providers own block %s. Use %s or %s to select one.", identifier, + qualifyBlockId(match.provider(), identifier), qualifyBlockId(provider, identifier)); + return null; + } + match = new BlockProvider(provider, identifier); + } + return match; + } + + private static Identifier qualifyBlockId(ExternalDataProvider provider, Identifier identifier) { + return new Identifier(provider.getPluginId().toLowerCase(Locale.ROOT), identifier.namespace() + "/" + identifier.key()); + } + private void activateConfiguredProvider(ProviderDefinition definition) { if (!isPluginEnabled(definition.pluginId()) || findProvider(definition.pluginId()).isPresent()) { return; @@ -267,6 +406,7 @@ public class ExternalDataSVC implements IrisService { } activeProviders.add(provider); IrisLogging.debug("Enabled ExternalDataProvider for %s.", provider.getPluginId()); + notifyContentChanged(); } catch (Throwable error) { IrisLogging.reportError("Failed to enable Iris external data provider " + provider.getPluginId() + ".", error); } @@ -279,4 +419,7 @@ public class ExternalDataSVC implements IrisService { private record ProviderDefinition(String pluginId, String className) { } + + private record BlockProvider(ExternalDataProvider provider, Identifier identifier) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index b7d0a5add..28a2916d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -56,6 +56,7 @@ final class EngineShutdownSequence { private final Set incompleteTargets = Collections.synchronizedSet( Collections.newSetFromMap(new IdentityHashMap<>())); private boolean runtimeReleased; + private boolean worldManagerStopped; private boolean targetReleased; private boolean engineDataReleased; private boolean preservationReleased; @@ -72,7 +73,10 @@ final class EngineShutdownSequence { if (!engine.beginShutdown()) { return; } - Throwable routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter); + Throwable routerFailure = stopWorldManager(); + if (routerFailure == null) { + routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter); + } if (routerFailure != null) { synchronized (engine.lifecycleLock) { engine.lifecycleState = LifecycleState.FAILED; @@ -111,20 +115,16 @@ final class EngineShutdownSequence { drainFailure = e; } if (drainFailure != null) { - // A drain timeout must not abandon teardown: the world manager is the lease - // producer, so stop it first, then re-drain briefly. + // A drain timeout must not abandon teardown: give remaining admitted work + // one final bounded drain before deciding whether resources can be released. IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name() - + "; stopping the world manager and retrying."); - Throwable managerFailure = engine.runtime == null - ? null - : runCleanup(null, engine.runtime.worldManager()::close); + + "; waiting briefly for remaining work."); try { engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true); drainFailure = null; } catch (GenerationSessionException e) { drainFailure = appendFailure(drainFailure, e); } - drainFailure = appendFailure(drainFailure, managerFailure); } if (drainFailure != null) { // A live lease may be mid-write, so the mantle must not be closed at it — but @@ -322,7 +322,9 @@ final class EngineShutdownSequence { if (engineRuntime == null) { return failure; } - Throwable serviceFailure = runCleanup(null, engineRuntime.worldManager()::close); + Throwable serviceFailure = engineRuntime == engine.runtime + ? stopWorldManager() + : runCleanup(null, engineRuntime.worldManager()::close); serviceFailure = runCleanup(serviceFailure, engineRuntime.effects()::close); if (serviceFailure != null) { return appendFailure(failure, serviceFailure); @@ -414,6 +416,17 @@ final class EngineShutdownSequence { return failure; } + private Throwable stopWorldManager() { + if (worldManagerStopped || engine.runtime == null) { + return null; + } + Throwable failure = runCleanup(null, engine.runtime.worldManager()::close); + if (failure == null) { + worldManagerStopped = true; + } + return failure; + } + private Throwable closeAssemblyResources(RuntimeAssembly assembly) { Throwable failure = runCleanup(null, () -> { if (assembly.worldManager != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index 10b5da58c..fe916b2e2 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -275,7 +275,7 @@ public class IrisEngineMantle implements EngineMantle { return true; } - private static Mantle createMantle( + public static Mantle createMantle( Engine engine, Path storageDirectory, Supplier dataSupplier diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index 9deffdc1c..bd8e954d8 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -213,6 +213,9 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } + if (generated) { + chunkMaintenance.invalidateMaterialization(e.getX(), e.getZ()); + } INMS.get().reconcileNativeStructurePois(e); if (cleanupServiceStopped || cleanupService == null || cleanupService.isShutdown()) { @@ -274,10 +277,15 @@ public class IrisWorldManager extends EngineAssignedWorldManager { public synchronized void close() { Throwable failure = null; try { - super.close(); + chunkMaintenance.close(); } catch (Throwable e) { failure = e; } + try { + super.close(); + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } if (!looperStopped) { try { if (looper != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/WorldBlockDropRouter.java b/core/src/main/java/art/arcane/iris/engine/WorldBlockDropRouter.java index 9d1b69a46..055d51aaa 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldBlockDropRouter.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldBlockDropRouter.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.service.tree.BlockDropRouter; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockDrops; import art.arcane.iris.engine.object.IrisMarker; @@ -52,21 +53,26 @@ final class WorldBlockDropRouter { } void onBlockBreak(BlockBreakEvent e) { + if (e.isCancelled()) { + return; + } if (e.getBlock().getWorld().equals(BukkitWorldBinding.world(manager.getTarget().getWorld()))) { int blockX = e.getBlock().getX(); int mantleY = toMantleY(e.getBlock().getY(), manager.getEngine().getWorld().minHeight()); int blockZ = e.getBlock().getZ(); - KList d = new KList<>(); - IrisBiome b = EngineBukkitOps.getBiome(manager.getEngine(), e.getBlock().getLocation()); - List dropProviders = filterDrops(b.getBlockDrops(), e, manager.getData()); - - if (dropProviders.stream().noneMatch(IrisBlockDrops::isSkipParents)) { - IrisRegion r = EngineBukkitOps.getRegion(manager.getEngine(), e.getBlock().getLocation()); - dropProviders.addAll(filterDrops(r.getBlockDrops(), e, manager.getData())); - dropProviders.addAll(filterDrops(manager.getEngine().getDimension().getBlockDrops(), e, manager.getData())); + List dropProviders; + try { + dropProviders = resolveDropProviders(e); + } catch (SavedBiomeUnavailableException unavailable) { + e.setCancelled(true); + if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) { + throw unavailable; + } + return; } + KList d = new KList<>(); dropProviders.forEach(provider -> provider.fillDrops(false, d)); if (dropProviders.stream().anyMatch(IrisBlockDrops::isReplaceVanillaDrops)) { @@ -131,4 +137,15 @@ final class WorldBlockDropRouter { private List filterDrops(KList drops, BlockBreakEvent e, IrisData data) { return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList()); } + + private List resolveDropProviders(BlockBreakEvent event) { + IrisBiome biome = EngineBukkitOps.getBiome(manager.getEngine(), event.getBlock().getLocation()); + List providers = filterDrops(biome.getBlockDrops(), event, manager.getData()); + if (providers.stream().noneMatch(IrisBlockDrops::isSkipParents)) { + IrisRegion region = EngineBukkitOps.getRegion(manager.getEngine(), event.getBlock().getLocation()); + providers.addAll(filterDrops(region.getBlockDrops(), event, manager.getData())); + providers.addAll(filterDrops(manager.getEngine().getDimension().getBlockDrops(), event, manager.getData())); + } + return providers; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java index 31b6a779f..94059d615 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java @@ -20,6 +20,8 @@ package art.arcane.iris.engine; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter.SavedChunkMantle; import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; @@ -28,6 +30,7 @@ import art.arcane.iris.util.common.plugin.Chunks; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.matter.Matter; @@ -36,10 +39,14 @@ import org.bukkit.World; import org.bukkit.entity.Player; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -50,21 +57,47 @@ import java.util.concurrent.atomic.AtomicBoolean; */ final class WorldChunkMaintenance { private static final int MAX_FORCED_CHUNK_UPDATES = 128; + private static final int MAX_PENDING_MATERIALIZATIONS = 128; + private static final int MAX_COMPLETED_MATERIALIZATIONS = 8192; private final IrisWorldManager manager; private final Set mantleWarmupQueue = ConcurrentHashMap.newKeySet(); private final Set markerFlagQueue = ConcurrentHashMap.newKeySet(); private final Set discoveredFlagQueue = ConcurrentHashMap.newKeySet(); private final Set chunkUpdateQueue = ConcurrentHashMap.newKeySet(); + private final Map materializations = new ConcurrentHashMap<>(); + private final Set completedMaterializations = Collections.synchronizedSet(new LinkedHashSet<>()); private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean(); private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean(); private volatile Position2[] loadedChunkPositions = new Position2[0]; private int forcedChunkUpdateCursor = 0; + private volatile boolean closed; WorldChunkMaintenance(IrisWorldManager manager) { this.manager = manager; } + void close() { + synchronized (materializations) { + closed = true; + for (RoutedMaterialization materialization : materializations.values()) { + materialization.cancel(); + } + } + completedMaterializations.clear(); + } + + void invalidateMaterialization(int chunkX, int chunkZ) { + long key = Cache.key(chunkX, chunkZ); + synchronized (materializations) { + RoutedMaterialization materialization = materializations.get(key); + if (materialization != null) { + materialization.cancel(); + } + completedMaterializations.remove(key); + } + } + void discoverChunks() { World world = BukkitWorldBinding.world(manager.getEngine().getWorld()); if (world == null) { @@ -264,32 +297,84 @@ final class WorldChunkMaintenance { } void updateChunkRegion(World world, int chunkX, int chunkZ) { - if (world == null || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { + if (closed || world == null || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { return; } Chunk chunk = world.getChunkAt(chunkX, chunkZ); if (IrisSettings.get().getWorld().isPostLoadBlockUpdates()) { - if (!manager.getMantle().isChunkLoaded(chunkX, chunkZ)) { - warmupMantleChunkAsync(chunkX, chunkZ); + GenerationHistoryRuntimeRouter router = manager.getEngine() instanceof IrisEngine engine + ? engine.getGenerationHistoryRuntimeRouter().orElse(null) : null; + if (router != null && !completedMaterializations.contains(Cache.key(chunkX, chunkZ))) { + scheduleMaterialization(new MaterializationTarget(world, chunkX, chunkZ), router); return; } - try { - EngineBukkitOps.updateChunk(manager.getEngine(), chunk); - } catch (SavedBiomeUnavailableException unavailable) { - if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) { - throw unavailable; - } + if (router == null && !updateCurrentChunk(chunk, chunkX, chunkZ)) { return; } } + spawnInitially(chunk); + } + + private boolean updateCurrentChunk(Chunk chunk, int chunkX, int chunkZ) { + if (!manager.getMantle().isChunkLoaded(chunkX, chunkZ)) { + warmupMantleChunkAsync(chunkX, chunkZ); + return false; + } + return materializeChunk(chunk, manager.getMantle()); + } + + private boolean materializeChunk(Chunk chunk, Mantle mantle) { + try { + EngineBukkitOps.updateChunk(manager.getEngine(), chunk, mantle); + } catch (SavedBiomeUnavailableException unavailable) { + if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) { + throw unavailable; + } + return false; + } + return true; + } + + private void spawnInitially(Chunk chunk) { if (manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) { manager.entitySpawner.spawnInitially(chunk); } } + private void scheduleMaterialization(MaterializationTarget target, GenerationHistoryRuntimeRouter router) { + RoutedMaterialization materialization; + synchronized (materializations) { + if (closed || materializations.size() >= MAX_PENDING_MATERIALIZATIONS + || materializations.containsKey(target.key())) { + return; + } + materialization = new RoutedMaterialization(target, router); + materializations.put(target.key(), materialization); + } + try { + J.a(manager.managedTask("bukkit_world_manager_materialization_load", + materialization::prepare, materialization::cancel)); + } catch (Throwable failure) { + materialization.cancel(); + IrisLogging.reportError(failure); + } + } + + private void recordMaterialized(long key) { + synchronized (completedMaterializations) { + if (closed) { + return; + } + if (completedMaterializations.size() >= MAX_COMPLETED_MATERIALIZATIONS) { + completedMaterializations.remove(completedMaterializations.iterator().next()); + } + completedMaterializations.add(key); + } + } + void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) { if (world == null || onFirstRaise == null) { return; @@ -355,4 +440,156 @@ final class WorldChunkMaintenance { } return loadedChunkPositions; } + + private record MaterializationTarget(World world, int x, int z) { + private long key() { + return Cache.key(x, z); + } + } + + private final class RoutedMaterialization { + private final MaterializationTarget target; + private final GenerationHistoryRuntimeRouter router; + private SavedChunkMantle savedMantle; + private MantleChunk mantleChunk; + private boolean cancelled; + private boolean preparing; + private boolean applying; + private boolean releasing; + + private RoutedMaterialization(MaterializationTarget target, GenerationHistoryRuntimeRouter router) { + this.target = target; + this.router = router; + } + + private void prepare() { + SavedChunkMantle opened = null; + MantleChunk loaded = null; + try { + synchronized (this) { + if (cancelled) { + return; + } + preparing = true; + } + opened = router.openSavedChunkMantle(target.x(), target.z()); + loaded = opened.mantle().getChunk(target.x(), target.z()).use(); + opened.detachThread(); + synchronized (this) { + if (cancelled) { + return; + } + savedMantle = opened; + mantleChunk = loaded; + opened = null; + loaded = null; + } + boolean scheduled = J.runRegion(target.world(), target.x(), target.z(), + manager.managedTask("bukkit_world_manager_materialization_apply", this::apply, this::cancel)); + if (!scheduled) { + cancel(); + } + } catch (Throwable failure) { + cancel(); + IrisLogging.reportError("Failed to load saved chunk materialization at " + target.x() + "," + target.z() + ".", failure); + } finally { + try { + try { + if (loaded != null) { + loaded.release(); + } + } finally { + if (opened != null) { + opened.close(); + } + } + } finally { + boolean cleanup; + synchronized (this) { + preparing = false; + cleanup = cancelled; + } + if (cleanup) { + cancel(); + } + } + } + } + + private void apply() { + SavedChunkMantle active; + synchronized (this) { + if (cancelled || savedMantle == null) { + return; + } + applying = true; + active = savedMantle; + } + try { + World world = target.world(); + if (closed || !world.isChunkLoaded(target.x(), target.z()) + || !Chunks.isSafe(world, target.x(), target.z())) { + return; + } + Chunk chunk = world.getChunkAt(target.x(), target.z()); + boolean completed; + try (SavedChunkMantle.Scope ignored = active.openScope()) { + Mantle mantle = active.mantle(); + if (!materializeChunk(chunk, mantle)) { + return; + } + completed = mantle.hasFlag(target.x(), target.z(), MantleFlag.ETCHED); + } + synchronized (this) { + if (completed && !cancelled) { + recordMaterialized(target.key()); + } + } + spawnInitially(chunk); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to materialize saved chunk at " + target.x() + "," + target.z() + ".", failure); + } finally { + synchronized (this) { + applying = false; + } + cancel(); + } + } + + private void cancel() { + SavedChunkMantle retained; + MantleChunk loaded; + synchronized (this) { + cancelled = true; + if (preparing || applying || releasing) { + return; + } + retained = savedMantle; + loaded = mantleChunk; + savedMantle = null; + mantleChunk = null; + releasing = retained != null; + } + if (retained != null) { + ForkJoinPool.commonPool().execute(() -> release(retained, loaded)); + } else { + materializations.remove(target.key(), this); + } + } + + private void release(SavedChunkMantle retained, MantleChunk loaded) { + try { + try { + loaded.release(); + } finally { + retained.close(); + } + } catch (Throwable failure) { + completedMaterializations.remove(target.key()); + IrisLogging.reportError("Failed to release saved chunk materialization at " + target.x() + "," + target.z() + ".", failure); + } finally { + materializations.remove(target.key(), this); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java index 1b95915fc..dd75cd986 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java @@ -138,6 +138,7 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen } private static BoundaryColumnGeometry.Voxel encode(PlatformBlockState state) { + String stateKey = state.key(); boolean custom = state.isCustom(); PlatformBlockState nativeState = state.placementBaseState(); if (nativeState != null) { @@ -149,7 +150,7 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen : state.isWaterLogged() ? "minecraft:water[level=0]" : ""; boolean protectedContent = custom || state.isDecorant() || state.isFoliage() || state.isTreeBlock() || state.hasTileEntity(); - return new BoundaryColumnGeometry.Voxel(state.key(), phase, fluidKey, protectedContent); + return new BoundaryColumnGeometry.Voxel(stateKey, phase, fluidKey, protectedContent); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java index aabb960df..fbe29df1a 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -574,11 +574,19 @@ public final class GenerationHistory { } public GenerationStage openStage(int chunkX, int chunkZ) throws IOException { + return openStage(chunkX, chunkZ, false); + } + + GenerationStage openSavedChunkStage(int chunkX, int chunkZ) throws IOException { + return openStage(chunkX, chunkZ, true); + } + + private GenerationStage openStage(int chunkX, int chunkZ, boolean savedChunk) throws IOException { GenerationAdmission.StageLease lease = admission.enterStage(); try { GenerationManifest manifest = store.manifest(); - GenerationActivation activation = manifest.activeActivation(); - GenerationEpoch epoch = manifest.activeEpoch(); + GenerationActivation activation = savedChunk ? resolveActivation(chunkX, chunkZ) : manifest.activeActivation(); + GenerationEpoch epoch = savedChunk ? requireEpoch(activation.epochId()) : manifest.activeEpoch(); return new GenerationStage( this, chunkX, diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java index dc399a1e0..955d4bd16 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java @@ -2,9 +2,12 @@ package art.arcane.iris.engine.history; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.IrisEngineMantle; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.GenerationTransitionGate; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; import java.io.IOException; import java.nio.file.Path; @@ -28,6 +31,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { private final ActivationRuntimeFactory runtimeFactory; private final LinkedHashMap bindings; private final Map retiringBindings; + private final Map savedMantles = new LinkedHashMap<>(); private final ReentrantLock stateLock; private final Condition inactive; private final ThreadLocal operationDepth; @@ -265,6 +269,29 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } } + public SavedChunkMantle openSavedChunkMantle(int chunkX, int chunkZ) throws IOException { + GenerationTransitionGate.Participation participation = engine.getGenerationSessions().transitionGate().enter(); + boolean operation = false; + try { + enterRouteOperation(); + operation = true; + GenerationHistory.GenerationStage stage = history.openSavedChunkStage(chunkX, chunkZ); + try { + SavedMantleAccess access = acquireSavedMantle(stage.activation(), stage.epoch()); + return new SavedChunkMantle(this, new SavedChunkResources(stage, participation, access)); + } catch (Throwable failure) { + stage.close(); + throw failure; + } + } catch (Throwable failure) { + if (operation) { + leaveRouteOperation(); + } + participation.close(); + throw propagate(failure, "Unable to open saved Iris chunk mantle."); + } + } + public RuntimeStage openStage(int chunkX, int chunkZ) throws IOException { RuntimeRoute route = openRoute(chunkX, chunkZ); try { @@ -369,6 +396,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { throw new IllegalStateException("Cannot close the generation-history router from an active route."); } List retired = null; + List saved = null; stateLock.lock(); try { closed = true; @@ -389,6 +417,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } } bindings.clear(); + saved = new ArrayList<>(savedMantles.values()); break; } } finally { @@ -411,6 +440,13 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } catch (Throwable retirementFailure) { failure = appendFailure(failure, retirementFailure); } + for (SavedMantleEntry entry : saved) { + try { + closeSavedMantle(entry); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } stateLock.lock(); try { @@ -431,6 +467,164 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { return signatureSampler.open(engine); } + private SavedMantleAccess acquireSavedMantle(GenerationActivation activation, GenerationEpoch epoch) throws IOException { + SavedMantleEntry saved = null; + RuntimeCacheEntry runtime; + boolean loader = false; + stateLock.lock(); + try { + while (true) { + awaitRetirementLocked(activation.activationId()); + saved = savedMantles.get(activation.activationId()); + if (saved == null || !saved.retiring) { + break; + } + awaitSavedMantleChange(saved); + } + runtime = bindings.get(activation.activationId()); + if (runtime != null) { + runtime.leases++; + } else { + if (saved == null) { + saved = new SavedMantleEntry(activation.activationId()); + saved.loading = true; + savedMantles.put(activation.activationId(), saved); + activeLoads++; + loader = true; + } + saved.leases++; + } + } finally { + stateLock.unlock(); + } + if (runtime != null) { + RuntimeLease lease = awaitRuntime(runtime, activation.activationId()); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(lease.binding())) { + return new SavedMantleAccess(engine.getMantle().getMantle(), lease::close); + } catch (Throwable failure) { + lease.close(); + throw failure; + } + } + SavedMantleEntry entry = saved; + if (loader) { + loadSavedMantle(entry, activation, epoch); + } + stateLock.lock(); + try { + while (entry.loading) { + try { + inactive.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + entry.leases--; + inactive.signalAll(); + throw new IOException("Interrupted while loading saved mantle for activation " + entry.activationId + ".", failure); + } + } + if (entry.failure != null) { + entry.leases--; + throw propagate(entry.failure, "Unable to load saved mantle for activation " + entry.activationId + "."); + } + return new SavedMantleAccess(entry.mantle, () -> releaseSavedMantle(entry)); + } finally { + stateLock.unlock(); + } + } + + private void loadSavedMantle(SavedMantleEntry entry, GenerationActivation activation, GenerationEpoch epoch) { + IrisData data = null; + Mantle mantle = null; + Throwable failure = null; + try { + data = IrisData.openRuntime(history.packRoot(activation.activationId()).toFile()); + data.bindGenerationRegistryContract(epoch.registryContract()); + data.registerEngine(engine); + IrisData savedData = data; + mantle = IrisEngineMantle.createMantle(engine, history.paths().activationMantleRoot(activation.activationId()), () -> savedData); + } catch (Throwable loadFailure) { + failure = loadFailure; + if (data != null) { + try { + data.unregisterEngine(engine); + data.close(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } + } + stateLock.lock(); + try { + entry.data = data; + entry.mantle = mantle; + entry.failure = failure; + entry.loading = false; + activeLoads--; + if (failure != null) { + savedMantles.remove(entry.activationId, entry); + } + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + } + + private void releaseSavedMantle(SavedMantleEntry entry) { + stateLock.lock(); + try { + if (entry.leases <= 0) { + throw new IllegalStateException("Saved mantle has no active lease."); + } + entry.leases--; + if (entry.leases > 0) { + return; + } + entry.retiring = true; + } finally { + stateLock.unlock(); + } + closeSavedMantle(entry); + } + + private void closeSavedMantle(SavedMantleEntry entry) { + Throwable failure = null; + try { + entry.mantle.close(); + entry.data.unregisterEngine(engine); + entry.data.close(); + } catch (Throwable closeFailure) { + failure = closeFailure; + } + stateLock.lock(); + try { + entry.failure = failure; + if (failure == null) { + savedMantles.remove(entry.activationId, entry); + } + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + if (failure != null) { + throw new IllegalStateException("Failed to close saved mantle for activation " + entry.activationId + ".", failure); + } + } + + private void awaitSavedMantleChange(SavedMantleEntry entry) throws IOException { + if (entry.failure != null) { + throw propagate(entry.failure, "Saved mantle for activation " + entry.activationId + " is unavailable."); + } + if (closed) { + throw new IllegalStateException("Generation-history runtime router is closed."); + } + try { + inactive.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting saved mantle for activation " + entry.activationId + ".", failure); + } + } + private RuntimeLease acquireRuntime( GenerationActivation activation, GenerationEpoch epoch @@ -439,7 +633,14 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { boolean loader = false; stateLock.lock(); try { - awaitRetirementLocked(activation.activationId()); + while (true) { + awaitRetirementLocked(activation.activationId()); + SavedMantleEntry saved = savedMantles.get(activation.activationId()); + if (saved == null) { + break; + } + awaitSavedMantleChange(saved); + } entry = bindings.get(activation.activationId()); if (entry == null) { entry = new RuntimeCacheEntry(activation.activationId()); @@ -823,6 +1024,139 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { throw new IllegalStateException("Failed to close the generation-history runtime router.", failure); } + private static final class SavedMantleEntry { + private final long activationId; + private IrisData data; + private Mantle mantle; + private Throwable failure; + private int leases; + private boolean loading; + private boolean retiring; + + private SavedMantleEntry(long activationId) { + this.activationId = activationId; + } + } + + private record SavedMantleAccess(Mantle mantle, Runnable release) { + } + + private record SavedChunkResources(GenerationHistory.GenerationStage stage, + GenerationTransitionGate.Participation participation, + SavedMantleAccess access) { + } + + public static final class SavedChunkMantle implements AutoCloseable { + private final GenerationHistoryRuntimeRouter router; + private final SavedChunkResources resources; + private int activeScopes; + private boolean closed; + + private SavedChunkMantle(GenerationHistoryRuntimeRouter router, SavedChunkResources resources) { + this.router = router; + this.resources = resources; + } + + public Mantle mantle() { + return resources.access().mantle(); + } + + public void detachThread() { + resources.participation().detachThread(); + } + + public Scope openScope() { + synchronized (this) { + if (closed) { + throw new IllegalStateException("Saved chunk mantle is closed."); + } + activeScopes++; + } + try { + GenerationTransitionGate.Participation participation = resources.participation().attach(); + router.enterRuntimeScope(); + return new Scope(this, participation); + } catch (Throwable failure) { + releaseScope(); + throw failure; + } + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + if (activeScopes > 0) { + throw new IllegalStateException("Saved chunk mantle still has active scopes."); + } + closed = true; + Throwable failure = null; + try { + resources.access().release().run(); + } catch (Throwable closeFailure) { + failure = closeFailure; + } + try { + resources.stage().close(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + try { + router.leaveRouteOperation(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + try { + resources.participation().close(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + if (failure != null) { + throw new IllegalStateException("Failed to close saved chunk mantle.", failure); + } + } + + private synchronized void releaseScope() { + if (activeScopes <= 0) { + throw new IllegalStateException("Saved chunk mantle has no active scope."); + } + activeScopes--; + } + + public static final class Scope implements AutoCloseable { + private final SavedChunkMantle handle; + private final GenerationTransitionGate.Participation participation; + private final Thread owner = Thread.currentThread(); + private boolean closed; + + private Scope(SavedChunkMantle handle, GenerationTransitionGate.Participation participation) { + this.handle = handle; + this.participation = participation; + } + + @Override + public void close() { + if (closed) { + return; + } + if (Thread.currentThread() != owner) { + throw new IllegalStateException("Saved chunk mantle scope closed from a different thread."); + } + closed = true; + try { + handle.router.leaveRuntimeScope(); + } finally { + try { + handle.releaseScope(); + } finally { + participation.close(); + } + } + } + } + } + private static final class RuntimeCacheEntry { private final long activationId; private IrisEngine.GenerationRuntimeBinding binding; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java index 7154dd9b3..0608986db 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java @@ -41,6 +41,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.Locale; import java.util.Map; @Accessors(chain = true) @@ -76,12 +77,17 @@ public class IrisBlockData extends IrisRegistrant { public static IrisBlockData from(String j) { IrisBlockData b = new IrisBlockData(); - String v = j.toLowerCase().trim(); + String v = j.trim(); + int bracket = v.indexOf('['); + String block = (bracket < 0 ? v : v.substring(0, bracket)).toLowerCase(Locale.ROOT); + b.setBlock(block); - if (v.contains("[")) { + if (bracket >= 0) { KList props = new KList<>(); - String rp = v.split("\\Q[\\E")[1].replaceAll("\\Q]\\E", ""); - b.setBlock(v.split("\\Q[\\E")[0]); + String rp = v.substring(bracket + 1).replace("]", ""); + if (!block.contains(":") || block.startsWith("minecraft:")) { + rp = rp.toLowerCase(Locale.ROOT); + } if (rp.contains(",")) { props.add(rp.split("\\Q,\\E")); @@ -93,8 +99,6 @@ public class IrisBlockData extends IrisRegistrant { Object kg = filter(i.split("\\Q=\\E")[1]); b.data.put(i.split("\\Q=\\E")[0], kg); } - } else { - b.setBlock(v); } return b; @@ -116,7 +120,11 @@ public class IrisBlockData extends IrisRegistrant { } try { - return Double.valueOf(string).intValue(); + double value = Double.parseDouble(string); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE && value == Math.rint(value)) { + return (int) value; + } + return value; } catch (Throwable ignored) { // Checks } @@ -156,19 +164,13 @@ public class IrisBlockData extends IrisRegistrant { PlatformBlockState customState = customData.getBlockData(data); if (customState != null) { - String st = customState.key(); - - if (st.contains("[")) { - st = st.split("\\Q[\\E")[0]; + if (getData().isEmpty()) { + return customState; } - KMap cdata = customData.getData().copy(); - - for (String i : getData().keySet()) { - cdata.put(i, getData().get(i)); - } - - String sx = keyify(st) + computeProperties(cdata); + IrisBlockData merged = IrisBlockData.from(customState.key()); + merged.getData().putAll(getData()); + String sx = merged.stateKey(); if (debug) { IrisLogging.debug("Block Data used " + sx + " (CUSTOM)"); diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 889e62227..1b687713f 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -146,6 +146,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private volatile Throwable initializationFailure; private volatile IrisDimension validatedDimension; private volatile IrisDimensionRuntimeContract validatedWorldContract; + private volatile EngineTarget startupTarget; + private volatile CompletableFuture startupContentReady = CompletableFuture.completedFuture(null); + private volatile CompletableFuture startupReady = CompletableFuture.completedFuture(null); private volatile boolean closing; @Setter private volatile StudioGenerator studioGenerator; @@ -195,6 +198,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } BukkitPlatform.volmitPlugin().unregisterListener(this); world.setRawWorldSeed(event.getWorld().getSeed()); + if (startupTarget != null) { + startupContentReady.whenComplete((ignored, failure) -> + J.s(() -> resumeDeferredStartup(event.getWorld(), failure))); + return; + } if (initialize(event.getWorld())) return; IrisLogging.warn("Failed to get Engine for " + event.getWorld().getName() + " re-trying..."); @@ -205,6 +213,73 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun }, 10); } + public void deferStartup(EngineTarget target, CompletableFuture contentReady) { + if (setup.get() || startupTarget != null) { + throw new IllegalStateException("Iris generator startup has already begun."); + } + startupTarget = Objects.requireNonNull(target); + startupContentReady = Objects.requireNonNull(contentReady); + startupReady = new CompletableFuture<>(); + } + + private void resumeDeferredStartup(World runtimeWorld, Throwable failure) { + lock.lock(); + try { + closeStartupTarget(); + if (closing) { + startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup.")); + return; + } + if (failure != null) { + initializationFailure = failure; + spawnChunks.completeExceptionally(failure); + initialSpawnReady.completeExceptionally(failure); + startupReady.completeExceptionally(failure); + IrisLogging.reportError("External block providers did not become available for Iris world '" + + world.name() + "'; generation remains locked.", failure); + Bukkit.shutdown(); + return; + } + if (!initialize(runtimeWorld)) { + throw new IllegalStateException("Iris engine initialization did not complete for '" + world.name() + "'."); + } + startupReady.complete(null); + } catch (RuntimeException | Error initializationError) { + initializationFailure = initializationError; + spawnChunks.completeExceptionally(initializationError); + initialSpawnReady.completeExceptionally(initializationError); + startupReady.completeExceptionally(initializationError); + Bukkit.shutdown(); + throw initializationError; + } finally { + lock.unlock(); + } + } + + private void closeStartupTarget() { + EngineTarget target = startupTarget; + startupTarget = null; + if (target != null) { + target.getData().close(); + } + } + + private void requireStartupContentReady() { + if (startupReady.isCompletedExceptionally()) { + startupReady.join(); + } + boolean initializationPending = startupTarget != null + || !startupReady.isDone() && !lock.isHeldByCurrentThread(); + requireStartupContentReady(startupContentReady, initializationPending); + } + + static void requireStartupContentReady(CompletableFuture ready, boolean initializationPending) { + if (!ready.isDone() || initializationPending) { + throw new IllegalStateException("Iris generation is waiting for external block providers to initialize."); + } + ready.join(); + } + private boolean initialize(World world) { try { Engine engine = getEngine(world); @@ -579,6 +654,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public EngineTarget getTarget() { if (engine != null) return engine.getTarget(); + EngineTarget pendingTarget = startupTarget; + if (pendingTarget != null) { + return pendingTarget; + } return targetCache.aquireOrThrow(() -> { if (generationHistory != null) { @@ -621,6 +700,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private Engine getEngine(WorldInfo world) { throwIfInitializationFailed(); + requireStartupContentReady(); validateAndBindWorld(world); if (setup.get()) { return getEngine(); @@ -763,6 +843,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } folder.clear(); populators.clear(); + closeStartupTarget(); + startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup.")); }); } catch (Throwable throwable) { if (!alreadyClosing) { @@ -1079,6 +1161,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } public void touch(World world) { + if (!startupReady.isDone() || startupReady.isCompletedExceptionally()) { + return; + } getEngine(world); } @@ -1274,7 +1359,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return shouldGenerateNativeStructures( isAuthoringStudio(), studioEntryBootstrapActive.get(), - initializationFailure != null); + initializationFailure != null || startupTarget != null); } static boolean shouldGenerateNativeStructures( diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index a10ba2f3c..d94914c24 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -101,6 +101,10 @@ public final class EngineBukkitOps { } public static void updateChunk(Engine engine, Chunk c) { + updateChunk(engine, c, engine.getMantle().getMantle()); + } + + public static void updateChunk(Engine engine, Chunk c, Mantle mantle) { for (int x = -1; x <= 1; x++) { for (int z = -1; z <= 1; z++) { if (c.getWorld().isChunkLoaded(c.getX() + x, c.getZ() + z)) @@ -110,7 +114,6 @@ public final class EngineBukkitOps { return; } } - Mantle mantle = engine.getMantle().getMantle(); if (!mantle.isLoaded(c)) { String msg = "Mantle Chunk " + c.getX() + "," + c.getZ() + " is not loaded"; IrisLogging.debug(msg); @@ -118,7 +121,7 @@ public final class EngineBukkitOps { } if (!J.isFolia() && !J.isPrimaryThread()) { - CompletableFuture scheduled = J.sfut(() -> updateChunk(engine, c)); + CompletableFuture scheduled = J.sfut(() -> updateChunk(engine, c, mantle)); if (scheduled != null) { try { scheduled.join(); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java index a4631511f..381174d8a 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java @@ -70,7 +70,7 @@ public final class BukkitBlockState implements PlatformBlockState { public static BukkitBlockState of(BlockData data) { if (data instanceof IrisCustomData custom) { - return new BukkitBlockState(data, custom.getAsString()); + return new BukkitBlockState(data, custom.getCustom().toString()); } BukkitBlockState fast = DATA_CACHE.get(data); if (fast != null) { @@ -369,15 +369,14 @@ public final class BukkitBlockState implements PlatformBlockState { @Override public PlatformBlockState withProperty(String name, String value) { - String merged = mergeProperty(key, name, value); - BlockData resolved = Bukkit.createBlockData(merged); - // Re-attach the custom identity (as the proxy's own merge/clone cases do): the key of - // a custom state is the BASE block's string, so rebuilding from it alone silently - // downgraded custom blocks to vanilla on e.g. auto-waterlogging. + // Re-attach the custom identity (as the proxy's own merge/clone cases do) after + // editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla. if (data instanceof IrisCustomData custom) { + String merged = mergeProperty(custom.getBase().getAsString(), name, value); + BlockData resolved = Bukkit.createBlockData(merged); return of(IrisCustomData.of(resolved, custom.getCustom())); } - return of(resolved); + return of(Bukkit.createBlockData(mergeProperty(key, name, value))); } @Override diff --git a/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java b/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java index 52db718e7..36ad97adb 100644 --- a/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java +++ b/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java @@ -94,17 +94,17 @@ final class CompatFixtures { @Override public PlatformBlockState blockOrNull(String key, boolean warn) { blockLookups++; - String normalized = key.trim().toLowerCase(Locale.ROOT); + String normalized = key.trim(); int props = normalized.indexOf('['); - String base = props < 0 ? normalized : normalized.substring(0, props); + String base = (props < 0 ? normalized : normalized.substring(0, props)).toLowerCase(Locale.ROOT); + String properties = props < 0 ? "" : normalized.substring(props); if (base.indexOf(':') < 0) { base = "minecraft:" + base; - normalized = base + (props < 0 ? "" : normalized.substring(props)); } if (missingBlocks.contains(base)) { return null; } - return new FakeBlockState(normalized); + return new FakeBlockState(base + (base.startsWith("minecraft:") ? properties.toLowerCase(Locale.ROOT) : properties)); } @Override diff --git a/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java b/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java index 46c98d13e..0d8d43464 100644 --- a/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java +++ b/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java @@ -165,6 +165,8 @@ public class ContentGateTest { assertEquals("minecraft:stone", ContentGate.normalizeState(" STONE ")); assertEquals("minecraft:oak_log[axis=y]", ContentGate.normalizeState("Oak_Log[AXIS=Y]")); assertEquals("create:cogwheel", ContentGate.normalizeState("create:cogwheel")); + assertEquals("craftengine:forest/chair[randomYaw=true,variant=OakLarge]", + ContentGate.normalizeState("CraftEngine:Forest/Chair[randomYaw=true,variant=OakLarge]")); assertNull(ContentGate.normalizeState(null)); assertNull(ContentGate.normalizeState(" ")); assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log[axis=y]")); diff --git a/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java b/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java index 843987a38..99c9f6b5e 100644 --- a/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java +++ b/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java @@ -1,7 +1,9 @@ package art.arcane.iris.core.compat; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.spi.PlatformBlockState; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -11,7 +13,10 @@ import java.nio.file.Files; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; public class IrisBlockDataResolutionTest { private static CompatFixtures.FakeRegistries registries; @@ -93,4 +98,119 @@ public class IrisBlockDataResolutionTest { assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action()); assertEquals("block", data.getCompatReport().findings().getFirst().subjectType()); } + + @Test + public void nestedPackBlocksPreserveInheritedProviderProperties() throws Exception { + IrisData data = pack(null, "{\"block\":\"base\",\"data\":{\"variant\":\"large\"}}"); + File blocks = new File(data.getDataFolder(), "blocks"); + Files.writeString(new File(blocks, "base.json").toPath(), + "{\"block\":\"craftengine:forest/amber_log\",\"data\":{\"axis\":\"y\",\"yaw\":22.5}}"); + IrisBlockData entry = new IrisBlockData("fancy"); + entry.getData().put("axis", "x"); + + PlatformBlockState resolved = entry.getBlockData(data); + IrisBlockData parsed = IrisBlockData.from(resolved.key()); + + assertEquals("craftengine:forest/amber_log", parsed.getBlock()); + assertEquals("x", parsed.getData().get("axis")); + assertEquals("large", parsed.getData().get("variant")); + assertEquals(22.5, (Double) parsed.getData().get("yaw"), 0.0); + } + + @Test + public void providerPropertiesKeepTheirCaseThroughDirectResolution() throws Exception { + IrisData data = pack(null, null); + IrisBlockData entry = new IrisBlockData("craftengine:forest/chair"); + entry.getData().put("randomYaw", true); + entry.getData().put("variant", "OakLarge"); + + PlatformBlockState resolved = entry.getBlockData(data); + IrisBlockData parsed = IrisBlockData.from(resolved.key()); + + assertEquals(true, parsed.getData().get("randomYaw")); + assertEquals("OakLarge", parsed.getData().get("variant")); + assertTrue(resolved.key().contains("randomYaw=true")); + assertTrue(resolved.key().contains("variant=OakLarge")); + } + + @Test + public void nestedPackBlocksKeepInheritedProviderPropertyCase() throws Exception { + IrisData data = pack(null, "{\"block\":\"base\",\"data\":{\"yaw\":22.5}}"); + File blocks = new File(data.getDataFolder(), "blocks"); + Files.writeString(new File(blocks, "base.json").toPath(), + "{\"block\":\"craftengine:forest/chair\",\"data\":{\"randomYaw\":true,\"variant\":\"OakLarge\"}}"); + IrisBlockData entry = new IrisBlockData("fancy"); + entry.getData().put("pitch", 12.75); + + IrisBlockData parsed = IrisBlockData.from(entry.getBlockData(data).key()); + + assertEquals(true, parsed.getData().get("randomYaw")); + assertEquals("OakLarge", parsed.getData().get("variant")); + assertEquals(22.5, (Double) parsed.getData().get("yaw"), 0.0); + assertEquals(12.75, (Double) parsed.getData().get("pitch"), 0.0); + } + + @Test + public void parsedVanillaPropertiesStillNormalizeCase() { + IrisBlockData entry = IrisBlockData.from("OAK_LOG[AXIS=Y]"); + + assertEquals("minecraft:oak_log[axis=y]", entry.stateKey()); + } + + @Test + public void decimalProviderPropertiesRetainPrecision() { + IrisBlockData entry = IrisBlockData.from("craftengine:forest/lantern[yaw=22.5,pitch=-12.75]"); + + assertEquals(22.5, (Double) entry.getData().get("yaw"), 0.0); + assertEquals(-12.75, (Double) entry.getData().get("pitch"), 0.0); + assertTrue(entry.stateKey().contains("yaw=22.5")); + assertTrue(entry.stateKey().contains("pitch=-12.75")); + } + + @Test + public void wholeNumberPropertiesKeepIntegerSyntax() { + IrisBlockData entry = new IrisBlockData("minecraft:wheat"); + entry.getData().put("age", 4.0); + + assertEquals("minecraft:wheat[age=4]", entry.stateKey()); + } + + @Test + public void providerReadinessClearsAuthoringBlockAndGateMisses() throws Exception { + String blockKey = "lateblocks:example/stone"; + registries.missingBlocks.add(blockKey); + IrisData data = pack(null, "{\"block\":\"" + blockKey + "\"}"); + try { + IrisBlockData missing = data.getBlockLoader().load("fancy", false); + assertTrue(missing.getBlockData(data).isAir()); + ContentGate originalGate = data.getContentGate(); + registries.missingBlocks.remove(blockKey); + + IrisData.invalidateLoadedContentRegistries(data.getDataFolder()); + + IrisBlockData resolved = data.getBlockLoader().load("fancy", false); + assertNotSame(missing, resolved); + assertNotSame(originalGate, data.getContentGate()); + assertEquals(blockKey, resolved.getBlockData(data).key()); + } finally { + registries.missingBlocks.remove(blockKey); + } + } + + @Test + public void providerReadinessLeavesEngineAttachedCachesIntact() throws Exception { + IrisData data = pack(null, "{\"block\":\"minecraft:stone\"}"); + IrisBlockData block = data.getBlockLoader().load("fancy", false); + ContentGate gate = data.getContentGate(); + Engine engine = mock(Engine.class); + data.registerEngine(engine); + try { + IrisData.invalidateLoadedContentRegistries(data.getDataFolder()); + + assertSame(block, data.getBlockLoader().load("fancy", false)); + assertSame(gate, data.getContentGate()); + } finally { + data.unregisterEngine(engine); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java index dc47d34ca..224a16e66 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java @@ -18,6 +18,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ContentKeyValidatorTest { private static PlatformRegistries registries() { @@ -87,6 +89,37 @@ public class ContentKeyValidatorTest { assertTrue(error.namespaceLoaded()); } + @Test + public void validateAcceptsNativeAndQualifiedExternalBlockTypes() { + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:caveblock", + "oraxen:oraxen/caveblock", "custom:marble", "itemsadder:custom/marble")); + + List errors = ContentKeyValidator.validate(registries, + List.of("stone", "oraxen:caveblock", "oraxen:oraxen/caveblock", + "custom:marble", "itemsadder:custom/marble"), List.of(), List.of()); + + assertTrue(errors.toString(), errors.isEmpty()); + } + + @Test + public void validateStillReportsMissingExternalBlockTypes() { + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:oraxen/caveblock")); + + List errors = ContentKeyValidator.validate(registries, + List.of("oraxen:oraxen/caveblok", "itemsadder:custom/marble"), List.of(), List.of()); + + assertEquals(2, errors.size()); + assertEquals("oraxen:oraxen/caveblok", errors.get(0).key()); + assertTrue(errors.get(0).namespaceLoaded()); + assertEquals("oraxen:oraxen/caveblock", errors.get(0).suggestion()); + assertEquals("itemsadder:custom/marble", errors.get(1).key()); + assertFalse(errors.get(1).namespaceLoaded()); + } + @Test public void validateFlagsUnloadedNamespaceWhenNamespaceMissing() { List errors = ContentKeyValidator.validate(registries(), @@ -275,7 +308,7 @@ public class ContentKeyValidatorTest { @Override public List blockTypeKeys() { - return List.of(); + return blocks; } @Override diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java index 5232eb2c9..2c79cbe0c 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java @@ -3,6 +3,10 @@ package art.arcane.iris.core.pack; 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.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformStructureHooks; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -22,6 +26,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class PackValidationCacheTest { @Rule @@ -214,4 +220,28 @@ public class PackValidationCacheTest { PackValidationRegistry.clear(); } } + + @Test + public void externalBlockAvailabilityChangesValidationContext() { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.structureHooks()).thenReturn(mock(PlatformStructureHooks.class)); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone")); + IrisPlatforms.bind(platform); + try { + String before = PackValidationCache.contextFingerprint(); + when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:oraxen/caveblock")); + + assertNotEquals(before, PackValidationCache.contextFingerprint()); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/project/ExternalBlockSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/ExternalBlockSchemaTest.java new file mode 100644 index 000000000..48d2009d4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/ExternalBlockSchemaTest.java @@ -0,0 +1,77 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ExternalBlockSchemaTest { + private static final String QUALIFIED_BLOCK = "craftengine:example/ores/copper"; + + private IrisPlatform previous; + + @Before + public void bindPlatform() { + previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(List.of(QUALIFIED_BLOCK)); + when(registries.blockStateProperties()).thenReturn(Map.of(QUALIFIED_BLOCK, + List.of(new PlatformBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null)))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + @Test + public void qualifiedBlockReferencesResolveToItsEnumAndPropertySchema() { + IrisData data = mock(IrisData.class); + ResourceLoader blocks = mock(ResourceLoader.class); + when(blocks.getPossibleKeys()).thenReturn(new String[0]); + when(data.getBlockLoader()).thenReturn(blocks); + + JSONObject schema = new SchemaBuilder(IrisBlockData.class, data).construct(); + JSONObject branch = schema.getJSONArray("allOf").getJSONObject(0) + .getJSONArray("anyOf").getJSONObject(0); + String enumReference = branch.getJSONObject("if").getJSONObject("properties") + .getJSONObject("block").getString("$ref"); + String propertyReference = branch.getJSONObject("then").getJSONObject("properties") + .getJSONObject("data").getString("$ref"); + + assertEquals(QUALIFIED_BLOCK, resolve(schema, enumReference).getJSONArray("enum").getString(0)); + JSONObject axis = resolve(schema, propertyReference).getJSONObject("properties").getJSONObject("axis"); + assertEquals("string", axis.getString("type")); + assertEquals("x", axis.getJSONArray("enum").getString(0)); + assertEquals("z", axis.getJSONArray("enum").getString(2)); + } + + private static JSONObject resolve(JSONObject schema, String reference) { + JSONObject resolved = schema; + for (String token : reference.substring(2).split("/")) { + resolved = resolved.getJSONObject(token.replace("~1", "/").replace("~0", "~")); + } + return resolved; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java new file mode 100644 index 000000000..edf027b46 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java @@ -0,0 +1,179 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.link.ExternalDataProvider; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.link.data.DataType; +import art.arcane.iris.core.nms.container.BlockProperty; +import art.arcane.iris.core.nms.container.Pair; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.volmlib.util.collection.KMap; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.MissingResourceException; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ExternalDataSVCBlockRoutingTest { + @Test + public void nativeLookupPinsProviderAndRetainsPropertiesUntilPlacement() throws Exception { + Identifier nativeId = Identifier.fromString("example:wood"); + ExternalDataProvider provider = provider("CraftEngine", nativeId); + BlockData base = mock(BlockData.class); + KMap properties = new KMap<>(); + properties.put("facing", "east"); + when(provider.getBlockData(nativeId, properties)).thenReturn(IrisCustomData.of(base, + Identifier.fromString("example:wood[facing=east]"))); + ExternalDataSVC service = service(provider); + + IrisCustomData resolved = (IrisCustomData) service.getBlockData( + Identifier.fromString("example:wood[facing=east]")).orElseThrow(); + + assertSame(base, resolved.getBase()); + assertEquals("craftengine:example/wood[facing=east]", resolved.getCustom().toString()); + ExternalDataProvider competing = provider("ItemsAdder", nativeId); + addProvider(service, competing); + Engine engine = mock(Engine.class); + Block block = mock(Block.class); + service.processUpdate(engine, block, resolved.getCustom()); + verify(provider).processUpdate(engine, block, Identifier.fromString("example:wood[facing=east]")); + verify(competing, never()).processUpdate(any(), any(), any()); + } + + @Test + public void qualifiedLookupSelectsProviderDespiteNativeCollision() throws Exception { + Identifier nativeId = Identifier.fromString("example:wood"); + ExternalDataProvider first = provider("CraftEngine", nativeId); + ExternalDataProvider second = provider("ItemsAdder", nativeId); + BlockData base = mock(BlockData.class); + when(second.getBlockData(eq(nativeId), any())).thenReturn(IrisCustomData.of(base, nativeId)); + ExternalDataSVC service = service(first, second); + + assertFalse(service.getBlockData(nativeId).isPresent()); + IrisCustomData resolved = (IrisCustomData) service.getBlockData( + Identifier.fromString("itemsadder:example/wood")).orElseThrow(); + + assertEquals("itemsadder:example/wood", resolved.getCustom().toString()); + verify(first, never()).getBlockData(any(), any()); + assertFalse(service.getBlockProperties(nativeId).isPresent()); + assertEquals(List.of(), service.getBlockProperties(Identifier.fromString("itemsadder:example/wood")).orElseThrow()); + } + + @Test + public void missingQualifiedProviderDoesNotLeakIntoAnotherNamespaceOwner() throws Exception { + Identifier id = Identifier.fromString("itemsadder:example/wood"); + ExternalDataProvider unrelated = provider("OtherBlocks", id); + ExternalDataSVC service = service(unrelated); + + assertFalse(service.getBlockData(id).isPresent()); + verify(unrelated, never()).getBlockData(any(), any()); + } + + @Test + public void qualifiedNestedKeysAndPropertiesAppearInSchemas() throws Exception { + Identifier nativeId = Identifier.fromString("example:ores/copper"); + ExternalDataProvider provider = provider("ItemsAdder", nativeId); + List properties = List.of(BlockProperty.ofBoolean("lit", false)); + when(provider.getBlockProperties(nativeId)).thenReturn(properties); + BlockData base = mock(BlockData.class); + when(provider.getBlockData(eq(nativeId), any())).thenReturn(base); + ExternalDataSVC service = service(provider); + Identifier qualified = Identifier.fromString("itemsadder:example/ores/copper"); + + assertSame(base, service.getBlockData(qualified).orElseThrow()); + assertEquals(Set.of(nativeId, qualified), Set.copyOf(service.getAllIdentifiers(DataType.BLOCK))); + assertEquals(properties, service.getBlockProperties(qualified).orElseThrow()); + assertEquals(2, service.getAllBlockProperties().size()); + } + + @Test + public void nativeProviderKeysCanContainSlashes() throws Exception { + Identifier nativeId = Identifier.fromString("oraxen:ores/copper"); + ExternalDataProvider provider = provider("Oraxen", nativeId); + BlockData base = mock(BlockData.class); + when(provider.getBlockData(eq(nativeId), any())).thenReturn(base); + ExternalDataSVC service = service(provider); + + assertSame(base, service.getBlockData(nativeId).orElseThrow()); + assertSame(base, service.getBlockData(Identifier.fromString("oraxen:oraxen/ores/copper")).orElseThrow()); + } + + @Test + public void providerFailureDoesNotEscapeIntoVanillaFallbackParsing() throws Exception { + Identifier id = Identifier.fromString("example:stone"); + ExternalDataProvider provider = provider("ItemsAdder", id); + when(provider.getBlockData(eq(id), any())).thenThrow(new IllegalStateException("Registry is unavailable")); + ExternalDataSVC service = service(provider); + + assertFalse(service.getBlockData(id).isPresent()); + } + + @Test + public void unavailableProviderDoesNotReportPlacementSuccess() throws Exception { + ExternalDataSVC service = service(); + + assertThrows(MissingResourceException.class, () -> service.processUpdate( + mock(Engine.class), mock(Block.class), Identifier.fromString("itemsadder:example/wood"))); + } + + @Test + public void malformedStateSuffixesFailBeforeProviderLookup() throws Exception { + ExternalDataProvider provider = provider("ItemsAdder", Identifier.fromString("example:wood")); + ExternalDataSVC service = service(provider); + for (String state : List.of("wood[", "wood]", "wood[a=b]tail", "wood[a=b,]", "wood[a=b,a=c]", "wood[[a=b]]", "wood[a]")) { + Identifier id = new Identifier("example", state); + assertThrows(state, IllegalArgumentException.class, () -> ExternalDataSVC.parseState(id)); + assertFalse(state, service.getBlockData(id).isPresent()); + } + verify(provider, never()).getBlockData(any(), any()); + } + + @Test + public void stateParserPreservesDecimalsAndTrimsProperties() { + Pair> parsed = ExternalDataSVC.parseState( + Identifier.fromString("craftengine:example/chair[ yaw = 22.5 ]")); + + assertEquals(Identifier.fromString("craftengine:example/chair"), parsed.getA()); + assertEquals("22.5", parsed.getB().get("yaw")); + assertEquals("craftengine:example/chair[yaw=22.5]", ExternalDataSVC.buildState(parsed.getA(), parsed.getB()).toString()); + } + + private static ExternalDataProvider provider(String pluginId, Identifier id) { + ExternalDataProvider provider = mock(ExternalDataProvider.class); + when(provider.getPluginId()).thenReturn(pluginId); + when(provider.isValidProvider(id, DataType.BLOCK)).thenReturn(true); + when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of(id)); + when(provider.getBlockProperties(id)).thenReturn(List.of()); + return provider; + } + + private static ExternalDataSVC service(ExternalDataProvider... providers) throws Exception { + ExternalDataSVC service = new ExternalDataSVC(); + for (ExternalDataProvider provider : providers) { + addProvider(service, provider); + } + return service; + } + + @SuppressWarnings("unchecked") + private static void addProvider(ExternalDataSVC service, ExternalDataProvider provider) throws Exception { + Field field = ExternalDataSVC.class.getDeclaredField("activeProviders"); + field.setAccessible(true); + List providers = (List) field.get(service); + providers.add(provider); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCContentChangeTest.java b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCContentChangeTest.java new file mode 100644 index 000000000..9d7ba45a9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCContentChangeTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.link.ExternalDataProvider; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.link.data.DataType; +import org.junit.Test; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +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.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ExternalDataSVCContentChangeTest { + @Test + public void enabledPluginIsPendingUntilItsProviderActivates() { + ExternalDataSVC service = new ExternalDataSVC(); + Plugin plugin = mock(Plugin.class); + PluginManager plugins = mock(PluginManager.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugins.getPlugin("ItemsAdder")).thenReturn(plugin); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + + assertTrue(service.hasPendingBlockProvider("itemsadder:example/stone")); + assertTrue(service.hasPendingBlockProvider("example:stone")); + assertFalse(service.hasPendingBlockProvider("minecraft:stone")); + assertFalse(service.hasPendingBlockProvider("oraxen:oraxen/stone")); + } + } + + @Test + public void contentChangesAdvanceTheRevisionWithoutAListener() { + ExternalDataSVC service = new ExternalDataSVC(); + long revision = service.contentRevision(); + + service.notifyContentChanged(); + + assertEquals(revision + 1L, service.contentRevision()); + } + + @Test + public void activationAndRegistryReloadNotifyTheListener() { + ExternalDataSVC service = new ExternalDataSVC(); + service.notifyContentChanged(); + AtomicInteger changes = new AtomicInteger(); + service.setContentChangeListener(changes::incrementAndGet); + ExternalDataProvider provider = enabledProvider("TestBlocks"); + + service.registerProvider(provider); + assertEquals(1, changes.get()); + service.notifyContentChanged(); + assertEquals(2, changes.get()); + service.onDisable(); + service.notifyContentChanged(); + assertEquals(2, changes.get()); + } + + @Test + public void unavailableRegistryDoesNotHideOtherProviderBlocks() { + ExternalDataSVC service = new ExternalDataSVC(); + ExternalDataProvider loading = enabledProvider("LoadingBlocks"); + when(loading.getTypes(DataType.BLOCK)).thenThrow(new IllegalStateException("Content is still loading")); + service.registerProvider(loading); + ExternalDataProvider ready = enabledProvider("ReadyBlocks"); + when(ready.getTypes(DataType.BLOCK)).thenReturn(List.of(Identifier.fromString("example:stone"))); + service.registerProvider(ready); + + assertEquals(Set.of(Identifier.fromString("example:stone"), Identifier.fromString("readyblocks:example/stone")), + Set.copyOf(service.getAllIdentifiers(DataType.BLOCK))); + } + + @Test + public void delayedRegistryInitializesBeforeItsContentIsReady() { + ExternalDataSVC service = new ExternalDataSVC(); + ExternalDataProvider provider = enabledProvider("DelayedBlocks"); + when(provider.isReady()).thenReturn(false); + when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of()); + + service.registerProvider(provider); + + verify(provider).init(); + assertTrue(service.hasPendingBlockProvider("delayedblocks:example/stone")); + assertTrue(service.getAllIdentifiers(DataType.BLOCK).isEmpty()); + + Identifier identifier = Identifier.fromString("example:stone"); + when(provider.isReady()).thenReturn(true); + when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of(identifier)); + service.notifyContentChanged(); + + assertFalse(service.hasPendingBlockProvider("delayedblocks:example/stone")); + assertEquals(Set.of(identifier, Identifier.fromString("delayedblocks:example/stone")), + Set.copyOf(service.getAllIdentifiers(DataType.BLOCK))); + } + + private static ExternalDataProvider enabledProvider(String pluginId) { + Plugin plugin = mock(Plugin.class); + when(plugin.isEnabled()).thenReturn(true); + ExternalDataProvider provider = mock(ExternalDataProvider.class); + when(provider.getPluginId()).thenReturn(pluginId); + when(provider.getPlugin()).thenReturn(plugin); + when(provider.isReady()).thenReturn(true); + return provider; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java index effc8d30e..f6229d600 100644 --- a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java @@ -31,6 +31,7 @@ public class ExternalDataSVCRegistryTest { assertEquals(List.of( "CraftEngine", "Nexo", + "Oraxen", "ItemsAdder", "ExecutableItems", "MMOItems", diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java index 4a5b58410..4b0208384 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java @@ -93,6 +93,50 @@ public class EngineShutdownDrainTest { } } + @Test + public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + AtomicBoolean queuedRoute = new AtomicBoolean(true); + doAnswer(invocation -> { + queuedRoute.set(false); + return null; + }).when(fixture.manager).close(); + doAnswer(invocation -> { + assertFalse("Router drain must not wait for a queued region callback", queuedRoute.get()); + return null; + }).when(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter(); + + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + + InOrder shutdownOrder = inOrder(fixture.manager, fixture.engine, fixture.sessions); + shutdownOrder.verify(fixture.manager).close(); + shutdownOrder.verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter(); + shutdownOrder.verify(fixture.sessions).sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); + verify(fixture.manager).close(); + assertTrue(fixture.engine.closed); + } + + @Test + public void failedWorldManagerStopPreventsRouterDrainAndCanBeRetried() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + doThrow(new IllegalStateException("World manager still has work")).doNothing().when(fixture.manager).close(); + + assertThrows(IllegalStateException.class, fixture.shutdown::close); + verify(fixture.engine, never()).closeAttachedGenerationHistoryRuntimeRouter(); + verify(fixture.mantle, never()).close(); + assertFalse(fixture.engine.closed); + + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + + verify(fixture.manager, times(2)).close(); + verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter(); + assertTrue(fixture.engine.closed); + } + @Test public void successfulEngineCloseAllowsTheSameWorldToPrepareGenerationAgain() throws Exception { GenerationHistory history = history(); diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java index 5f8d3e4be..efe614c2e 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java @@ -7,6 +7,8 @@ import org.bukkit.Chunk; import org.junit.Test; import org.mockito.MockedStatic; +import java.lang.reflect.Field; + import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.CALLS_REAL_METHODS; @@ -34,8 +36,11 @@ public class IrisWorldManagerNativePoiTest { } @Test - public void chunkLoadPropagatesPoiFailuresToTheRuntimeEventReporter() { + public void chunkLoadPropagatesPoiFailuresToTheRuntimeEventReporter() throws Exception { IrisWorldManager manager = mock(IrisWorldManager.class, CALLS_REAL_METHODS); + Field maintenance = IrisWorldManager.class.getDeclaredField("chunkMaintenance"); + maintenance.setAccessible(true); + maintenance.set(manager, new WorldChunkMaintenance(manager)); Engine engine = mock(Engine.class); Chunk chunk = mock(Chunk.class); INMSBinding binding = mock(INMSBinding.class); diff --git a/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java b/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java index 1842335d2..29967c1bd 100644 --- a/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java @@ -35,6 +35,7 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -58,6 +59,57 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public final class ResolvedTerrainProviderTest { + @Test + public void cachedTransitionTerrainRetainsDeferredBlocksWhenCopied() { + Fixture fixture = new Fixture(); + String key = "itemsadder:rocks/ruby_ore"; + PlatformBlockState custom = Fixture.block(key, false); + when(custom.isCustom()).thenReturn(true); + when(custom.placementBaseState()).thenReturn(fixture.stone); + when(custom.deferredPlacementKey()).thenReturn(key); + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true); + when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); + when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); + fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + context.setTerrainHeight(localX, localZ, 2); + for (int y = 0; y < 16; y++) { + blocks.setRaw(localX, y, localZ, y == 2 ? custom : y < 2 ? fixture.stone : fixture.air); + biomes.setRaw(localX, y, localZ, fixture.biome); + } + } + } + }; + TerrainBoundarySignature captured = fixture.provider.column(0, 0); + assertEquals(key, captured.geometry().voxelAt(2).stateKey()); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockOrNull(key)).thenReturn(custom); + when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone); + when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air); + when(registries.biome("minecraft:plains")).thenReturn(fixture.biome); + ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); + AtomicInteger customWrites = new AtomicInteger(); + Hunk blocks = Hunk.newArrayHunk(16, 16, 16).listen((x, y, z, state) -> { + if (state.isCustom()) { + customWrites.incrementAndGet(); + } + }); + try (MockedStatic platforms = mockStatic(IrisPlatforms.class); + IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { + platforms.when(IrisPlatforms::get).thenReturn(platform); + fixture.provider.generate(fixture.mode, 0, 0, blocks, Hunk.newArrayHunk(16, 16, 16), false, context); + } + + assertEquals(256, customWrites.get()); + assertSame(custom, blocks.getRaw(0, 2, 0)); + assertEquals(key, blocks.getRaw(0, 2, 0).deferredPlacementKey()); + assertEquals(Integer.valueOf(1), fixture.computations.get("0,0")); + } + @Test public void cachedSpeculativeFloatingIdentityIsPublishedOnlyByActualGeneration() throws Exception { Fixture fixture = new Fixture(); diff --git a/core/src/test/java/art/arcane/iris/engine/WorldBlockDropRouterTest.java b/core/src/test/java/art/arcane/iris/engine/WorldBlockDropRouterTest.java new file mode 100644 index 000000000..655189e4f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldBlockDropRouterTest.java @@ -0,0 +1,266 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBlockDrops; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayDeque; +import java.util.Queue; + +import static org.junit.Assert.assertEquals; +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldBlockDropRouterTest { + @Test + public void coldBiomeCancelsBeforeChangingDropsOrSchedulingWork() { + try (Fixture fixture = new Fixture()) { + fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) + .thenThrow(new SavedBiomeUnavailableException("Biome loading", true)); + BlockBreakEvent event = fixture.event(); + + fixture.router.onBlockBreak(event); + + assertTrue(event.isCancelled()); + assertTrue(event.isDropItems()); + fixture.assertNoSideEffects(); + fixture.queries.verify(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location), never()); + } + } + + @Test + public void coldRegionCancelsBeforeFillingAlreadyMatchedBiomeDrops() { + try (Fixture fixture = new Fixture()) { + fixture.queries.when(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location)) + .thenThrow(new SavedBiomeUnavailableException("Region loading", true)); + BlockBreakEvent event = fixture.event(); + + fixture.router.onBlockBreak(event); + + verify(fixture.drops).shouldDropFor(fixture.blockData, fixture.data); + assertTrue(event.isCancelled()); + assertTrue(event.isDropItems()); + fixture.assertNoSideEffects(); + } + } + + @Test + public void alreadyCancelledBreakSkipsSavedLookups() { + try (Fixture fixture = new Fixture()) { + BlockBreakEvent event = fixture.event(); + event.setCancelled(true); + + fixture.router.onBlockBreak(event); + + assertTrue(event.isCancelled()); + fixture.queries.verifyNoInteractions(); + fixture.assertNoSideEffects(); + } + } + + @Test + public void otherWorldBreakSkipsSavedLookups() { + try (Fixture fixture = new Fixture()) { + when(fixture.block.getWorld()).thenReturn(mock(World.class)); + BlockBreakEvent event = fixture.event(); + + fixture.router.onBlockBreak(event); + + assertFalse(event.isCancelled()); + fixture.queries.verifyNoInteractions(); + fixture.assertNoSideEffects(); + } + } + + @Test + public void freshAttemptAfterLoadingProducesDropsOnceWithoutReplayingTheCancelledEvent() { + try (Fixture fixture = new Fixture()) { + fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) + .thenThrow(new SavedBiomeUnavailableException("Biome loading", true)) + .thenReturn(fixture.biome); + BlockBreakEvent cold = fixture.event(); + fixture.router.onBlockBreak(cold); + fixture.assertNoSideEffects(); + + BlockBreakEvent retry = fixture.event(); + fixture.router.onBlockBreak(retry); + + assertTrue(cold.isCancelled()); + assertTrue(cold.isDropItems()); + assertFalse(retry.isCancelled()); + assertFalse(retry.isDropItems()); + assertEquals(1, fixture.delayed.size()); + verify(fixture.drops).fillDrops(eq(false), any()); + fixture.delayed.remove().run(); + + verify(fixture.world).dropItemNaturally(fixture.location.clone().add(.5, .5, .5), fixture.item); + assertEquals(1, fixture.markers.size()); + assertTrue(fixture.delayed.isEmpty()); + } + } + + @Test + public void permanentSavedBiomeFailureCancelsAndPropagates() { + verifyReportedFailure(new SavedBiomeUnavailableException("Saved biome missing", false)); + } + + @Test + public void loadingWithSuppressedFailureCancelsAndPropagates() { + SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Biome loading", true); + failure.addSuppressed(new IllegalStateException("Saved storage cleanup failed")); + verifyReportedFailure(failure); + } + + @Test + public void protectionProbeCancelledAfterDispatchProducesNoFinalDropsOrMarkerCleanup() { + try (Fixture fixture = new Fixture()) { + BlockBreakEvent probe = new ProtectionProbe(fixture.block, fixture.player); + probe.setDropItems(false); + + fixture.router.onBlockBreak(probe); + assertFalse(probe.isCancelled()); + assertFalse(probe.isDropItems()); + assertEquals(1, fixture.delayed.size()); + probe.setCancelled(true); + fixture.delayed.remove().run(); + + verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + assertTrue(fixture.markers.isEmpty()); + verify(fixture.manager, never()).getMantle(); + } + } + + private void verifyReportedFailure(SavedBiomeUnavailableException failure) { + try (Fixture fixture = new Fixture()) { + fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)).thenThrow(failure); + BlockBreakEvent event = fixture.event(); + + assertSame(failure, assertThrows(SavedBiomeUnavailableException.class, + () -> fixture.router.onBlockBreak(event))); + + assertTrue(event.isCancelled()); + assertTrue(event.isDropItems()); + fixture.assertNoSideEffects(); + } + } + + private static final class ProtectionProbe extends BlockBreakEvent { + private ProtectionProbe(Block block, Player player) { + super(block, player); + } + } + + private static final class Fixture implements AutoCloseable { + private final IrisWorldManager manager = mock(IrisWorldManager.class); + private final Engine engine = mock(Engine.class); + private final IrisData data = mock(IrisData.class); + private final World world = mock(World.class); + private final Block block = mock(Block.class); + private final BlockData blockData = mock(BlockData.class); + private final Player player = mock(Player.class); + private final Location location = new Location(world, 16, 72, 32); + private final IrisBiome biome = mock(IrisBiome.class); + private final IrisBlockDrops drops = mock(IrisBlockDrops.class); + private final ItemStack item = mock(ItemStack.class); + private final WorldBlockDropRouter router = new WorldBlockDropRouter(manager); + private final Queue delayed = new ArrayDeque<>(); + private final Queue markers = new ArrayDeque<>(); + private final MockedStatic binding = mockStatic(BukkitWorldBinding.class); + private final MockedStatic queries = mockStatic(EngineBukkitOps.class); + private final MockedStatic scheduling = mockStatic(J.class); + + private Fixture() { + IrisWorld irisWorld = mock(IrisWorld.class); + EngineTarget target = mock(EngineTarget.class); + IrisRegion region = mock(IrisRegion.class); + IrisDimension dimension = mock(IrisDimension.class); + when(manager.getEngine()).thenReturn(engine); + when(manager.getTarget()).thenReturn(target); + when(manager.getData()).thenReturn(data); + when(target.getWorld()).thenReturn(irisWorld); + when(engine.getWorld()).thenReturn(irisWorld); + when(irisWorld.minHeight()).thenReturn(-64); + when(engine.getDimension()).thenReturn(dimension); + binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + when(block.getWorld()).thenReturn(world); + when(block.getX()).thenReturn(16); + when(block.getY()).thenReturn(72); + when(block.getZ()).thenReturn(32); + when(block.getLocation()).thenReturn(location); + when(block.getBlockData()).thenReturn(blockData); + when(biome.getBlockDrops()).thenReturn(new KList<>(drops)); + when(region.getBlockDrops()).thenReturn(new KList<>()); + when(dimension.getBlockDrops()).thenReturn(new KList<>()); + when(drops.shouldDropFor(blockData, data)).thenReturn(true); + when(drops.isReplaceVanillaDrops()).thenReturn(true); + doAnswer(invocation -> { + KList results = invocation.getArgument(1); + results.add(item); + return null; + }).when(drops).fillDrops(eq(false), any()); + queries.when(() -> EngineBukkitOps.getBiome(engine, location)).thenReturn(biome); + queries.when(() -> EngineBukkitOps.getRegion(engine, location)).thenReturn(region); + when(manager.managedTask(anyString(), any(Runnable.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + scheduling.when(() -> J.runAt(eq(location), any(Runnable.class), eq(1))).thenAnswer(invocation -> { + delayed.add(invocation.getArgument(1)); + return true; + }); + scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { + markers.add(invocation.getArgument(0)); + return null; + }); + } + + private BlockBreakEvent event() { + return new BlockBreakEvent(block, player); + } + + private void assertNoSideEffects() { + verify(drops, never()).fillDrops(anyBoolean(), any()); + verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + verify(manager, never()).getMantle(); + scheduling.verifyNoInteractions(); + assertTrue(delayed.isEmpty()); + assertTrue(markers.isEmpty()); + } + + @Override + public void close() { + scheduling.close(); + queries.close(); + binding.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceRoutingTest.java b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceRoutingTest.java new file mode 100644 index 000000000..16d8e2fdd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceRoutingTest.java @@ -0,0 +1,391 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter.SavedChunkMantle; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.mockito.InOrder; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldChunkMaintenanceRoutingTest { + private static IrisPlatform previousPlatform; + + @BeforeClass + public static void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class)); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void materializesTheSavedOwnerAndKeepsSpawningAfterCompletion() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.schedule(); + verify(fixture.currentMantle, never()).getChunk(2, -1); + fixture.prepare(); + + verify(fixture.savedChunk).use(); + verify(fixture.saved).detachThread(); + verify(fixture.saved, never()).close(); + verify(fixture.savedChunk, never()).release(); + + fixture.apply(); + fixture.awaitRelease(); + + fixture.updates.verify(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle)); + InOrder lifetime = inOrder(fixture.savedChunk, fixture.saved); + lifetime.verify(fixture.savedChunk).use(); + lifetime.verify(fixture.saved).detachThread(); + lifetime.verify(fixture.savedChunk).release(); + lifetime.verify(fixture.saved).close(); + assertNotSame(Thread.currentThread(), fixture.releaseThread.get()); + assertFalse(fixture.scoped.get()); + + fixture.schedule(); + assertTrue(fixture.async.isEmpty()); + verify(fixture.router).openSavedChunkMantle(2, -1); + verify(fixture.spawner, times(2)).spawnInitially(fixture.chunk); + } + } + + @Test + public void managerCloseReleasesQueuedStorageWithoutRunningItsRegionCallback() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.schedule(); + fixture.prepare(); + + fixture.maintenance.close(); + fixture.awaitRelease(); + fixture.apply(); + fixture.schedule(); + + fixture.updates.verifyNoInteractions(); + verify(fixture.saved).close(); + verify(fixture.spawner, never()).spawnInitially(fixture.chunk); + assertTrue(fixture.async.isEmpty()); + } + } + + @Test + public void managerCloseDuringPreparationReleasesLocallyHeldStorage() throws Exception { + try (Fixture fixture = new Fixture()) { + when(fixture.savedMantle.getChunk(2, -1)).thenAnswer(invocation -> { + fixture.maintenance.close(); + return fixture.savedChunk; + }); + fixture.schedule(); + + fixture.prepare(); + fixture.awaitRelease(); + + fixture.updates.verifyNoInteractions(); + assertTrue(fixture.regions.isEmpty()); + verify(fixture.savedChunk).release(); + verify(fixture.saved).close(); + } + } + + @Test + public void rejectedRegionSchedulingReleasesStorageAndAllowsRetry() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.acceptRegion.set(false); + fixture.schedule(); + fixture.prepare(); + fixture.awaitRelease(); + + fixture.acceptRegion.set(true); + fixture.schedule(); + assertEquals(1, fixture.async.size()); + fixture.updates.verifyNoInteractions(); + verify(fixture.savedChunk).release(); + } + } + + @Test + public void rejectedLifecycleAdmissionReleasesPreparedStorage() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.schedule(); + fixture.prepare(); + fixture.admitted.set(false); + + fixture.apply(); + fixture.awaitRelease(); + + fixture.updates.verifyNoInteractions(); + verify(fixture.saved).close(); + verify(fixture.savedChunk).release(); + } + } + + @Test + public void unloadedChunkDoesNotBecomeCompleted() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.schedule(); + fixture.prepare(); + when(fixture.world.isChunkLoaded(2, -1)).thenReturn(false); + + fixture.apply(); + fixture.awaitRelease(); + + when(fixture.world.isChunkLoaded(2, -1)).thenReturn(true); + fixture.schedule(); + assertEquals(1, fixture.async.size()); + fixture.updates.verifyNoInteractions(); + } + } + + @Test + public void failedMaterializationRetainsRetryAndReportsTheFailure() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("Provider placement failed"); + fixture.updates.when(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle)).thenThrow(failure); + fixture.schedule(); + fixture.prepare(); + + fixture.apply(); + fixture.awaitRelease(); + + fixture.logging.verify(() -> IrisLogging.reportError(anyString(), eq(failure))); + fixture.schedule(); + assertEquals(1, fixture.async.size()); + verify(fixture.spawner, never()).spawnInitially(fixture.chunk); + } + } + + @Test + public void skippedMaterializationRetainsRetryWithoutSuppressingSpawning() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.updates.when(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle)) + .thenAnswer(invocation -> null); + fixture.schedule(); + fixture.prepare(); + + fixture.apply(); + fixture.awaitRelease(); + + fixture.schedule(); + assertEquals(1, fixture.async.size()); + verify(fixture.spawner).spawnInitially(fixture.chunk); + } + } + + @Test + public void regeneratedChunkInvalidatesCompletedMaterialization() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.schedule(); + fixture.prepare(); + fixture.apply(); + fixture.awaitRelease(); + + fixture.maintenance.invalidateMaterialization(2, -1); + fixture.schedule(); + + assertEquals(1, fixture.async.size()); + } + } + + @Test + public void pendingMaterializationIsBoundedAndCancelledBeforePreparation() throws Exception { + try (Fixture fixture = new Fixture()) { + for (int x = 0; x < 256; x++) { + fixture.maintenance.updateChunkRegion(fixture.world, x, 0); + } + assertEquals(128, fixture.async.size()); + + fixture.maintenance.close(); + while (!fixture.async.isEmpty()) { + fixture.prepare(); + } + + verify(fixture.router, never()).openSavedChunkMantle(anyInt(), anyInt()); + } + } + + private static final class Fixture implements AutoCloseable { + private final IrisEngine engine = mock(IrisEngine.class); + private final IrisWorldManager manager = mock(IrisWorldManager.class); + private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + private final SavedChunkMantle saved = mock(SavedChunkMantle.class); + private final SavedChunkMantle.Scope scope = mock(SavedChunkMantle.Scope.class); + private final EngineMantle currentEngineMantle = mock(EngineMantle.class); + private final Mantle currentMantle; + private final Mantle savedMantle; + private final MantleChunk savedChunk; + private final WorldEntitySpawner spawner = mock(WorldEntitySpawner.class); + private final World world = mock(World.class); + private final Chunk chunk = mock(Chunk.class); + private final WorldChunkMaintenance maintenance = new WorldChunkMaintenance(manager); + private final Map pending; + private final Queue async = new ArrayDeque<>(); + private final Queue regions = new ArrayDeque<>(); + private final AtomicBoolean etched = new AtomicBoolean(); + private final AtomicBoolean scoped = new AtomicBoolean(); + private final AtomicBoolean acceptRegion = new AtomicBoolean(true); + private final AtomicBoolean admitted = new AtomicBoolean(true); + private final AtomicReference releaseThread = new AtomicReference<>(); + private final Semaphore released = new Semaphore(0); + private final MockedStatic configured = mockStatic(IrisSettings.class); + private final MockedStatic scheduling = mockStatic(J.class); + private final MockedStatic updates = mockStatic(EngineBukkitOps.class); + private final MockedStatic logging = mockStatic(IrisLogging.class); + + @SuppressWarnings("unchecked") + private Fixture() throws Exception { + currentMantle = mock(Mantle.class); + savedMantle = mock(Mantle.class); + savedChunk = mock(MantleChunk.class); + Field pendingField = WorldChunkMaintenance.class.getDeclaredField("materializations"); + pendingField.setAccessible(true); + pending = (Map) pendingField.get(maintenance); + Field field = IrisWorldManager.class.getDeclaredField("entitySpawner"); + field.setAccessible(true); + field.set(manager, spawner); + when(manager.getEngine()).thenReturn(engine); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(manager.getMantle()).thenReturn(currentMantle); + when(engine.getMantle()).thenReturn(currentEngineMantle); + when(currentEngineMantle.getMantle()).thenReturn(currentMantle); + when(saved.mantle()).thenReturn(savedMantle); + when(savedMantle.getChunk(2, -1)).thenReturn(savedChunk); + when(savedChunk.use()).thenReturn(savedChunk); + when(savedMantle.isChunkLoaded(2, -1)).thenReturn(true); + when(savedMantle.hasFlag(2, -1, MantleFlag.ETCHED)).thenAnswer(invocation -> etched.get()); + when(router.openSavedChunkMantle(2, -1)).thenReturn(saved); + when(saved.openScope()).thenAnswer(invocation -> { + assertFalse(scoped.getAndSet(true)); + return scope; + }); + doAnswer(invocation -> { + assertTrue(scoped.getAndSet(false)); + return null; + }).when(scope).close(); + doAnswer(invocation -> { + releaseThread.set(Thread.currentThread()); + released.release(); + return null; + }).when(saved).close(); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(spawner.isEntitySpawningEnabledForCurrentWorld()).thenReturn(true); + when(manager.managedTask(anyString(), any(Runnable.class), any(Runnable.class))) + .thenAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + Runnable rejected = invocation.getArgument(2); + return (Runnable) () -> { + if (admitted.get()) { + task.run(); + } else { + rejected.run(); + } + }; + }); + IrisSettings settings = new IrisSettings(); + settings.getWorld().setPostLoadBlockUpdates(true); + configured.when(IrisSettings::get).thenReturn(settings); + scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { + async.add(invocation.getArgument(0)); + return null; + }); + scheduling.when(() -> J.runRegion(eq(world), eq(2), eq(-1), any(Runnable.class))) + .thenAnswer(invocation -> { + if (!acceptRegion.get()) { + return false; + } + regions.add(invocation.getArgument(3)); + return true; + }); + updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk, savedMantle)).thenAnswer(invocation -> { + assertTrue(scoped.get()); + assertSame(currentMantle, engine.getMantle().getMantle()); + etched.set(true); + return null; + }); + } + + private void schedule() { + maintenance.updateChunkRegion(world, 2, -1); + } + + private void prepare() { + async.remove().run(); + } + + private void apply() { + regions.remove().run(); + } + + private void awaitRelease() throws InterruptedException { + assertTrue("Saved mantle handle was not released", released.tryAcquire(5L, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (!pending.isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(1L); + } + assertTrue("Saved mantle cleanup did not finish", pending.isEmpty()); + } + + @Override + public void close() { + maintenance.close(); + logging.close(); + updates.close(); + scheduling.close(); + configured.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java index 74ba1191a..5dc5ac6b0 100644 --- a/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java @@ -73,7 +73,7 @@ public class WorldChunkMaintenanceSavedBiomeTest { try (MockedStatic configured = mockStatic(IrisSettings.class); MockedStatic updates = mockStatic(EngineBukkitOps.class)) { configured.when(IrisSettings::get).thenReturn(settings); - updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk)) + updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk, mantle)) .thenThrow(failure).thenAnswer(invocation -> null); if (loading) { diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuatorTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuatorTest.java index feaabecb7..96d362aa4 100644 --- a/core/src/test/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuatorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuatorTest.java @@ -4,6 +4,8 @@ import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.history.BoundaryColumnGeometry; import art.arcane.iris.engine.history.BoundaryGeometryInfluence; +import art.arcane.iris.engine.history.NativeTerrainReceipt; +import art.arcane.iris.engine.history.SavedTerrainChunk; import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -20,6 +22,8 @@ import java.util.ArrayList; import java.util.List; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; @@ -55,6 +59,34 @@ public class IrisTransitionGeometryActuatorTest { } } + @Test + public void boundaryReceiptRetainsCustomKeyAndCarrierPhysics() throws Exception { + String key = "itemsadder:rocks/ruby_ore"; + PlatformBlockState carrier = state("minecraft:oak_slab[type=bottom,waterlogged=true]", false); + when(carrier.isWaterLogged()).thenReturn(true); + PlatformBlockState custom = state(key, false); + when(custom.isCustom()).thenReturn(true); + when(custom.placementBaseState()).thenReturn(carrier); + Hunk blocks = filled(); + blocks.setRaw(0, 3, 0, custom); + Hunk biomes = Hunk.newArrayHunk(16, 16, 16); + PlatformBiome biome = mock(PlatformBiome.class); + when(biome.key()).thenReturn("minecraft:plains"); + biomes.fill(biome); + ChunkContext context = mock(ChunkContext.class); + when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3); + + SavedTerrainChunk captured = IrisTransitionGeometryActuator.capture(0, 0, blocks, biomes, 0, context, true); + byte[] encoded = NativeTerrainReceipt.encode(captured, 7, "epoch"); + SavedTerrainChunk restored = NativeTerrainReceipt.decode(encoded, "minecraft:noise").terrain(); + BoundaryColumnGeometry.Voxel voxel = restored.column(0, 0).geometry().voxelAt(3); + + assertEquals(key, voxel.stateKey()); + assertEquals(BoundaryColumnGeometry.Phase.SOLID, voxel.phase()); + assertEquals("minecraft:water[level=0]", voxel.fluidStateKey()); + assertTrue(voxel.protectedContent()); + } + @Test public void appliesCaveAndIslandGeometryThenUpdatesDecorationHeight() { Hunk blocks = filled(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java index 06c930109..93e4e3e4b 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -1,7 +1,9 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.IrisEngineMantle; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; @@ -17,6 +19,7 @@ import org.junit.Test; import org.mockito.MockedStatic; import org.junit.rules.TemporaryFolder; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -44,6 +47,7 @@ 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.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -180,6 +184,138 @@ public final class GenerationHistoryRuntimeRouterTest { router.close(); } + @Test + public void savedChunkMantlesShareRecordedStorageWithoutLoadingHistoricalRuntimes() throws Exception { + Path world = temporaryFolder.newFolder("router-saved-chunk-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-saved-chunk"); + int recordedChunks = history.explicitChunkCount(); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + AtomicReference scoped = installScopeTracking(engine); + EngineMantle activeEngineMantle = mock(EngineMantle.class); + Mantle activeMantle = mock(Mantle.class); + when(engine.getMantle()).thenReturn(activeEngineMantle); + when(activeEngineMantle.getMantle()).thenReturn(activeMantle); + Map dataByPack = new HashMap<>(); + Map> storageByPath = new HashMap<>(); + for (long activationId : List.of(1L, 2L)) { + dataByPack.put(history.packRoot(activationId).toFile(), mock(IrisData.class)); + storageByPath.put(history.paths().activationMantleRoot(activationId), mock(Mantle.class)); + } + + try (MockedStatic data = mockStatic(IrisData.class); + MockedStatic storage = mockStatic(IrisEngineMantle.class); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes)) { + data.when(() -> IrisData.openRuntime(any(File.class))) + .thenAnswer(invocation -> dataByPack.get(invocation.getArgument(0, File.class))); + storage.when(() -> IrisEngineMantle.createMantle(any(), any(), any())) + .thenAnswer(invocation -> storageByPath.get(invocation.getArgument(1, Path.class))); + for (int chunkX = 0; chunkX < 3; chunkX++) { + long activationId = chunkX + 1L; + Mantle expected = activationId == 3L ? activeMantle + : storageByPath.get(history.paths().activationMantleRoot(activationId)); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle first = router.openSavedChunkMantle(chunkX, 0)) { + assertSame(expected, first.mantle()); + assertNull(scoped.get()); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle second = router.openSavedChunkMantle(chunkX, 0)) { + assertSame(first.mantle(), second.mantle()); + } + verify(expected, never()).close(); + } + if (activationId != 3L) { + verify(expected).close(); + IrisData savedData = dataByPack.get(history.packRoot(activationId).toFile()); + verify(savedData).bindGenerationRegistryContract(history.resolveEpoch(chunkX, 0).registryContract()); + verify(savedData).registerEngine(engine); + verify(savedData).unregisterEngine(engine); + verify(savedData).close(); + } + try (GenerationHistoryRuntimeRouter.RuntimeStage stage = router.openStage(chunkX, 0)) { + assertEquals(3L, stage.activation().activationId()); + assertSame(active, scoped.get()); + } + assertTrue(history.semantics(chunkX, 0).isEmpty()); + } + storage.verify(() -> IrisEngineMantle.createMantle(any(), any(), any()), times(2)); + assertEquals(recordedChunks, history.explicitChunkCount()); + assertEquals(3L, history.activeActivation().activationId()); + assertEquals(1L, history.resolveActivation(0, 0).activationId()); + assertEquals(2L, history.resolveActivation(1, 0).activationId()); + assertEquals(Set.of(3L), runtimes.bindings.keySet()); + verify(activeMantle, never()).close(); + verify(engine, never()).setDefaultGenerationRuntime(any()); + } + } + + @Test + public void savedChunkMantleOpensAfterItsGeneratorKernelIsNoLongerAvailable() throws Exception { + Path world = temporaryFolder.newFolder("router-saved-old-kernel-world").toPath(); + Path pack = createPack("router-saved-old-kernel-pack", "alpha"); + GenerationKernelRegistry.Version oldVersion = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version currentVersion = new GenerationKernelRegistry.Version(2, 1, 1); + GenerationKernelRegistry kernels = kernels(currentVersion); + createHistory(world, pack, oldVersion, kernels); + writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), new int[][]{{0, 0}}); + GenerationHistory history = GenerationHistory.open(world, + new GenerationKernelRegistry(currentVersion, List.of(kernels.requireSupported(currentVersion)))); + history.stageCurrentKernel(256); + promoteWithSignatures(history); + assertEquals(oldVersion, history.resolveEpoch(0, 0).kernelVersion()); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + IrisData savedData = mock(IrisData.class); + Mantle savedStorage = mock(Mantle.class); + try (MockedStatic data = mockStatic(IrisData.class); + MockedStatic storage = mockStatic(IrisEngineMantle.class); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes)) { + data.when(() -> IrisData.openRuntime(history.packRoot(1L).toFile())).thenReturn(savedData); + storage.when(() -> IrisEngineMantle.createMantle(any(), any(), any())).thenReturn(savedStorage); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) { + assertSame(savedStorage, saved.mantle()); + } + verify(savedStorage).close(); + storage.verify(() -> IrisEngineMantle.createMantle( + any(), eq(history.paths().activationMantleRoot(1L)), any())); + assertEquals(Set.of(2L), runtimes.bindings.keySet()); + assertEquals(0, runtimes.loadCount(1L)); + verify(engine, never()).openGenerationRuntimeScope(any()); + } + } + + @Test + public void savedChunkScopeRejectsReentrantCloseAndCutoverAfterThreadHandoff() throws Exception { + Path world = temporaryFolder.newFolder("router-saved-scope-world").toPath(); + GenerationHistory history = createHistory(world, createPack("router-saved-scope-pack", "alpha")); + IrisEngine engine = mock(IrisEngine.class); + when(engine.isStudio()).thenReturn(true); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + AtomicReference scoped = installScopeTracking(engine); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mock(Mantle.class)); + try (GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes); + GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) { + saved.detachThread(); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle.Scope ignored = saved.openScope()) { + assertNull(scoped.get()); + assertThrows(IllegalStateException.class, router::close); + assertThrows(IllegalStateException.class, saved::close); + assertThrows(IllegalStateException.class, () -> router.beginStudioCutover(1L)); + assertThrows(IllegalStateException.class, + () -> engine.getGenerationSessions().transitionGate().beginTransition(1L)); + } + } + } + @Test public void historicalTerrainRoutesWorkWhenFrozenPackFixturesAreMissing() throws Exception { Path world = temporaryFolder.newFolder("router-archived-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java index 4bb70b4d4..d7012b1ca 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java @@ -89,6 +89,7 @@ public class BukkitChunkGeneratorCloseTest { private CloseFixture() throws Exception { setField(generator, "closeFuture", new AtomicReference>()); + setField(generator, "startupReady", CompletableFuture.completedFuture(null)); setField(generator, "engine", engine); setField(generator, "folder", mock(ReactiveFolder.class)); setField(generator, "populators", new KList()); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStartupContentTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStartupContentTest.java new file mode 100644 index 000000000..d86069b20 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStartupContentTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.VolmitPlugin; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.HeightMap; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.event.world.WorldInitEvent; +import org.bukkit.generator.WorldInfo; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +import static org.junit.Assert.assertEquals; +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.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BukkitChunkGeneratorStartupContentTest { + @Test(timeout = 1000L) + public void pendingContentRejectsGenerationWithoutWaitingOnTheServerThread() { + CompletableFuture ready = new CompletableFuture<>(); + + assertThrows(IllegalStateException.class, + () -> BukkitChunkGenerator.requireStartupContentReady(ready, true)); + assertFalse(ready.isDone()); + ready.complete(null); + assertThrows(IllegalStateException.class, + () -> BukkitChunkGenerator.requireStartupContentReady(ready, true)); + BukkitChunkGenerator.requireStartupContentReady(ready, false); + } + + @Test + public void failedContentNeverAdmitsGeneration() { + IllegalStateException failure = new IllegalStateException("Required provider is unavailable"); + CompletableFuture ready = CompletableFuture.failedFuture(failure); + + CompletionException rejected = assertThrows(CompletionException.class, + () -> BukkitChunkGenerator.requireStartupContentReady(ready, false)); + + assertSame(failure, rejected.getCause()); + } + + @Test + public void failedInitializationRejectsTerrainEvenAfterContentBecomesReady() throws Exception { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + IllegalStateException failure = new IllegalStateException("Generator injection failed"); + setField(generator, "startupContentReady", CompletableFuture.completedFuture(null)); + setField(generator, "startupReady", CompletableFuture.failedFuture(failure)); + + CompletionException rejected = assertThrows(CompletionException.class, + () -> generator.getBaseHeight(mock(WorldInfo.class), new Random(1L), 0, 0, HeightMap.WORLD_SURFACE)); + + assertSame(failure, rejected.getCause()); + verify(generator, never()).getTarget(); + } + + @Test + public void pendingWorldExposesItsStemTargetAndFailsReadinessOnProviderTimeout() throws Exception { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + IrisWorld irisWorld = mock(IrisWorld.class); + World world = mock(World.class); + EngineTarget target = mock(EngineTarget.class); + IrisData data = mock(IrisData.class); + VolmitPlugin plugin = mock(VolmitPlugin.class); + CompletableFuture contentReady = new CompletableFuture<>(); + List tasks = new ArrayList<>(); + when(irisWorld.identity()).thenReturn("iris:test"); + when(irisWorld.name()).thenReturn("world_iris_test"); + when(world.getGenerator()).thenReturn(generator); + when(target.getData()).thenReturn(data); + setField(generator, "world", irisWorld); + setField(generator, "setup", new AtomicBoolean()); + setField(generator, "lock", new ReentrantLock()); + setField(generator, "spawnChunks", new CompletableFuture()); + setField(generator, "initialSpawnReady", new CompletableFuture()); + generator.deferStartup(target, contentReady); + + assertSame(target, generator.getTarget()); + generator.touch(world); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic platform = mockStatic(BukkitPlatform.class); + MockedStatic identity = mockStatic(WorldIdentity.class); + MockedStatic logging = mockStatic(IrisLogging.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin); + identity.when(() -> WorldIdentity.key(world)).thenReturn(NamespacedKey.fromString("iris:test")); + scheduling.when(() -> J.s(any(Runnable.class))).thenAnswer(invocation -> { + tasks.add(invocation.getArgument(0, Runnable.class)); + return null; + }); + + generator.onWorldInit(new WorldInitEvent(world)); + assertTrue(tasks.isEmpty()); + TimeoutException failure = new TimeoutException("Provider startup expired"); + contentReady.completeExceptionally(failure); + assertFalse(generator.getStartupReady().isDone()); + assertEquals(1, tasks.size()); + tasks.removeFirst().run(); + + assertTrue(generator.getStartupReady().isCompletedExceptionally()); + assertTrue(generator.getInitialSpawnReady().isCompletedExceptionally()); + assertSame(failure, generator.getInitializationFailure()); + generator.touch(world); + verify(data).close(); + bukkit.verify(Bukkit::shutdown); + } + } + + private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java index 9296d87ba..60825f792 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java @@ -19,6 +19,7 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -202,6 +203,8 @@ public class BukkitChunkGeneratorStudioSelectionTest { field(generator, "studio", true); field(generator, "world", world); field(generator, "setup", setup); + field(generator, "startupContentReady", CompletableFuture.completedFuture(null)); + field(generator, "startupReady", CompletableFuture.completedFuture(null)); field(generator, "lock", new ReentrantLock()); field(generator, "targetCache", new AtomicCache()); field(generator, "populators", new KList<>()); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java index d3e58ed2d..4c31403d5 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.engine.platform; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.IrisEngineMantle; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineMetrics; @@ -9,12 +10,15 @@ import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest; import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleHooks; @@ -23,10 +27,12 @@ import art.arcane.volmlib.util.matter.MatterUpdate; import art.arcane.volmlib.util.matter.slices.UpdateMatter; import org.bukkit.Chunk; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; import org.junit.Test; import org.mockito.InOrder; +import org.mockito.MockedStatic; import java.util.ArrayList; import java.util.List; @@ -41,16 +47,58 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class EngineBukkitOpsDeferredMaterializationTest { + @Test + @SuppressWarnings("unchecked") + public void savedMantlePlacementUsesCurrentProviderWithoutReadingCurrentMantle() { + Mantle saved = mock(Mantle.class); + MantleDataAdapter adapter = mock(MantleDataAdapter.class); + MantleChunk stored = spy(new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE)); + Identifier identifier = Identifier.fromString("itemsadder:forest/amber_ore"); + doAnswer(invocation -> { + Consumer4 iterator = invocation.getArgument(1); + iterator.accept(1, 2, 3, identifier); + return null; + }).when(stored).iterate(eq(Identifier.class), any()); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + World world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(world.getMaxHeight()).thenReturn(16); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + Block block = mock(Block.class); + when(chunk.getBlock(1, 2, 3)).thenReturn(block); + when(saved.isLoaded(chunk)).thenReturn(true); + when(saved.getChunk(chunk)).thenReturn(stored); + ExternalDataSVC provider = mock(ExternalDataSVC.class); + + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic services = mockStatic(IrisServices.class)) { + scheduling.when(J::isPrimaryThread).thenReturn(true); + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider); + + EngineBukkitOps.updateChunk(engine, chunk, saved); + } + + verify(provider).processUpdate(engine, block, identifier); + verify(engine, never()).getMantle(); + verify(stored).deleteSlices(Identifier.class); + assertTrue(stored.isFlagged(MantleFlag.ETCHED)); + } + @Test(timeout = 5000) public void loadingRetryPreservesPendingUpdatesWithoutRepeatingCompletedUpdates() { MantleChunk mantleChunk = pendingUpdates(); diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java index 939f33eed..7e403680c 100644 --- a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java @@ -18,11 +18,14 @@ package art.arcane.iris.platform.bukkit; +import art.arcane.iris.core.link.Identifier; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockProperty; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.Server; @@ -34,6 +37,10 @@ import org.junit.BeforeClass; import org.junit.Test; import org.mockito.invocation.InvocationOnMock; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -120,6 +127,39 @@ public class BukkitSpiConformanceTest { assertEquals("iristest:key_block[facing=north,lit=true]", BukkitBlockState.of(data).key()); } + @Test + public void customStateKeepsProviderIdentityAndProperties() throws Exception { + BlockData base = blockData("minecraft:note_block[instrument=harp,note=5,powered=false]"); + Identifier identifier = Identifier.fromString("craftengine:forest/amber_log[axis=x]"); + PlatformBlockState state = BukkitBlockState.of(IrisCustomData.of(base, identifier)); + + assertEquals(identifier.toString(), state.key()); + assertEquals(identifier.toString(), state.deferredPlacementKey()); + assertEquals("craftengine", state.namespace()); + assertEquals("craftengine:forest/amber_log", state.materialKey()); + assertEquals(base.getAsString(), state.placementBaseState().key()); + assertTrue(state.isCustom()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + new PlatformBlockMatter().writeNode(state, new DataOutputStream(bytes)); + assertEquals(identifier.toString(), new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())).readUTF()); + } + + @Test + public void customCarrierPropertyChangeKeepsProviderPropertiesSeparate() { + BlockData base = blockData("minecraft:oak_slab[type=bottom,waterlogged=false]"); + Identifier identifier = Identifier.fromString("itemsadder:forest/amber_slab"); + PlatformBlockState state = BukkitBlockState.of(IrisCustomData.of(base, identifier)); + + PlatformBlockState merged = state.withProperty("waterlogged", "true"); + + assertEquals(identifier.toString(), merged.key()); + assertEquals(identifier.toString(), merged.deferredPlacementKey()); + assertEquals("minecraft:oak_slab[type=bottom,waterlogged=true]", merged.placementBaseState().key()); + assertEquals("minecraft:oak_slab[type=bottom,waterlogged=false]", state.placementBaseState().key()); + assertTrue(merged.isCustom()); + } + @Test public void blockStateNamespaceParsesWithAndWithoutProperties() { BlockData withProps = blockData("iristest:ns_block[axis=y]"); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edeb4b5cc..137ed519e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ jaxen = "2.0.6" # https://central.sonatype.com/artifact/jaxen/jaxen # Third Party Integrations nexo = "1.25.0" # https://repo.nexomc.com/#/releases/com/nexomc/nexo +oraxen = "1.218.0" itemsadder = "4.0.10" # https://github.com/LoneDev6/API-ItemsAdder placeholderApi = "2.11.6" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi score = "5.25.3.9" # https://github.com/Ssomar-Developement/SCore @@ -95,6 +96,7 @@ jaxen = { module = "jaxen:jaxen", version.ref = "jaxen" } # Third Party Integrations nexo = { module = "com.nexomc:nexo", version.ref = "nexo" } +oraxen = { module = "io.th0rgal:oraxen", version.ref = "oraxen" } itemsadder = { module = "dev.lone:api-itemsadder", version.ref = "itemsadder" } placeholderApi = { module = "me.clip:placeholderapi", version.ref = "placeholderApi" } score = { module = "com.github.Ssomar-Developement:SCore", version.ref = "score" }