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 3c268dae5..651bc31ba 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 @@ -990,6 +990,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onDisable() { + preservePendingBlockDrops(); papiLifecycleActive = false; if (updateNotifier != null) { updateNotifier.close(); @@ -1034,6 +1035,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { @Override public CompletionStage commitReload(ReloadAware.PreUnloadReason reason) { + preservePendingBlockDrops(); papiLifecycleActive = false; teardownPapi(); if (IrisToolbelt.isServerStopping()) { @@ -1283,6 +1285,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { && preservation.hasActiveResources(); } + private void preservePendingBlockDrops() { + for (World world : Bukkit.getWorlds()) { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (generator != null && generator.getEngine() != null + && generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) { + manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> { + if (failure != null) { + Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure); + } + }); + } + } + } + private void drainWorldGenerators(String reason, long timeoutSeconds) { List irisWorlds = new ArrayList<>(); List generators = new ArrayList<>(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/RoutedBlockBreakEvent.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/RoutedBlockBreakEvent.java index b720092bd..14bf3c79e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/RoutedBlockBreakEvent.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/RoutedBlockBreakEvent.java @@ -3,19 +3,33 @@ package art.arcane.iris.world.tree; import org.bukkit.block.Block; import org.bukkit.entity.Player; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.inventory.ItemStack; + +import java.util.Collection; +import java.util.Objects; final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter { private final TreeFellerSVC service; private final FellingRun run; + private final ItemStack tool; - RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) { - super(block, player); - this.run = run; - this.service = service; + RoutedBlockBreakEvent(Options options) { + super(options.block(), options.player()); + run = options.run(); + service = options.service(); + tool = Objects.requireNonNull(options.tool(), "tool").clone(); + } + + @Override + public Collection captureNativeDrops() { + return getBlock().getDrops(tool); } @Override public boolean routeDrop(Object drop) { return service.isServiceEnabled() && run.presentation.routeDrop(drop); } + + record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) { + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/TreeFellingRunner.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/TreeFellingRunner.java index c0c0ddbce..7d447a613 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/TreeFellingRunner.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/tree/TreeFellingRunner.java @@ -387,7 +387,8 @@ final class TreeFellingRunner { return; } - BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service); + BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options( + block, run.candidate.player(), run, service, reservation.toolForDrops())); service.managedEvents.add(probe); try { Bukkit.getPluginManager().callEvent(probe); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/tree/RoutedBlockBreakEventTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/tree/RoutedBlockBreakEventTest.java new file mode 100644 index 000000000..459e92e41 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/tree/RoutedBlockBreakEventTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.world.tree; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.Test; + +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class RoutedBlockBreakEventTest { + @Test + public void nativeLootUsesTheReservedToolCloneWithoutReadingPlayerInventory() { + Block block = mock(Block.class); + Player player = mock(Player.class); + ItemStack reservedTool = mock(ItemStack.class); + ItemStack capturedTool = mock(ItemStack.class); + ItemStack drop = mock(ItemStack.class); + Collection expected = List.of(drop); + when(reservedTool.clone()).thenReturn(capturedTool); + when(block.getDrops(capturedTool)).thenReturn(expected); + RoutedBlockBreakEvent event = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options( + block, player, mock(FellingRun.class), mock(TreeFellerSVC.class), reservedTool)); + + reservedTool.setType(Material.AIR); + + assertSame(expected, event.captureNativeDrops()); + verify(reservedTool).clone(); + verify(block).getDrops(capturedTool); + verify(block, never()).getDrops(reservedTool); + verifyNoInteractions(player); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java index c2cef4e7f..09cc601cd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -31,6 +31,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockDropHooks; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.structure.placement.LootResolver; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.biome.IrisBiome; @@ -181,26 +182,30 @@ public final class ModdedBlockBreakHandler { NativeBlockState brokenState, Engine engine ) { - KList providers = providers(engine, position, brokenState); - if (providers.isEmpty()) { - return Result.empty(); - } + BiomeEnvironment environment = engine.getBiomeEnvironment(position.x(), + position.y() - engine.getMinHeight(), position.z()); + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + KList providers = providers(environment, brokenState); + if (providers.isEmpty()) { + return Result.empty(); + } - KList drops = new KList<>(); - boolean replaceVanillaDrops = false; - for (IrisBlockDrops provider : providers) { - replaceVanillaDrops |= provider.isReplaceVanillaDrops(); - for (IrisLoot loot : provider.getDrops()) { - if (!LootResolver.oneIn(RNG.r, loot.getRarity())) { - continue; - } - NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level)); - if (stack != null && !stack.isEmpty()) { - drops.add(stack); + KList drops = new KList<>(); + boolean replaceVanillaDrops = false; + for (IrisBlockDrops provider : providers) { + replaceVanillaDrops |= provider.isReplaceVanillaDrops(); + for (IrisLoot loot : provider.getDrops()) { + if (!LootResolver.oneIn(RNG.r, loot.getRarity())) { + continue; + } + NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level)); + if (stack != null && !stack.isEmpty()) { + drops.add(stack); + } } } + return new Result(drops, replaceVanillaDrops, null); } - return new Result(drops, replaceVanillaDrops, null); } private static void removeMarker(Engine engine, NativeBlockPoint position) { @@ -225,22 +230,17 @@ public final class ModdedBlockBreakHandler { engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class); } - private static KList providers(Engine engine, NativeBlockPoint position, NativeBlockState brokenState) { + private static KList providers(BiomeEnvironment environment, NativeBlockState brokenState) { KList providers = new KList<>(); - IrisData data = engine.getData(); - int relativeY = position.y() - engine.getMinHeight(); - IrisBiome biome = engine.getBiome(position.x(), relativeY, position.z()); - if (biome != null) { - addMatching(providers, biome.getBlockDrops(), brokenState, data); - } + IrisData data = environment.data(); + IrisBiome biome = environment.biome(); + addMatching(providers, biome.getBlockDrops(), brokenState, data); if (skipsParents(providers)) { return providers; } - IrisRegion region = engine.getRegion(position.x(), relativeY, position.z()); - if (region != null) { - addMatching(providers, region.getBlockDrops(), brokenState, data); - } - addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data); + IrisRegion region = environment.region(); + addMatching(providers, region.getBlockDrops(), brokenState, data); + addMatching(providers, environment.dimension().getBlockDrops(), brokenState, data); return providers; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java index 4f618941c..fe18042bd 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java @@ -18,6 +18,21 @@ package art.arcane.iris.modded; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.world.loot.IrisBlockDrops; +import art.arcane.iris.world.loot.IrisLoot; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeItemStack; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.Direction; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import net.minecraft.world.level.block.Blocks; @@ -27,9 +42,22 @@ import org.junit.Test; import org.junit.BeforeClass; import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; +import org.mockito.MockedStatic; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +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.eq; +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 ModdedBlockBreakHandlerTest { @BeforeClass @@ -48,4 +76,97 @@ public class ModdedBlockBreakHandlerTest { assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false)); assertFalse(ModdedBlockBreakHandler.matchesState(north, ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null), false)); } + + @Test + public void historicalEnvironmentSuppliesAllRulesAndScopesMatchingAndLoot() { + try (DropFixture fixture = new DropFixture()) { + fixture.dimensionRule.setReplaceVanillaDrops(true); + + ModdedBlockBreakHandler.Result result = fixture.evaluate(); + + assertTrue(result.replaceVanillaDrops()); + assertEquals(3, result.drops().size()); + assertSame(fixture.item, result.drops().getFirst()); + assertFalse(fixture.scoped.get()); + verify(fixture.engine).getBiomeEnvironment(16, 136, 32); + verify(fixture.engine).openBiomeEnvironmentScope(fixture.environment); + verify(fixture.engine, never()).getData(); + verify(fixture.engine, never()).getDimension(); + verify(fixture.engine, never()).getBiome(anyInt(), anyInt(), anyInt()); + verify(fixture.engine, never()).getRegion(anyInt(), anyInt(), anyInt()); + } + } + + @Test + public void historicalBiomeCanSkipRegionAndDimensionDrops() { + try (DropFixture fixture = new DropFixture()) { + fixture.biomeRule.setSkipParents(true); + fixture.dimensionRule.setReplaceVanillaDrops(true); + + ModdedBlockBreakHandler.Result result = fixture.evaluate(); + + assertFalse(result.replaceVanillaDrops()); + assertEquals(1, result.drops().size()); + assertFalse(fixture.scoped.get()); + verify(fixture.region, never()).getBlockDrops(); + verify(fixture.dimension, never()).getBlockDrops(); + } + } + + private static final class DropFixture implements AutoCloseable { + private final NativeWorld world = mock(NativeWorld.class); + private final Engine engine = mock(Engine.class); + private final IrisBiome biome = mock(IrisBiome.class); + private final IrisRegion region = mock(IrisRegion.class); + private final IrisDimension dimension = mock(IrisDimension.class); + private final IrisData data = mock(IrisData.class); + private final BiomeEnvironment environment = new BiomeEnvironment(7L, biome, region, dimension, data); + private final ModdedBlockState state = ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null); + private final NativeItemStack item = mock(NativeItemStack.class); + private final IrisLoot loot = new IrisLoot().setRarity(1); + private final IrisBlockData configuredBlock = mock(IrisBlockData.class); + private final IrisBlockDrops biomeRule = rule(); + private final IrisBlockDrops regionRule = rule(); + private final IrisBlockDrops dimensionRule = rule(); + private final AtomicBoolean scoped = new AtomicBoolean(); + private final MockedStatic generators = mockStatic(NativeWorldGenerators.class); + private final MockedStatic translator = mockStatic(ModdedItemTranslator.class); + + private DropFixture() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + generators.when(() -> NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class)).thenReturn(generator); + when(generator.engineIfBound()).thenReturn(engine); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getBiomeEnvironment(16, 136, 32)).thenReturn(environment); + when(engine.openBiomeEnvironmentScope(environment)).thenAnswer(invocation -> { + assertTrue(scoped.compareAndSet(false, true)); + return (BiomeEnvironment.Scope) () -> scoped.set(false); + }); + when(biome.getBlockDrops()).thenReturn(new KList<>(biomeRule)); + when(region.getBlockDrops()).thenReturn(new KList<>(regionRule)); + when(dimension.getBlockDrops()).thenReturn(new KList<>(dimensionRule)); + when(configuredBlock.getBlockData(data)).thenAnswer(invocation -> { + assertTrue(scoped.get()); + return state; + }); + translator.when(() -> ModdedItemTranslator.stack(eq(loot), eq(RNG.r), any())).thenAnswer(invocation -> { + assertTrue(scoped.get()); + return item; + }); + } + + private IrisBlockDrops rule() { + return new IrisBlockDrops().setBlocks(new KList<>(configuredBlock)).setDrops(new KList<>(loot)); + } + + private ModdedBlockBreakHandler.Result evaluate() { + return ModdedBlockBreakHandler.evaluateManagedDrops(world, new NativeBlockPoint(16, 72, 32), state); + } + + @Override + public void close() { + translator.close(); + generators.close(); + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 84f9ff45d..3074fbe9f 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -539,9 +539,20 @@ public class IrisEngine implements Engine { if (saved.isPresent()) { return saved.get(); } - try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = - openGenerationHistoryReadScope(x, z, "resolve a biome environment")) { - return Engine.super.getBiomeEnvironment(x, y, z); + return resolveUnsavedBiomeEnvironment(x, y, z); + } + + public CompletableFuture getBiomeEnvironmentAsync(int x, int y, int z) { + try { + if (!usesSavedBiomeEnvironment()) { + return CompletableFuture.completedFuture(getBiomeEnvironment(x, y, z)); + } + int worldY = y + getWorld().minHeight(); + return generationHistoryRuntimeRouter.biomes().readChunkAsync(Math.floorDiv(x, 16), Math.floorDiv(z, 16)) + .thenApply(session -> session.resolve(x, worldY, z, false) + .orElseGet(() -> resolveUnsavedBiomeEnvironment(x, y, z))); + } catch (RuntimeException | Error failure) { + return CompletableFuture.failedFuture(failure); } } @@ -577,6 +588,13 @@ public class IrisEngine implements Engine { return binding; } + private BiomeEnvironment resolveUnsavedBiomeEnvironment(int x, int y, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryReadScope(x, z, "resolve a biome environment")) { + return Engine.super.getBiomeEnvironment(x, y, z); + } + } + private Optional resolveSavedBiomeEnvironment(int x, int y, int z, boolean surface) { if (!usesSavedBiomeEnvironment()) { return Optional.empty(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java index 49944165a..c173fbcc0 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java @@ -38,11 +38,15 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import org.bukkit.Chunk; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockDropItemEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerTeleportEvent; import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -267,24 +271,38 @@ public class IrisWorldManager extends EngineAssignedWorldManager { blockDropRouter.onBlockBreak(e); } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBlockDropItems(BlockDropItemEvent event) { + runManagerTask("bukkit_world_manager_block_drops", () -> blockDropRouter.onBlockDropItems(event)); + } + @Override public void onBlockPlace(BlockPlaceEvent e) { blockDropRouter.onBlockPlace(e); } + public CompletableFuture flushPendingBlockDropsAsync() { + return blockDropRouter.flushAsync(); + } + @Override public synchronized void close() { Throwable failure = null; try { chunkMaintenance.close(); } catch (Throwable e) { - failure = e; + failure = appendCloseFailure(failure, e); } try { super.close(); } catch (Throwable e) { failure = appendCloseFailure(failure, e); } + try { + blockDropRouter.close(); + } catch (Throwable e) { + failure = appendCloseFailure(failure, e); + } if (!looperStopped) { try { if (looper != null) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java index 9cf289a40..cfcb9455b 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java @@ -20,13 +20,8 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.world.tree.BlockDropRouter; -import art.arcane.iris.world.history.SavedBiomeUnavailableException; -import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; -import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.world.loot.IrisBlockDrops; import art.arcane.iris.world.entity.IrisMarker; -import art.arcane.iris.generation.terrain.IrisRegion; -import art.arcane.iris.platform.generation.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; @@ -35,81 +30,140 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.matter.MatterMarker; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Item; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockDropItemEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.inventory.ItemStack; +import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; /** - * Biome, region and dimension block drops for a Bukkit Iris world. The drop list is resolved on the - * event thread, the marker cleanup runs asynchronously and the drops themselves are handed to the - * event's own router when it offers one so a deferred break keeps its inventory destination. + * Biome, region and dimension block drops for a Bukkit Iris world. Native loot is retained while + * saved biome definitions load, then finalized on the block's owning thread after the break. */ final class WorldBlockDropRouter { private final IrisWorldManager manager; + private final Set pending = ConcurrentHashMap.newKeySet(); + private boolean closed; WorldBlockDropRouter(IrisWorldManager manager) { this.manager = manager; } - void onBlockBreak(BlockBreakEvent e) { - if (e.isCancelled()) { + void onBlockBreak(BlockBreakEvent event) { + if (event.isCancelled() || !isManagedWorld(event.getBlock().getWorld())) { 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(); - - List dropProviders; - try { - dropProviders = resolveDropProviders(e); - } catch (SavedBiomeUnavailableException unavailable) { - e.setCancelled(true); - if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) { - throw unavailable; - } + Location location = event.getBlock().getLocation(); + int mantleY = toMantleY(location.getBlockY(), manager.getEngine().getWorld().minHeight()); + Runnable finalizedBreak = manager.managedTask("bukkit_world_manager_block_break_finalize", () -> { + if (event.isCancelled()) { return; } - - KList d = new KList<>(); - dropProviders.forEach(provider -> provider.fillDrops(false, d)); - - if (dropProviders.stream().anyMatch(IrisBlockDrops::isReplaceVanillaDrops)) { - e.setDropItems(false); - } - - World w = e.getBlock().getWorld(); - Location blockLocation = e.getBlock().getLocation(); - Location dropLocation = blockLocation.clone().add(.5, .5, .5); - BlockDropRouter dropRouter = e instanceof BlockDropRouter router ? router : null; - Runnable finalizedBreak = manager.managedTask("bukkit_world_manager_block_break_finalize", () -> { - if (e.isCancelled()) { + J.a(manager.managedTask("bukkit_world_manager_block_break_marker", () -> { + MatterMarker marker = manager.getMantle().get(location.getBlockX(), mantleY, location.getBlockZ(), MatterMarker.class); + if (marker == null || marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { return; } - J.a(manager.managedTask("bukkit_world_manager_block_break_marker", () -> { - MatterMarker marker = manager.getMantle().get(blockX, mantleY, blockZ, MatterMarker.class); - if (marker == null || marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { - return; - } - - IrisMarker mark = manager.getData().getMarkerLoader().load(marker.getTag()); - if (mark == null || mark.isRemoveOnChange()) { - manager.getMantle().remove(blockX, mantleY, blockZ, MatterMarker.class); - } - })); - routeDrops(d, dropRouter, item -> w.dropItemNaturally(dropLocation, item)); - }); - if (!J.runAt(blockLocation, finalizedBreak, 1) && !J.isFolia()) { - J.s(finalizedBreak, 1); + IrisMarker mark = manager.getData().getMarkerLoader().load(marker.getTag()); + if (mark == null || mark.isRemoveOnChange()) { + manager.getMantle().remove(location.getBlockX(), mantleY, location.getBlockZ(), MatterMarker.class); + } + })); + }); + schedule(location, finalizedBreak); + if (!(event instanceof BlockDropRouter router) || !event.isDropItems()) { + return; + } + synchronized (pending) { + if (closed) { + return; + } + BlockData state = event.getBlock().getBlockData().clone(); + if (!mayHaveBlockDropRules(state)) { + return; + } + Location dropLocation = location.clone().add(.5, .5, .5); + List originals = new ArrayList<>(); + for (Object nativeDrop : router.captureNativeDrops()) { + if (nativeDrop instanceof ItemStack stack) { + originals.add(new NativeDrop(dropLocation, stack.clone())); + } + } + CompletableFuture environment = environment(location); + if (defer(new DeferredDrops(location, state, router, () -> !event.isCancelled(), originals, environment, new AtomicBoolean()))) { + event.setDropItems(false); } } } - void onBlockPlace(BlockPlaceEvent e) { + void onBlockDropItems(BlockDropItemEvent event) { + if (event.isCancelled() || !isManagedWorld(event.getBlock().getWorld())) { + return; + } + synchronized (pending) { + if (closed) { + return; + } + BlockData state = event.getBlockState().getBlockData().clone(); + if (!mayHaveBlockDropRules(state)) { + return; + } + Location location = event.getBlockState().getLocation(); + CompletableFuture environment = environment(location); + List originals = new ArrayList<>(event.getItems().size()); + for (Item item : event.getItems()) { + originals.add(new NativeDrop(location.clone().add(.5, .5, .5), item.getItemStack().clone())); + } + if (defer(new DeferredDrops(location, state, null, () -> !event.isCancelled(), originals, environment, new AtomicBoolean()))) { + event.getItems().clear(); + } + } + } + void onBlockPlace(BlockPlaceEvent event) { + + } + + CompletableFuture flushAsync() { + List outstanding; + synchronized (pending) { + outstanding = List.copyOf(pending); + } + List> restores = new ArrayList<>(outstanding.size()); + for (DeferredDrops drops : outstanding) { + Location location = drops.location; + restores.add(J.runRegionFuture(location.getWorld(), location.getBlockX() >> 4, location.getBlockZ() >> 4, + () -> finish(drops, null, null))); + } + return CompletableFuture.allOf(restores.toArray(CompletableFuture[]::new)); + } + + void close() { + synchronized (pending) { + closed = true; + } + try { + flushAsync().get(5L, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while preserving pending Iris block loot.", interrupted); + } catch (ExecutionException | TimeoutException failure) { + throw new IllegalStateException("Unable to preserve pending Iris block loot before world shutdown.", failure); + } } static int toMantleY(int worldY, int minHeight) { @@ -136,32 +190,137 @@ final class WorldBlockDropRouter { return mantleY + minHeight; } - private List filterDrops(KList drops, BlockBreakEvent e, IrisData data) { - return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList()); + private boolean isManagedWorld(World world) { + return world.equals(BukkitWorldBinding.world(manager.getTarget().getWorld())); } - private List resolveDropProviders(BlockBreakEvent event) { - if (!mayHaveBlockDropRules(BukkitBlockState.of(event.getBlock().getBlockData()).materialKey())) { - return List.of(); + private CompletableFuture environment(Location location) { + Engine engine = manager.getEngine(); + int relativeY = toMantleY(location.getBlockY(), engine.getWorld().minHeight()); + if (engine instanceof IrisEngine iris) { + return iris.getBiomeEnvironmentAsync(location.getBlockX(), relativeY, location.getBlockZ()); } - IrisBiome biome = EngineBukkitOps.getBiome(manager.getEngine(), event.getBlock().getLocation()); - List providers = filterDrops(biome.getBlockDrops(), event, manager.getData()); + try { + return CompletableFuture.completedFuture(engine.getBiomeEnvironment(location.getBlockX(), relativeY, location.getBlockZ())); + } catch (RuntimeException failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private boolean defer(DeferredDrops drops) { + pending.add(drops); + if (J.runAt(drops.location, () -> drops.environment.orTimeout(5L, TimeUnit.SECONDS).whenComplete((environment, failure) -> { + if (drops.finished.get()) { + return; + } + J.runRegionFuture(drops.location.getWorld(), drops.location.getBlockX() >> 4, drops.location.getBlockZ() >> 4, + () -> finish(drops, environment, failure)).whenComplete((ignored, schedulingFailure) -> { + if (schedulingFailure != null) { + IrisLogging.reportError("Unable to finalize pending Iris block loot on its owning thread.", schedulingFailure); + } + }); + }), 1)) { + return true; + } + pending.remove(drops); + IrisLogging.reportError("Unable to schedule Iris block loot. Preserving normal block loot.", + new IllegalStateException("The owning region did not accept the block-drop task.")); + return false; + } + + private void schedule(Location location, Runnable task) { + if (!J.runAt(location, task, 1) && !J.isFolia()) { + J.s(task, 1); + } + } + + private void finish(DeferredDrops drops, BiomeEnvironment environment, Throwable failure) { + if (!drops.finished.compareAndSet(false, true)) { + return; + } + pending.remove(drops); + if (!drops.allowed.getAsBoolean()) { + return; + } + KList custom = new KList<>(); + boolean replaceVanilla = false; + if (failure == null && environment != null) { + try { + DropResult result = EngineLifecycleTasks.call(manager.getEngine(), "bukkit_world_manager_block_drop_roll", + () -> rollDrops(drops.state, environment), null); + if (result != null) { + custom = result.items(); + replaceVanilla = result.replaceVanilla(); + } + } catch (Throwable error) { + failure = error; + } + } + if (failure != null) { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; + String context = "Unable to resolve Iris block drops at " + drops.location + ". Preserving normal block loot."; + if (IrisLogging.warnOnce("block-drops:" + drops.location.getWorld().getUID() + ":" + cause.getMessage(), context)) { + IrisLogging.reportError(context, cause); + } + } + if (!replaceVanilla) { + for (NativeDrop original : drops.originals) { + routeDrops(List.of(original.stack()), drops.router, stack -> original.location().getWorld().dropItemNaturally(original.location(), stack)); + } + } + Location location = drops.location.clone().add(.5, .5, .5); + routeDrops(custom, drops.router, stack -> location.getWorld().dropItemNaturally(location, stack)); + } + + private DropResult rollDrops(BlockData state, BiomeEnvironment environment) { + try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(environment)) { + KList items = new KList<>(); + boolean replaceVanilla = false; + for (IrisBlockDrops provider : resolveDropProviders(state, environment)) { + provider.fillDrops(false, items); + replaceVanilla |= provider.isReplaceVanillaDrops(); + } + return new DropResult(items, replaceVanilla); + } + } + + private List resolveDropProviders(BlockData state, BiomeEnvironment environment) { + List providers = new ArrayList<>(); + addMatching(providers, environment.biome().getBlockDrops(), state, environment.data()); 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())); + addMatching(providers, environment.region().getBlockDrops(), state, environment.data()); + addMatching(providers, environment.dimension().getBlockDrops(), state, environment.data()); } return providers; } - private boolean mayHaveBlockDropRules(String material) { + private void addMatching(List providers, KList candidates, BlockData state, IrisData data) { + for (IrisBlockDrops candidate : candidates) { + if (candidate.shouldDropFor(state, data)) { + providers.add(candidate); + } + } + } + + private boolean mayHaveBlockDropRules(BlockData state) { + String material = BukkitBlockState.of(state).materialKey(); if (manager.getData().hasBlockDropRules(material)) { return true; } if (manager.getEngine() instanceof IrisEngine engine) { - GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElse(null); - return router != null && router.biomes().mayHaveBlockDropRules(material); + return engine.getGenerationHistoryRuntimeRouter().map(router -> router.biomes().mayHaveBlockDropRules(material)).orElse(false); } return false; } + + private record DropResult(KList items, boolean replaceVanilla) { + } + + private record NativeDrop(Location location, ItemStack stack) { + } + + private record DeferredDrops(Location location, BlockData state, BlockDropRouter router, BooleanSupplier allowed, + List originals, CompletableFuture environment, + AtomicBoolean finished) { + } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java index ca8463da6..db13d866d 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java @@ -44,14 +44,15 @@ import art.arcane.iris.generation.biome.IrisBiomeCustom; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; +import art.arcane.volmlib.util.io.AtomicFileIO; import art.arcane.iris.platform.bootstrap.ServerProperties; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.world.task.J; import lombok.NonNull; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; +import org.bukkit.Server; import org.bukkit.configuration.InvalidConfigurationException; -import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.jetbrains.annotations.Nullable; @@ -99,11 +100,17 @@ public class ServerConfigurator { } IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); if (s.isConfigureSpigotTimeoutTime()) { - J.attempt(ServerConfigurator::increaseKeepAliveSpigot); + Throwable failure = J.attemptCatch(ServerConfigurator::increaseKeepAliveSpigot); + if (failure != null) { + IrisLogging.reportError("Unable to persist the Spigot timeout setting.", failure); + } } - if (s.isConfigurePaperWatchdogDelay()) { - J.attempt(ServerConfigurator::increasePaperWatchdog); + if (s.isConfigurePaperWatchdogDelay() && BukkitPlatform.isPaperServer()) { + Throwable failure = J.attemptCatch(ServerConfigurator::increasePaperWatchdog); + if (failure != null) { + IrisLogging.reportError("Unable to persist the Paper watchdog setting.", failure); + } } if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) { @@ -230,35 +237,61 @@ public class ServerConfigurator { } } - private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException { - File spigotConfig = new File("spigot.yml"); - FileConfiguration f = new YamlConfiguration(); - f.load(spigotConfig); - long tt = f.getLong("settings.timeout-time"); + private static void increaseKeepAliveSpigot() throws IOException, ReflectiveOperationException { + increaseKeepAliveSpigot(serverConfigurationOption("spigot-settings"), Bukkit.spigot().getConfig()); + } - long spigotTimeout = TimeUnit.MINUTES.toSeconds(5); + static void increaseKeepAliveSpigot(Path file, YamlConfiguration configuration) throws IOException { + long previous = configuration.getLong("settings.timeout-time"); + long minimum = TimeUnit.MINUTES.toSeconds(5); + if (previous >= minimum) { + return; + } + persistTimeout(file, configuration, "settings.timeout-time", minimum); + IrisLogging.warn("Updated " + file + " settings.timeout-time: " + previous + " -> " + minimum + + " seconds. Takes effect after a server restart."); + IrisLogging.warn("Disable autoConfiguration.configureSpigotTimeoutTime in iris.json to manage this setting yourself."); + } - if (tt < spigotTimeout) { - IrisLogging.warn("Updating spigot.yml timeout-time: " + tt + " -> " + spigotTimeout + " (5 minutes)"); - IrisLogging.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value."); - f.set("settings.timeout-time", spigotTimeout); - f.save(spigotConfig); + private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException, ReflectiveOperationException { + increasePaperWatchdog(serverConfigurationOption("paper-settings-directory").resolve("paper-global.yml")); + } + + static void increasePaperWatchdog(Path file) throws IOException, InvalidConfigurationException { + YamlConfiguration configuration = new YamlConfiguration(); + configuration.load(file.toFile()); + long previous = configuration.getLong("watchdog.early-warning-delay"); + long minimum = TimeUnit.MINUTES.toMillis(3); + if (previous >= minimum) { + return; + } + persistTimeout(file, configuration, "watchdog.early-warning-delay", minimum); + IrisLogging.warn("Updated " + file + " watchdog.early-warning-delay: " + previous + " -> " + minimum + + " milliseconds. Takes effect after a server restart."); + IrisLogging.warn("Disable autoConfiguration.configurePaperWatchdogDelay in iris.json to manage this setting yourself."); + } + + private static void persistTimeout(Path file, YamlConfiguration configuration, String key, long minimum) throws IOException { + Path target = file.toRealPath(); + Object previous = configuration.isSet(key) ? configuration.get(key) : null; + configuration.set(key, minimum); + try { + AtomicFileIO.writeString(target, configuration.saveToString()); + } catch (IOException | RuntimeException failure) { + configuration.set(key, previous); + throw failure; } } - private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException { - File spigotConfig = new File("config/paper-global.yml"); - FileConfiguration f = new YamlConfiguration(); - f.load(spigotConfig); - long tt = f.getLong("watchdog.early-warning-delay"); - - long watchdog = TimeUnit.MINUTES.toMillis(3); - if (tt < watchdog) { - IrisLogging.warn("Updating paper.yml watchdog early-warning-delay: " + tt + " -> " + watchdog + " (3 minutes)"); - IrisLogging.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value."); - f.set("watchdog.early-warning-delay", watchdog); - f.save(spigotConfig); + static Path serverConfigurationOption(String name) throws ReflectiveOperationException, IOException { + Server server = Bukkit.getServer(); + Object console = server.getClass().getMethod("getServer").invoke(server); + Object options = console.getClass().getField("options").get(console); + Object value = options.getClass().getMethod("valueOf", String.class).invoke(options, name); + if (!(value instanceof File file)) { + throw new IOException("Server option " + name + " did not provide a configuration path."); } + return file.toPath().toAbsolutePath().normalize(); } public static KList getDatapacksFolder() { diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index 295621ed3..06de2087b 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -42,6 +42,7 @@ import art.arcane.iris.studio.StudioSVC; import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.IrisWorldManager; import art.arcane.volmlib.util.cache.AtomicCache; import art.arcane.iris.generation.chunk.TerrainChunk; import art.arcane.iris.generation.runtime.Engine; @@ -928,29 +929,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun boolean alreadyClosing = closing; closing = true; cancelWatcherHotload(); + AtomicBoolean dispatchAccepted = new AtomicBoolean(); CompletableFuture operation; try { - operation = withExclusiveControlFuture(() -> { - Looper activeHotloader = hotloader; - hotloader = null; - if (isStudio() && activeHotloader != null) { - activeHotloader.interrupt(); - try { - activeHotloader.join(1000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - IrisLogging.reportError(e); + CompletableFuture preservedDrops = engine != null && engine.getWorldManager() instanceof IrisWorldManager manager + ? manager.flushPendingBlockDropsAsync() : CompletableFuture.completedFuture(null); + operation = preservedDrops.thenCompose(ignored -> { + CompletableFuture dispatched = withExclusiveControlFuture(() -> { + Looper activeHotloader = hotloader; + hotloader = null; + if (isStudio() && activeHotloader != null) { + activeHotloader.interrupt(); + try { + activeHotloader.join(1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + IrisLogging.reportError(e); + } } - } - Engine currentEngine = engine; - if (currentEngine != null && !currentEngine.isClosed()) { - currentEngine.close(); - } - folder.clear(); - populators.clear(); - closeStartupTarget(); - startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup.")); + Engine currentEngine = engine; + if (currentEngine != null && !currentEngine.isClosed()) { + currentEngine.close(); + } + folder.clear(); + populators.clear(); + closeStartupTarget(); + startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup.")); + }); + dispatchAccepted.set(true); + return dispatched; }); } catch (Throwable throwable) { if (!alreadyClosing) { @@ -969,9 +977,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (throwable == null) { future.complete(null); } else { - // The close body already ran and tore the engine down; unlike the pre-dispatch - // failure above, resetting the gate here would advertise a healthy generator - // over a CLOSED or FAILED engine. Stay latched and surface the failure. + // An accepted close may have torn the engine down. Reset the gate only when + // exclusive cleanup was never dispatched, or a CLOSED or FAILED engine would + // be advertised as a healthy generator. + if (!alreadyClosing && !dispatchAccepted.get()) { + closing = false; + } closeFuture.compareAndSet(future, null); future.completeExceptionally(throwable); } diff --git a/core/src/main/java/art/arcane/iris/world/tree/BlockDropRouter.java b/core/src/main/java/art/arcane/iris/world/tree/BlockDropRouter.java index 2911e67af..66d1bc456 100644 --- a/core/src/main/java/art/arcane/iris/world/tree/BlockDropRouter.java +++ b/core/src/main/java/art/arcane/iris/world/tree/BlockDropRouter.java @@ -1,6 +1,9 @@ package art.arcane.iris.world.tree; -@FunctionalInterface +import java.util.Collection; + public interface BlockDropRouter { + Collection captureNativeDrops(); + boolean routeDrop(Object drop); } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineBiomeEnvironmentAsyncTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineBiomeEnvironmentAsyncTest.java new file mode 100644 index 000000000..99b540673 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineBiomeEnvironmentAsyncTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.SavedBiomeRuntime; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisEngineBiomeEnvironmentAsyncTest { + @Test + public void uncachedBiomeWaitsAsynchronouslyAndUsesPreparedVolumeWithWorldHeight() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture pending = new CompletableFuture<>(); + SavedBiomeRuntime.ReadSession session = mock(SavedBiomeRuntime.ReadSession.class); + BiomeEnvironment environment = environment(7L); + when(fixture.biomes.readChunkAsync(-2, 2)).thenReturn(pending); + when(session.resolve(-17, -59, 33, false)).thenReturn(Optional.of(environment)); + + CompletableFuture reading = fixture.engine.getBiomeEnvironmentAsync(-17, 5, 33); + + assertFalse(reading.isDone()); + verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + pending.complete(session); + assertSame(environment, reading.get(5L, TimeUnit.SECONDS)); + verify(session).resolve(-17, -59, 33, false); + verify(fixture.biomes, never()).resolve(anyInt(), anyInt(), anyInt(), anyBoolean()); + } + + @Test + public void permanentSavedBiomeFailureCompletesTheFutureExceptionally() throws Exception { + Fixture fixture = new Fixture(); + SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Saved pack is missing", false); + when(fixture.biomes.readChunkAsync(0, 0)).thenReturn(CompletableFuture.failedFuture(failure)); + + CompletableFuture reading = fixture.engine.getBiomeEnvironmentAsync(0, 64, 0); + + assertSame(failure, assertThrows(CompletionException.class, reading::join).getCause()); + verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + } + + @Test + public void preparedEmptyRecordFallsBackWithoutReenteringTheSavedQueryCache() throws Exception { + Fixture fixture = new Fixture(); + SavedBiomeRuntime.ReadSession session = mock(SavedBiomeRuntime.ReadSession.class); + GenerationHistoryRuntimeRouter.CoordinateScope scope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); + BiomeEnvironment expected = environment(0L); + when(fixture.biomes.readChunkAsync(0, 0)).thenReturn(CompletableFuture.completedFuture(session)); + when(session.resolve(3, 7, 4, false)).thenReturn(Optional.empty()); + when(fixture.router.openReadScope(3, 4)).thenReturn(scope); + when(fixture.engine.getBiome(3, 71, 4)).thenReturn(expected.biome()); + when(fixture.engine.getRegion(3, 71, 4)).thenReturn(expected.region()); + when(fixture.engine.getDimension()).thenReturn(expected.dimension()); + when(fixture.engine.getData()).thenReturn(expected.data()); + + assertEquals(expected, fixture.engine.getBiomeEnvironmentAsync(3, 71, 4).get(5L, TimeUnit.SECONDS)); + + verify(scope).close(); + verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + verify(fixture.biomes, never()).resolve(anyInt(), anyInt(), anyInt(), anyBoolean()); + } + + @Test + public void scopedLookupUsesCurrentRuntimeAndDoesNotReadSavedBiomes() throws Exception { + Fixture fixture = new Fixture(); + BiomeEnvironment expected = environment(4L); + when(fixture.engine.hasGenerationRuntimeScope()).thenReturn(true); + when(fixture.engine.getBiomeEnvironment(1, 2, 3)).thenReturn(expected); + + CompletableFuture reading = fixture.engine.getBiomeEnvironmentAsync(1, 2, 3); + + assertTrue(reading.isDone()); + assertSame(expected, reading.join()); + verifyNoInteractions(fixture.biomes); + } + + @Test + public void immediateLookupFailureIsReturnedInTheFuture() throws Exception { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("Generation closed"); + when(fixture.engine.hasGenerationRuntimeScope()).thenReturn(true); + when(fixture.engine.getBiomeEnvironment(1, 2, 3)).thenThrow(failure); + + CompletableFuture reading = fixture.engine.getBiomeEnvironmentAsync(1, 2, 3); + + assertSame(failure, assertThrows(CompletionException.class, reading::join).getCause()); + verifyNoInteractions(fixture.biomes); + } + + private static BiomeEnvironment environment(long activationId) { + return new BiomeEnvironment(activationId, mock(IrisBiome.class), mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + } + + private static final class Fixture { + private final IrisEngine engine = mock(IrisEngine.class); + private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + private final SavedBiomeRuntime biomes = mock(SavedBiomeRuntime.class); + + private Fixture() throws Exception { + Field field = IrisEngine.class.getDeclaredField("generationHistoryRuntimeRouter"); + field.setAccessible(true); + field.set(engine, router); + when(router.biomes()).thenReturn(biomes); + when(engine.getWorld()).thenReturn(IrisWorld.builder().name("test").minHeight(-64).maxHeight(320).build()); + doCallRealMethod().when(engine).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisWorldManagerMarkerTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisWorldManagerMarkerTest.java index 427dbacaa..32b6d4f9b 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisWorldManagerMarkerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisWorldManagerMarkerTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.runtime; +import art.arcane.iris.world.tree.BlockDropRouter; import org.junit.Test; import org.bukkit.World; import org.bukkit.entity.LivingEntity; @@ -74,16 +75,12 @@ public class IrisWorldManagerMarkerTest { public void deferredDropsUseTheRouteAndFallbackOnlyWhenDeclined() { List routed = new ArrayList<>(); List fallback = new ArrayList<>(); + BlockDropRouter router = mock(BlockDropRouter.class); + when(router.routeDrop("routed")).thenAnswer(invocation -> routed.add(invocation.getArgument(0))); WorldBlockDropRouter.routeDrops( List.of("routed", "fallback"), - drop -> { - if (drop.equals("routed")) { - routed.add((String) drop); - return true; - } - return false; - }, + router, fallback::add ); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java index 4494bfde5..1ce13e35d 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java @@ -2,36 +2,45 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import art.arcane.iris.world.tree.BlockDropRouter; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.world.loot.IrisBlockDrops; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.world.IrisWorld; -import art.arcane.iris.platform.generation.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.collection.KList; import org.bukkit.Location; -import org.bukkit.GameMode; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.BlockState; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockDropItemEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; import org.junit.Test; import org.mockito.MockedStatic; import java.util.ArrayDeque; +import java.util.Collection; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; 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.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; @@ -43,191 +52,241 @@ import static org.mockito.Mockito.when; public class WorldBlockDropRouterTest { @Test - public void packWithoutDropRulesAllowsTheFirstCreativeAndSurvivalBreakWhileBiomesAreCold() { - for (GameMode mode : new GameMode[] {GameMode.CREATIVE, GameMode.SURVIVAL}) { - try (Fixture fixture = new Fixture()) { - when(fixture.data.hasBlockDropRules("minecraft:stone")).thenReturn(false); - when(fixture.data.hasBlockDropRules("minecraft:grass_block")).thenReturn(true); - when(fixture.player.getGameMode()).thenReturn(mode); - fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) - .thenThrow(new SavedBiomeUnavailableException("Biome loading", true)); - BlockBreakEvent event = fixture.event(); - - fixture.router.onBlockBreak(event); - - assertFalse(mode.name(), event.isCancelled()); - assertTrue(event.isDropItems()); - fixture.queries.verifyNoInteractions(); - verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); - assertEquals(1, fixture.delayed.size()); - fixture.delayed.remove().run(); - assertEquals(1, fixture.markers.size()); - } - } - } - - @Test - public void coldBiomeCancelsBeforeChangingDropsOrSchedulingWork() { + public void coldGrassBreakNeverCancelsOrChangesNativeDropHandling() { 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.environment = new CompletableFuture<>(); + BlockBreakEvent event = new BlockBreakEvent(fixture.block, fixture.player); 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(); + assertTrue(event.isDropItems()); + verify(fixture.engine, never()).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt()); } } @Test - public void freshAttemptAfterLoadingProducesDropsOnceWithoutReplayingTheCancelledEvent() { + public void firstColdBreakReplacesNativeDropsOnceAfterSavedEnvironmentArrives() { 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()); + fixture.environment = new CompletableFuture<>(); + BlockDropItemEvent event = fixture.event(); + fixture.router.onBlockDropItems(event); + assertFalse(event.isCancelled()); + assertTrue(event.getItems().isEmpty()); + fixture.flush(); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + fixture.environment.complete(fixture.biomeEnvironment); + fixture.flush(); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom); + verify(fixture.world, never()).dropItemNaturally(any(Location.class), eq(fixture.nativeStack)); verify(fixture.drops).fillDrops(eq(false), any()); - fixture.delayed.remove().run(); + fixture.router.close(); + fixture.flush(); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom); + } + } - verify(fixture.world).dropItemNaturally(fixture.location.clone().add(.5, .5, .5), fixture.item); - assertEquals(1, fixture.markers.size()); + @Test + public void additiveRulesPreserveServerCalculatedLootIncludingQuantityAndEnchantments() { + try (Fixture fixture = new Fixture()) { + when(fixture.drops.isReplaceVanillaDrops()).thenReturn(false); + fixture.router.onBlockDropItems(fixture.event()); + fixture.flush(); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom); + } + } + + @Test + public void rejectedSchedulerLeavesNativeLootInItsOriginalEvent() { + try (Fixture fixture = new Fixture()) { + fixture.scheduling.when(() -> J.runAt(eq(fixture.location), any(Runnable.class), eq(1))).thenReturn(false); + BlockDropItemEvent event = fixture.event(); + fixture.router.onBlockDropItems(event); + assertFalse(event.isCancelled()); + assertEquals(1, event.getItems().size()); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + } + + @Test + public void noNativeItemsStillProducesMatchingCustomDrops() { + try (Fixture fixture = new Fixture()) { + BlockDropItemEvent event = fixture.event(); + event.getItems().clear(); + fixture.router.onBlockDropItems(event); + fixture.flush(); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom); + } + } + + @Test + public void noMatchingRulesLeaveNativeEventUntouchedWithoutSavedLookups() { + try (Fixture fixture = new Fixture()) { + when(fixture.data.hasBlockDropRules(anyString())).thenReturn(false); + BlockDropItemEvent event = fixture.event(); + fixture.router.onBlockDropItems(event); + assertEquals(1, event.getItems().size()); + verify(fixture.engine, never()).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt()); 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() { + public void cancelledDropEventRemainsCancelledAndDoesNotRollOrEmitLoot() { 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))); - + fixture.environment = new CompletableFuture<>(); + BlockDropItemEvent event = fixture.event(); + fixture.router.onBlockDropItems(event); + event.setCancelled(true); + fixture.environment.complete(fixture.biomeEnvironment); + fixture.flush(); assertTrue(event.isCancelled()); - assertTrue(event.isDropItems()); - fixture.assertNoSideEffects(); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); } } - private static final class ProtectionProbe extends BlockBreakEvent { - private ProtectionProbe(Block block, Player player) { + @Test + public void permanentFailurePreservesNormalLootWithoutBlockingOrDiscardingIt() { + try (Fixture fixture = new Fixture()) { + fixture.environment = CompletableFuture.failedFuture(new SavedBiomeUnavailableException("Saved biome missing", false)); + BlockDropItemEvent event = fixture.event(); + fixture.router.onBlockDropItems(event); + fixture.flush(); + assertFalse(event.isCancelled()); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + fixture.logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class))); + } + } + + @Test + public void managerCloseRestoresPendingNativeLootOnlyOnce() { + try (Fixture fixture = new Fixture()) { + fixture.environment = new CompletableFuture<>(); + fixture.router.onBlockDropItems(fixture.event()); + fixture.router.close(); + fixture.environment.complete(fixture.biomeEnvironment); + fixture.flush(); + verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + } + } + + @Test + public void historicalContextOwnsMatchingAndAllParentRules() { + try (Fixture fixture = new Fixture()) { + IrisData historicData = mock(IrisData.class); + IrisRegion historicRegion = mock(IrisRegion.class); + IrisDimension historicDimension = mock(IrisDimension.class); + IrisBlockDrops regionDrops = mock(IrisBlockDrops.class); + IrisBlockDrops dimensionDrops = mock(IrisBlockDrops.class); + when(historicRegion.getBlockDrops()).thenReturn(new KList<>(regionDrops)); + when(historicDimension.getBlockDrops()).thenReturn(new KList<>(dimensionDrops)); + when(fixture.drops.shouldDropFor(fixture.state, historicData)).thenReturn(true); + when(regionDrops.shouldDropFor(fixture.state, historicData)).thenReturn(true); + when(dimensionDrops.shouldDropFor(fixture.state, historicData)).thenReturn(true); + BiomeEnvironment historic = new BiomeEnvironment(2L, fixture.biome, historicRegion, historicDimension, historicData); + fixture.environment = CompletableFuture.completedFuture(historic); + fixture.router.onBlockDropItems(fixture.event()); + fixture.flush(); + verify(fixture.engine).openBiomeEnvironmentScope(historic); + verify(regionDrops).fillDrops(eq(false), any()); + verify(dimensionDrops).fillDrops(eq(false), any()); + verify(fixture.engine, never()).getDimension(); + verify(fixture.drops, never()).shouldDropFor(fixture.state, fixture.data); + } + } + + @Test + public void skipParentsStillPreventsRegionAndDimensionLoot() { + try (Fixture fixture = new Fixture()) { + when(fixture.drops.isSkipParents()).thenReturn(true); + fixture.router.onBlockDropItems(fixture.event()); + fixture.flush(); + verify(fixture.region, never()).getBlockDrops(); + verify(fixture.dimension, never()).getBlockDrops(); + } + } + + @Test + public void routedTreeBreakPreservesInventoryDestinationWithColdMetadata() { + try (Fixture fixture = new Fixture()) { + fixture.environment = new CompletableFuture<>(); + RoutedBreak event = new RoutedBreak(fixture.block, fixture.player); + fixture.router.onBlockBreak(event); + assertFalse(event.isCancelled()); + assertFalse(event.isDropItems()); + fixture.environment.complete(fixture.biomeEnvironment); + fixture.flush(); + assertEquals(List.of(fixture.custom), event.routed); + verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + } + + @Test + public void cancelledRoutedProbeNeverEmitsDeferredLoot() { + try (Fixture fixture = new Fixture()) { + fixture.environment = new CompletableFuture<>(); + RoutedBreak event = new RoutedBreak(fixture.block, fixture.player); + fixture.router.onBlockBreak(event); + event.setCancelled(true); + fixture.environment.complete(fixture.biomeEnvironment); + fixture.flush(); + assertTrue(event.routed.isEmpty()); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + assertTrue(fixture.markers.isEmpty()); + } + } + + private static final class RoutedBreak extends BlockBreakEvent implements BlockDropRouter { + private final List routed = new ArrayList<>(); + + private RoutedBreak(Block block, Player player) { super(block, player); } + + @Override + public Collection captureNativeDrops() { + return List.of(); + } + + @Override + public boolean routeDrop(Object drop) { + return routed.add(drop); + } } private static final class Fixture implements AutoCloseable { private final IrisWorldManager manager = mock(IrisWorldManager.class); - private final Engine engine = mock(Engine.class); + private final IrisEngine engine = mock(IrisEngine.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 BlockState blockState = mock(BlockState.class); + private final BlockData state = mock(BlockData.class); private final Player player = mock(Player.class); private final Location location = new Location(world, 16, 72, 32); + private final Location center = location.clone().add(.5, .5, .5); + private final Location nativeLocation = location.clone().add(.2, .3, .4); private final IrisBiome biome = mock(IrisBiome.class); + private final IrisRegion region = mock(IrisRegion.class); + private final IrisDimension dimension = mock(IrisDimension.class); private final IrisBlockDrops drops = mock(IrisBlockDrops.class); - private final ItemStack item = mock(ItemStack.class); + private final ItemStack custom = mock(ItemStack.class); + private final ItemStack nativeStack = mock(ItemStack.class); + private final BiomeEnvironment biomeEnvironment = new BiomeEnvironment(1L, biome, region, dimension, data); + private CompletableFuture environment = CompletableFuture.completedFuture(biomeEnvironment); 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 final MockedStatic logging = mockStatic(IrisLogging.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); @@ -235,56 +294,67 @@ public class WorldBlockDropRouterTest { when(target.getWorld()).thenReturn(irisWorld); when(engine.getWorld()).thenReturn(irisWorld); when(irisWorld.minHeight()).thenReturn(-64); - when(engine.getDimension()).thenReturn(dimension); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty()); + when(engine.getBiomeEnvironmentAsync(16, 136, 32)).thenAnswer(invocation -> environment); + when(engine.openBiomeEnvironmentScope(any(BiomeEnvironment.class))).thenReturn(() -> {}); binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + when(world.getUID()).thenReturn(new UUID(0L, 1L)); 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(blockData.getAsString()).thenReturn("minecraft:stone"); + when(block.getBlockData()).thenReturn(state); + when(state.clone()).thenReturn(state); + when(state.getAsString()).thenReturn("minecraft:grass_block"); + when(blockState.getBlockData()).thenReturn(state); + when(blockState.getLocation()).thenReturn(location); + when(nativeStack.clone()).thenReturn(nativeStack); 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.shouldDropFor(state, data)).thenReturn(true); when(drops.isReplaceVanillaDrops()).thenReturn(true); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(block.getDrops(inventory.getItemInMainHand(), player)).thenReturn(List.of(nativeStack)); doAnswer(invocation -> { KList results = invocation.getArgument(1); - results.add(item); + results.add(custom); 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)); + 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.isOwnedByCurrentRegion(world, 1, 2)).thenReturn(true); + scheduling.when(() -> J.runRegionFuture(eq(world), eq(1), eq(2), any(Runnable.class))).thenAnswer(invocation -> { + Runnable task = invocation.getArgument(3); + task.run(); + return CompletableFuture.completedFuture(null); + }); scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { markers.add(invocation.getArgument(0)); return null; }); + logging.when(() -> IrisLogging.warnOnce(anyString(), anyString())).thenReturn(true); } - private BlockBreakEvent event() { - return new BlockBreakEvent(block, player); + private BlockDropItemEvent event() { + Item item = mock(Item.class); + when(item.getItemStack()).thenReturn(nativeStack); + when(item.getLocation()).thenReturn(nativeLocation); + return new BlockDropItemEvent(block, blockState, player, new ArrayList<>(List.of(item))); } - 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()); + private void flush() { + while (!delayed.isEmpty()) { + delayed.remove().run(); + } } @Override public void close() { scheduling.close(); - queries.close(); + logging.close(); binding.close(); } } diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorTimeoutTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorTimeoutTest.java new file mode 100644 index 000000000..ce49e303f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorTimeoutTest.java @@ -0,0 +1,207 @@ +package art.arcane.iris.pack.datapack; + +import art.arcane.iris.spi.IrisLogging; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class ServerConfiguratorTimeoutTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void spigotLiveConfigurationKeepsTheRaisedTimeoutWhenTheServerSavesItAgain() throws Exception { + Path file = temporaryFolder.newFile("custom-spigot.yml").toPath(); + Files.writeString(file, "settings:\n timeout-time: 60\n sample-setting: retained\n"); + YamlConfiguration live = load(file); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + logging.when(() -> IrisLogging.warn(anyString())).thenAnswer(invocation -> { + assertEquals(300L, load(file).getLong("settings.timeout-time")); + return null; + }); + + ServerConfigurator.increaseKeepAliveSpigot(file, live); + + assertEquals(300L, live.getLong("settings.timeout-time")); + live.set("world-settings.default.sample-world-setting", true); + live.save(file.toFile()); + YamlConfiguration persisted = load(file); + assertEquals(300L, persisted.getLong("settings.timeout-time")); + assertEquals("retained", persisted.getString("settings.sample-setting")); + logging.verify(() -> IrisLogging.warn("Updated " + file + " settings.timeout-time: 60 -> 300" + + " seconds. Takes effect after a server restart.")); + } + } + + @Test + public void higherSpigotTimeoutIsLeftUntouched() throws Exception { + Path file = temporaryFolder.newFile("larger-spigot.yml").toPath(); + String original = "# Keep this configuration\nsettings:\n timeout-time: 600\n"; + Files.writeString(file, original); + YamlConfiguration live = load(file); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + ServerConfigurator.increaseKeepAliveSpigot(file, live); + + assertEquals(600L, live.getLong("settings.timeout-time")); + assertEquals(original, Files.readString(file)); + logging.verifyNoInteractions(); + } + } + + @Test + public void sharedSpigotConfigurationSymlinkIsPreservedWhileItsTargetIsUpdated() throws Exception { + Path directory = temporaryFolder.newFolder("shared-settings").toPath(); + Path target = directory.resolve("shared-spigot.yml"); + Path link = directory.resolve("spigot.yml"); + Files.writeString(target, "settings:\n timeout-time: 60\n sample-setting: retained\n"); + Path relativeTarget = target.getFileName(); + Files.createSymbolicLink(link, relativeTarget); + YamlConfiguration live = load(link); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + ServerConfigurator.increaseKeepAliveSpigot(link, live); + + assertTrue(Files.isSymbolicLink(link)); + assertEquals(relativeTarget, Files.readSymbolicLink(link)); + assertEquals(300L, load(target).getLong("settings.timeout-time")); + assertEquals("retained", load(target).getString("settings.sample-setting")); + assertEquals(300L, live.getLong("settings.timeout-time")); + } + } + + @Test + public void paperWatchdogPersistsToTheSpecifiedFileAndPreservesOtherSettings() throws Exception { + Path directory = temporaryFolder.newFolder("custom-paper-directory").toPath(); + Path file = directory.resolve("paper-global.yml"); + Files.writeString(file, "watchdog:\n early-warning-delay: 10000\n early-warning-every: 5000\n" + + "misc:\n sample-setting: retained\n"); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + ServerConfigurator.increasePaperWatchdog(file); + + YamlConfiguration persisted = load(file); + assertEquals(180000L, persisted.getLong("watchdog.early-warning-delay")); + assertEquals(5000L, persisted.getLong("watchdog.early-warning-every")); + assertEquals("retained", persisted.getString("misc.sample-setting")); + logging.verify(() -> IrisLogging.warn("Updated " + file + " watchdog.early-warning-delay: 10000 -> 180000" + + " milliseconds. Takes effect after a server restart.")); + } + } + + @Test + public void higherPaperWatchdogDelayIsLeftUntouched() throws Exception { + Path file = temporaryFolder.newFile("larger-paper-global.yml").toPath(); + String original = "# Keep this configuration\nwatchdog:\n early-warning-delay: 240000\n"; + Files.writeString(file, original); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + ServerConfigurator.increasePaperWatchdog(file); + + assertEquals(original, Files.readString(file)); + logging.verifyNoInteractions(); + } + } + + @Test + public void malformedPaperYamlIsPreservedAndDoesNotReportAnUpdate() throws Exception { + Path file = temporaryFolder.newFile("invalid-paper-global.yml").toPath(); + String malformed = "watchdog:\n early-warning-delay: [invalid\n"; + Files.writeString(file, malformed); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertThrows(InvalidConfigurationException.class, () -> ServerConfigurator.increasePaperWatchdog(file)); + + assertEquals(malformed, Files.readString(file)); + logging.verifyNoInteractions(); + } + } + + @Test + public void failedSpigotWriteRestoresTheLiveValueAndDoesNotReportSuccess() throws Exception { + Path blockedParent = temporaryFolder.newFile("blocked-parent").toPath(); + Files.writeString(blockedParent, "retained"); + YamlConfiguration live = new YamlConfiguration(); + live.set("settings.timeout-time", 60L); + live.set("settings.sample-setting", "retained"); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertThrows(IOException.class, () -> ServerConfigurator.increaseKeepAliveSpigot( + blockedParent.resolve("spigot.yml"), live)); + + assertEquals(60L, live.getLong("settings.timeout-time")); + assertEquals("retained", live.getString("settings.sample-setting")); + assertEquals("retained", Files.readString(blockedParent)); + logging.verifyNoInteractions(); + } + } + + @Test + public void actualServerOptionsResolveCustomConfigurationPaths() throws Exception { + Path directory = temporaryFolder.newFolder("custom-settings").toPath(); + File spigot = directory.resolve("nested/../server-spigot.yml").toFile(); + File paper = directory.resolve("nested/../paper").toFile(); + Server server = mock(Server.class, withSettings().extraInterfaces(ConsoleAccess.class)); + when(((ConsoleAccess) server).getServer()).thenReturn(new ConfigurationConsole( + new ConfigurationOptions(Map.of("spigot-settings", spigot, "paper-settings-directory", paper)))); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + + assertEquals(spigot.toPath().toAbsolutePath().normalize(), ServerConfigurator.serverConfigurationOption("spigot-settings")); + assertEquals(paper.toPath().toAbsolutePath().normalize(), ServerConfigurator.serverConfigurationOption("paper-settings-directory")); + assertThrows(IOException.class, () -> ServerConfigurator.serverConfigurationOption("missing-option")); + } + } + + private static YamlConfiguration load(Path file) throws IOException, InvalidConfigurationException { + YamlConfiguration configuration = new YamlConfiguration(); + configuration.load(file.toFile()); + return configuration; + } + + public interface ConsoleAccess { + ConfigurationConsole getServer(); + } + + public static final class ConfigurationConsole { + public final ConfigurationOptions options; + + public ConfigurationConsole(ConfigurationOptions options) { + this.options = options; + } + } + + public static final class ConfigurationOptions { + private final Map paths; + + public ConfigurationOptions(Map paths) { + this.paths = paths; + } + + public File valueOf(String name) { + return paths.get(name); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java index 1e4b55828..0cf840117 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.platform.generation; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisWorldManager; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; @@ -25,11 +26,60 @@ import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class BukkitChunkGeneratorCloseTest { + @Test + public void nativeDropFlushCompletesBeforeExclusiveCloseIsDispatched() throws Exception { + CloseFixture fixture = new CloseFixture(); + IrisWorldManager manager = mock(IrisWorldManager.class); + CompletableFuture flushed = new CompletableFuture<>(); + when(fixture.engine.getWorldManager()).thenReturn(manager); + when(manager.flushPendingBlockDropsAsync()).thenReturn(flushed); + CompletableFuture operation = fixture.queueOperation(); + + CompletableFuture closed = fixture.generator.closeAsync(); + + assertFalse(closed.isDone()); + verify(fixture.generator, never()).withExclusiveControlFuture(any(Runnable.class)); + verify(fixture.engine, never()).close(); + flushed.complete(null); + verify(fixture.generator).withExclusiveControlFuture(any(Runnable.class)); + verify(fixture.engine, never()).close(); + fixture.operation.get().run(); + operation.complete(null); + closed.join(); + verify(fixture.engine).close(); + } + + @Test + public void failedNativeDropFlushLeavesTheInitialCloseRetryable() throws Exception { + CloseFixture fixture = new CloseFixture(); + IrisWorldManager manager = mock(IrisWorldManager.class); + when(fixture.engine.getWorldManager()).thenReturn(manager); + when(manager.flushPendingBlockDropsAsync()).thenReturn( + CompletableFuture.failedFuture(new IllegalStateException("Drop scheduling unavailable")), + CompletableFuture.completedFuture(null)); + + CompletableFuture failed = fixture.generator.closeAsync(); + + assertTrue(failed.isCompletedExceptionally()); + assertFalse(fixture.generator.isClosing()); + verify(fixture.generator, never()).withExclusiveControlFuture(any(Runnable.class)); + verify(fixture.engine, never()).close(); + CompletableFuture operation = fixture.queueOperation(); + CompletableFuture retried = fixture.generator.closeAsync(); + assertNotSame(failed, retried); + fixture.operation.get().run(); + operation.complete(null); + retried.join(); + assertTrue(fixture.generator.isClosing()); + verify(fixture.engine).close(); + } + @Test public void failedCloseReleasesInitialEntryDeferral() throws Exception { CloseFixture fixture = new CloseFixture();