diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java index d6f734c16..8cbc5f74f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java @@ -74,7 +74,7 @@ import java.util.Set; @Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto") public class CommandFind implements DirectorExecutor { - @Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") + @Director(description = "Find a biome", sync = true, descriptionKey = "iris.director.commandfind.director.find_biome") public void biome( @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class) IrisBiome biome, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index b3a4b71ce..fa600f153 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -31,6 +31,9 @@ import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.generation.locator.Locator; +import art.arcane.iris.generation.locator.BiomeLocator; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.world.history.SavedBiomeRuntime; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.generation.runtime.WrongEngineBroException; @@ -82,7 +85,7 @@ final class ModdedLocateCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); return 0; } - locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); + locate(source, level, engine, player, BiomeLocator.forBiome(engine, biome.getLoadKey(), player.blockY()), "biome " + biome.getLoadKey()); return 1; } @@ -545,12 +548,12 @@ final class ModdedLocateCommands { previous.cancel(true); } search.whenComplete((Position2 at, Throwable error) -> completeLocate( - source, level, engine, player, label, server, playerId, search, at, error)); + source, level, engine, player, label, server, playerId, search, locator, at, error)); } private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine, NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId, - CompletableFuture search, Position2 at, Throwable error) { + CompletableFuture search, Locator locator, Position2 at, Throwable error) { if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { return; } @@ -576,15 +579,77 @@ final class ModdedLocateCommands { }); return; } + if (locator instanceof BiomeLocator caveLocator) { + prepareCaveLocate(new CaveLocateRequest(source, level, engine, player, label, server, playerId, + search, caveLocator, at)); + return; + } server.execute(() -> { if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { - teleportToLocateResult(source, level, engine, player, label, at); + teleportToLocateResult(source, level, engine, player, label, locator, at, Optional.empty()); } }); } - private static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine, - NativeProtocolPlayer player, String label, Position2 at) { + private static void prepareCaveLocate(CaveLocateRequest request) { + CompletableFuture.supplyAsync(() -> resolveCavePosition(request)) + .whenComplete((target, error) -> request.server().execute(() -> loadCaveDestination(request, target, error))); + } + + private static SubterrainPosition resolveCavePosition(CaveLocateRequest request) { + try (GenerationSessionLease lease = request.engine().acquireGenerationLease("modded_cave_position"); + IrisContext.Scope ignored = IrisContext.open(request.engine(), lease.sessionId(), null)) { + return request.locator().position(request.engine(), request.at()); + } catch (GenerationSessionException error) { + throw new CompletionException(error); + } + } + + private static void loadCaveDestination(CaveLocateRequest request, SubterrainPosition target, Throwable error) { + if (ACTIVE_LOCATE_REQUESTS.get(request.playerId()) != request.search()) { + return; + } + if (error != null) { + failCaveLocate(request, error); + return; + } + if (target == null || !request.player().connected() || !request.player().inWorld(request.level())) { + if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) { + IrisModdedCommands.fail(request.source(), "The cave destination is no longer available."); + } + return; + } + try { + request.level().getBlock(target.x(), target.y(), target.z()); + request.locator().prepareRead(request.engine(), new Position2(target.x() >> 4, target.z() >> 4)) + .whenComplete((read, failure) -> request.server().execute(() -> finishCaveLocate(request, target, read, failure))); + } catch (RuntimeException failure) { + failCaveLocate(request, failure); + } + } + + private static void finishCaveLocate(CaveLocateRequest request, SubterrainPosition target, + Optional read, Throwable error) { + if (error != null) { + failCaveLocate(request, error); + return; + } + if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) { + teleportToLocateResult(request.source(), request.level(), request.engine(), request.player(), + request.label(), request.locator(), request.at(), Optional.of(new CaveDestination(target, read))); + } + } + + private static void failCaveLocate(CaveLocateRequest request, Throwable error) { + ModdedIrisLog.error("Iris cave locate failed for {}", request.label(), error); + if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) { + IrisModdedCommands.fail(request.source(), "Could not prepare the cave destination. See the server log for details."); + } + } + + static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine, + NativeProtocolPlayer player, String label, Locator locator, Position2 at, + Optional caveDestination) { // Same liveness guards the structure completion path has: the search can take up to // two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then. if (!player.connected()) { @@ -600,6 +665,17 @@ final class ModdedLocateCommands { try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; + if (locator instanceof BiomeLocator caveLocator) { + CaveDestination prepared = caveDestination.orElseThrow(); + SubterrainPosition landing = caveLocator.landing(engine, level, prepared.target(), prepared.read()); + if (landing == null) { + IrisModdedCommands.fail(source, "No safe cave landing found for " + label + "."); + return; + } + blockX = landing.x(); + blockY = landing.y(); + blockZ = landing.z(); + } boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D); if (!teleported) { IrisModdedCommands.fail(source, IrisLanguage.plain( @@ -611,7 +687,8 @@ final class ModdedLocateCommands { return; } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ))); - } catch (GenerationSessionException e) { + } catch (GenerationSessionException | RuntimeException e) { + ModdedIrisLog.error("Iris locate teleport failed for {}", label, e); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label))); } } @@ -635,6 +712,14 @@ final class ModdedLocateCommands { NO_PLACEMENT } + record CaveDestination(SubterrainPosition target, Optional read) { + } + + private record CaveLocateRequest(NativeCommandSource source, NativeWorld level, Engine engine, + NativeProtocolPlayer player, String label, NativeModdedServer server, + UUID playerId, CompletableFuture search, BiomeLocator locator, Position2 at) { + } + private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder, NativeStructureAvailability availability) { } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedCaveLocateTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedCaveLocateTest.java new file mode 100644 index 000000000..50cb457b6 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedCaveLocateTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.locator.BiomeLocator; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.math.Position2; +import org.junit.Test; +import org.mockito.MockedStatic; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class ModdedCaveLocateTest { + @Test + public void caveTeleportUsesValidatedUndergroundPosition() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.player.teleport(fixture.world, 8.5, -42, 8.5)).thenReturn(true); + try (MockedStatic commands = mockStatic(IrisModdedCommands.class); + MockedStatic context = mockStatic(IrisContext.class)) { + fixture.context(context); + fixture.teleport(); + verify(fixture.player).teleport(fixture.world, 8.5, -42, 8.5); + commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString())); + commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never()); + } + } + + @Test + public void failedTeleportNeverReportsSuccess() throws Exception { + Fixture fixture = new Fixture(); + try (MockedStatic commands = mockStatic(IrisModdedCommands.class); + MockedStatic context = mockStatic(IrisContext.class)) { + fixture.context(context); + fixture.teleport(); + commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString())); + commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never()); + } + } + + @Test + public void worldChangeCancelsBeforeCaveLookupOrTeleport() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.player.inWorld(fixture.world)).thenReturn(false); + try (MockedStatic commands = mockStatic(IrisModdedCommands.class)) { + fixture.teleport(); + verifyNoInteractions(fixture.locator); + verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble()); + commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString())); + } + } + + private static final class Fixture { + private final NativeCommandSource source = mock(NativeCommandSource.class); + private final NativeWorld world = mock(NativeWorld.class); + private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + private final Engine engine = mock(Engine.class); + private final BiomeLocator locator = mock(BiomeLocator.class); + + private Fixture() throws Exception { + when(player.connected()).thenReturn(true); + when(player.inWorld(world)).thenReturn(true); + when(engine.acquireGenerationLease("modded_locator_teleport")).thenReturn(mock(GenerationSessionLease.class)); + SubterrainPosition target = new SubterrainPosition(8, -40, 8); + when(locator.position(engine, new Position2(0, 0))).thenReturn(target); + when(locator.landing(engine, world, target, Optional.empty())).thenReturn(new SubterrainPosition(8, -42, 8)); + } + + private void context(MockedStatic context) { + context.when(() -> IrisContext.open(eq(engine), anyLong(), any())).thenReturn(mock(IrisContext.Scope.class)); + } + + private void teleport() { + ModdedLocateCommands.teleportToLocateResult(source, world, engine, player, "biome deep", locator, + new Position2(0, 0), Optional.of(new ModdedLocateCommands.CaveDestination( + new SubterrainPosition(8, -40, 8), Optional.empty()))); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/locator/BiomeLocator.java b/core/src/main/java/art/arcane/iris/generation/locator/BiomeLocator.java new file mode 100644 index 000000000..8630eb03b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/locator/BiomeLocator.java @@ -0,0 +1,429 @@ +package art.arcane.iris.generation.locator; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.hydrology.IrisRiverPolicy; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.SavedBiomeRuntime; +import art.arcane.iris.generation.runtime.GenerationSessionException; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.runtime.WrongEngineBroException; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.world.history.GenerationSemanticIndex; +import art.arcane.iris.world.history.GenerationSemanticQueries; +import art.arcane.iris.world.history.GenerationFindCatalog; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.ChunkGenerationSemantics; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.BooleanSupplier; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public final class BiomeLocator implements Locator { + private static final Set HAZARDOUS_FLOORS = Set.of("minecraft:magma_block", "minecraft:campfire", + "minecraft:soul_campfire", "minecraft:cactus", "minecraft:powder_snow", "minecraft:pointed_dripstone"); + + private volatile long searchDeadline = Long.MAX_VALUE; + private final String key; + private final boolean authored; + private Set caveHosts = Set.of(); + private boolean configured; + private int originWorldY; + private boolean surfaceAllowed; + private final Set surfaceTargets = ConcurrentHashMap.newKeySet(); + private volatile SubterrainPosition authoredTarget; + private final ConcurrentHashMap cavernTargets = new ConcurrentHashMap<>(); + + public BiomeLocator(String key, boolean authored) { + this.key = key; + this.authored = authored; + } + + public static Locator forBiome(Engine engine, String key, int originWorldY) { + boolean authored = false; + boolean configured = GenerationFindCatalog.hasRetainedCaveBiome(engine, key); + Set caveHosts = new HashSet<>(); + boolean surfaceAllowed = GenerationFindCatalog.hasRetainedSurfaceBiome(engine, key); + for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { + for (String root : region.getLandBiomes()) { + surfaceAllowed |= caveBranch(engine, root).contains(key); + } + for (String root : region.getSeaBiomes()) { + surfaceAllowed |= caveBranch(engine, root).contains(key); + } + for (String root : region.getShoreBiomes()) { + surfaceAllowed |= caveBranch(engine, root).contains(key); + } + configured |= floodedBiome(region.getRiverPolicy(), key); + for (String root : region.getCaveBiomes()) { + Set branch = caveBranch(engine, root); + if (branch.contains(key)) { + caveHosts.addAll(branch); + } + } + } + for (IrisDimensionCarvingEntry entry : engine.getDimension().getCarving()) { + if (entry.isEnabled() && !entry.getBiome().isEmpty() && caveBranch(engine, entry.getBiome()).contains(key)) { + configured = true; + } + } + for (IrisSubterrainFeature feature : engine.getDimension().getSubterrainFeatures()) { + if (feature.isEnabled() && key.equals(feature.getBiome())) { + authored = true; + } + } + configured |= floodedBiome(engine.getDimension().getRiverPolicy(), key); + for (IrisBiome biome : engine.getAllBiomes()) { + configured |= key.equals(biome.getCarvingBiome()) || floodedBiome(biome.getRiverPolicy(), key); + } + if (!authored && !configured && caveHosts.isEmpty()) { + return Locator.surfaceBiome(key); + } + BiomeLocator locator = new BiomeLocator(key, authored); + locator.caveHosts = Set.copyOf(caveHosts); + locator.configured = configured; + locator.originWorldY = originWorldY; + locator.surfaceAllowed = surfaceAllowed; + return locator; + } + + private static boolean floodedBiome(IrisRiverPolicy policy, String key) { + return policy != null && policy.getFloodedCaveBiomes() != null && policy.getFloodedCaveBiomes().contains(key); + } + + private static Set caveBranch(Engine engine, String root) { + Set visited = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(root); + while (!pending.isEmpty()) { + String candidate = pending.removeFirst(); + if (!visited.add(candidate)) { + continue; + } + IrisBiome biome = engine.getData().getBiomeLoader().load(candidate); + if (biome != null) { + pending.addAll(biome.getChildren()); + } + } + return visited; + } + + @Override + public CompletableFuture find(Engine engine, Position2 origin, long timeout, Consumer checks) + throws WrongEngineBroException { + searchDeadline = System.nanoTime() + timeout * 1_000_000L; + if (!authored || surfaceAllowed) { + return Locator.super.find(engine, origin, timeout, checks); + } + CompletableFuture result = new CompletableFuture<>(); + CompletableFuture.runAsync(() -> { + long deadline = System.nanoTime() + timeout * 1_000_000L; + try (GenerationSessionLease lease = engine.acquireGenerationLease("cave_biome_locator"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + BooleanSupplier running = () -> !result.isCancelled() && !engine.isClosing() && System.nanoTime() < deadline; + Map safeTargets = new HashMap<>(); + SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(engine, + new SubterrainLocator.Query("", null, key), (origin.getX() << 4) + 8, + originWorldY, (origin.getZ() << 4) + 8, 32768, + running, candidate -> { + SubterrainLocator.Result safe = dryAuthoredTarget(engine, candidate, running); + if (safe == null) { + return false; + } + safeTargets.put(candidate, safe); + return true; + }).orElse(null); + if (found != null) { + found = safeTargets.get(found); + authoredTarget = new SubterrainPosition(found.x(), found.y(), found.z()); + } + checks.accept(1); + result.complete(found == null ? null : new Position2(found.x() >> 4, found.z() >> 4)); + } catch (Throwable error) { + result.completeExceptionally(error); + } + }); + return result; + } + + @Override + public SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maximumRadius) { + GenerationHistory history = GenerationLocatorPolicy.history(engine).orElse(null); + if (history == null) { + return null; + } + Set rejected = new HashSet<>(); + GenerationSemanticIndex.Query query = GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.CAVE_BIOME, key, + new ChunkGenerationSemantics.BlockPosition((origin.getX() << 4) + 8, originWorldY, + (origin.getZ() << 4) + 8), maximumRadius); + while (!engine.isClosing() && !Thread.currentThread().isInterrupted() && System.nanoTime() < searchDeadline) { + GenerationSemanticIndex.Match match = history.findRecorded(query, + record -> !rejected.contains(new Position2(record.chunkX(), record.chunkZ()))).orElse(null); + if (match == null) { + return null; + } + Position2 chunk = new Position2(match.chunk().chunkX(), match.chunk().chunkZ()); + SubterrainPosition target = position(engine, chunk); + if (target != null) { + cavernTargets.put(chunk, target); + return new SearchCandidate(chunk, target.x(), target.z()); + } + rejected.add(chunk); + } + return null; + } + + @Override + public boolean matches(Engine engine, Position2 chunk) { + if (surfaceAllowed && Locator.chunkContainsSurfaceBiome(engine, chunk, key)) { + surfaceTargets.add(chunk); + return true; + } + if (authored) { + return position(engine, chunk) != null; + } + if (position(engine, chunk) == null) { + return false; + } + try (GenerationSessionLease lease = engine.acquireGenerationLease("cave_biome_matter")) { + ChunkContext context = new ChunkContext(chunk.getX() << 4, chunk.getZ() << 4, engine.getComplex(), + lease.sessionId(), false, ChunkContext.PrefillPlan.NONE, null); + try (IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), context)) { + engine.generateMatter(chunk.getX(), chunk.getZ(), true, context); + AtomicReference target = new AtomicReference<>(); + engine.getMantle().getMantle().iterateChunk(chunk.getX(), chunk.getZ(), MatterCavern.class, (x, y, z, cavern) -> { + if (target.get() != null || !dryCavern(cavern)) { + return; + } + int blockX = (chunk.getX() << 4) + x; + int blockZ = (chunk.getZ() << 4) + z; + SubterrainPosition candidate = cavernPosition(engine, blockX, y, blockZ, cavern); + if (candidate != null) { + target.set(candidate); + } + }); + if (target.get() == null) { + return false; + } + cavernTargets.put(chunk, target.get()); + return true; + } + } catch (GenerationSessionException error) { + throw new IllegalStateException(error); + } + } + + public SubterrainPosition position(Engine engine, Position2 chunk) { + if (authoredTarget != null || cavernTargets.containsKey(chunk) || surfaceTargets.contains(chunk) || authored + || activePredictionWithoutHistory(engine, chunk)) { + return position(engine, chunk, Optional.empty()); + } + try { + CompletableFuture> preparation = prepareRead(engine, chunk); + Optional read = searchDeadline == Long.MAX_VALUE + ? preparation.get() : preparation.get(Math.max(1L, searchDeadline - System.nanoTime()), TimeUnit.NANOSECONDS); + return position(engine, chunk, read); + } catch (TimeoutException error) { + return null; + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new CompletionException(error); + } catch (ExecutionException error) { + throw new CompletionException(error.getCause()); + } + } + + private SubterrainPosition position(Engine engine, Position2 chunk, Optional read) { + return GenerationLocatorPolicy.evaluateValueScoped(engine, chunk, () -> positionScoped(engine, chunk, read)); + } + + private SubterrainPosition positionScoped(Engine engine, Position2 chunk, Optional read) { + if (surfaceTargets.contains(chunk)) { + int x = (chunk.getX() << 4) + 8; + int z = (chunk.getZ() << 4) + 8; + return new SubterrainPosition(x, engine.getMinHeight() + engine.getHeight(x, z, false) + 2, z); + } + SubterrainPosition cavern = cavernTargets.get(chunk); + if (cavern != null) { + return cavern; + } + int x = (chunk.getX() << 4) + 8; + int z = (chunk.getZ() << 4) + 8; + if (authored) { + if (authoredTarget != null && authoredTarget.x() >> 4 == chunk.getX() + && authoredTarget.z() >> 4 == chunk.getZ()) { + return authoredTarget; + } + for (SubterrainPlan plan : engine.getComplex().getSubterrainPlanner().plansForBounds(x - 8, z - 8, x + 7, z + 7)) { + if (!key.equals(plan.biome())) { + continue; + } + SubterrainPosition anchor = plan.anchor(); + SubterrainCell owner = engine.getComplex().getSubterrainPlanner().sample(anchor.x(), anchor.y(), anchor.z()); + if (owner.occupied() && plan.id().equals(owner.room().featureId())) { + SubterrainLocator.Result safe = dryAuthoredTarget(engine, + new SubterrainLocator.Result(plan.id(), plan.family(), plan.biome(), anchor.x(), anchor.y(), anchor.z()), + () -> !engine.isClosing()); + if (safe != null) { + return new SubterrainPosition(safe.x(), safe.y(), safe.z()); + } + } + } + return null; + } + if (read.isEmpty() && !configured && !caveHosts.isEmpty()) { + IrisBiome host = engine.getCaveBiome(x, z); + if (host == null || !caveHosts.contains(host.getLoadKey())) { + return null; + } + } + for (int radius = 0; radius <= (read.isPresent() ? 8 : 0); radius++) { + for (int columnX = Math.max(x - 8, x - radius); columnX <= Math.min(x + 7, x + radius); columnX++) { + for (int columnZ = Math.max(z - 8, z - radius); columnZ <= Math.min(z + 7, z + radius); columnZ++) { + if (Math.max(Math.abs(columnX - x), Math.abs(columnZ - z)) != radius) { + continue; + } + int top = Math.min(engine.getHeight() - 2, engine.getHeight(columnX, columnZ) - 2); + for (int y = top; y >= 1; y--) { + if (Thread.currentThread().isInterrupted() || engine.isClosing() || System.nanoTime() >= searchDeadline) { + return null; + } + IrisBiome saved = savedBiomeAt(columnX, y + engine.getMinHeight(), columnZ, read); + IrisBiome biome = saved != null ? saved : engine.getCaveBiome(columnX, y, columnZ); + if (biome != null && key.equals(biome.getLoadKey())) { + return new SubterrainPosition(columnX, y + engine.getMinHeight(), columnZ); + } + } + } + } + } + return null; + } + + private static SubterrainLocator.Result dryAuthoredTarget(Engine engine, SubterrainLocator.Result candidate, + BooleanSupplier running) { + return GenerationSemanticQueries.drySubterrainLanding(engine, candidate, running).orElse(null); + } + + private static boolean activePredictionWithoutHistory(Engine engine, Position2 chunk) { + if (!(engine instanceof IrisEngine irisEngine)) { + return true; + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return true; + } + GenerationHistory history = router.history(); + return history.isActiveUnowned(chunk.getX(), chunk.getZ()) && history.semantics(chunk.getX(), chunk.getZ()).isEmpty(); + } + + public CompletableFuture> prepareRead(Engine engine, Position2 chunk) { + if (engine instanceof IrisEngine irisEngine) { + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router != null) { + return router.biomes().readChunkAsync(chunk.getX(), chunk.getZ()).thenApply(Optional::of); + } + } + return CompletableFuture.completedFuture(Optional.empty()); + } + + public SubterrainPosition landing(Engine engine, NativeWorld world, SubterrainPosition target, + Optional read) { + return GenerationLocatorPolicy.evaluateValueScoped(engine, new Position2(target.x() >> 4, target.z() >> 4), + () -> landingScoped(engine, world, target, read)); + } + + private SubterrainPosition landingScoped(Engine engine, NativeWorld world, SubterrainPosition target, + Optional read) { + if (surfaceTargets.contains(new Position2(target.x() >> 4, target.z() >> 4))) { + return target; + } + int chunkX = target.x() >> 4; + int chunkZ = target.z() >> 4; + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + for (int radius = 0; radius <= 15; radius++) { + for (int x = Math.max(minimumX, target.x() - radius); x <= Math.min(minimumX + 15, target.x() + radius); x++) { + for (int z = Math.max(minimumZ, target.z() - radius); z <= Math.min(minimumZ + 15, target.z() + radius); z++) { + if (Math.max(Math.abs(x - target.x()), Math.abs(z - target.z())) != radius) { + continue; + } + int top = Math.min(world.maxHeight() - 2, engine.getMinHeight() + engine.getHeight(x, z) - 2); + for (int y = top; y > world.minHeight(); y--) { + NativeBlockState feet = world.getBlock(x, y, z); + if (!feet.isAir() || !world.getBlock(x, y + 1, z).isAir()) { + continue; + } + NativeBlockState floor = world.getBlock(x, y - 1, z); + if (!floor.isSolid() || floor.isFluid() || floor.isWaterLogged() || hazardous(floor)) { + continue; + } + IrisBiome biome = biomeAt(engine, x, y, z, read); + if (biome != null && key.equals(biome.getLoadKey())) { + return new SubterrainPosition(x, y, z); + } + } + } + } + } + return null; + } + + private static IrisBiome biomeAt(Engine engine, int x, int worldY, int z, + Optional read) { + IrisBiome saved = savedBiomeAt(x, worldY, z, read); + return saved != null ? saved : engine.getBiomeOrMantle(x, worldY - engine.getMinHeight(), z); + } + + private static IrisBiome savedBiomeAt(int x, int worldY, int z, + Optional read) { + return read.flatMap(session -> session.resolve(x, worldY, z, false)).map(BiomeEnvironment::biome).orElse(null); + } + + SubterrainPosition cavernPosition(Engine engine, int x, int y, int z, MatterCavern cavern) { + if (!dryCavern(cavern)) { + return null; + } + IrisBiome biome = engine.getBiomeOrMantle(x, y, z); + return biome != null && key.equals(biome.getLoadKey()) + ? new SubterrainPosition(x, y + engine.getMinHeight(), z) : null; + } + + private static boolean dryCavern(MatterCavern cavern) { + return cavern != null && cavern.isCavern() && (cavern.isAir() || cavern.getLiquid() == 3); + } + + private static boolean hazardous(NativeBlockState state) { + String key = state.materialKey(); + if (key == null) { + key = state.key().split("\\[", 2)[0]; + } + return HAZARDOUS_FLOORS.contains(key); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java b/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java index 23190b111..e2c687221 100644 --- a/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java +++ b/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.locator; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainBounds; import art.arcane.iris.generation.subterrain.SubterrainPlan; import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.generation.subterrain.SubterrainPosition; @@ -12,6 +13,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; +import java.util.function.BooleanSupplier; public final class SubterrainLocator { private static final int SEARCH_TILE_SIZE = 512; @@ -31,6 +33,11 @@ public final class SubterrainLocator { public static Optional nearest(SubterrainPlanner planner, Query query, int x, int worldY, int z, int maximumDistance, Predicate allowed) { + return nearest(planner, query, x, worldY, z, maximumDistance, allowed, () -> true); + } + + public static Optional nearest(SubterrainPlanner planner, Query query, int x, int worldY, int z, + int maximumDistance, Predicate allowed, BooleanSupplier running) { Objects.requireNonNull(planner, "planner"); Objects.requireNonNull(query, "query"); Objects.requireNonNull(allowed, "allowed"); @@ -51,6 +58,9 @@ public final class SubterrainLocator { for (int offsetX = -ring; offsetX <= ring; offsetX++) { int offsetZStep = Math.abs(offsetX) == ring ? 1 : Math.max(1, ring * 2); for (int offsetZ = -ring; offsetZ <= ring; offsetZ += offsetZStep) { + if (!running.getAsBoolean() || Thread.currentThread().isInterrupted()) { + return Optional.empty(); + } long tileX = (centerTileX + offsetX) * SEARCH_TILE_SIZE; long tileZ = (centerTileZ + offsetZ) * SEARCH_TILE_SIZE; long tileMaxX = tileX + SEARCH_TILE_SIZE - 1L; @@ -107,6 +117,68 @@ public final class SubterrainLocator { return Optional.ofNullable(nearest); } + public static Optional dryLanding(SubterrainPlanner planner, Result target, BooleanSupplier running) { + return dryLanding(planner, target, running, ignored -> true); + } + + public static Optional dryLanding(SubterrainPlanner planner, Result target, BooleanSupplier running, + Predicate allowed) { + for (SubterrainPlan plan : planner.plansForBounds(target.x(), target.z(), target.x(), target.z())) { + if (!plan.id().equals(target.featureId())) { + continue; + } + SubterrainBounds bounds = plan.bounds(); + int radiusLimit = Math.min(512, Math.max(Math.max(target.x() - bounds.minX(), bounds.maxX() - target.x()), + Math.max(target.z() - bounds.minZ(), bounds.maxZ() - target.z()))); + for (int radius = 0; radius <= radiusLimit; radius++) { + if (!running.getAsBoolean() || Thread.currentThread().isInterrupted()) { + return Optional.empty(); + } + int stride = Math.max(1, radius / 8); + for (int offset = -radius; offset <= radius; offset += stride) { + if (!running.getAsBoolean() || Thread.currentThread().isInterrupted()) { + return Optional.empty(); + } + SubterrainPosition landing = eligibleDryLanding(planner, plan, target, target.x() + offset, target.z() - radius, allowed); + if (landing == null && radius != 0) { + landing = eligibleDryLanding(planner, plan, target, target.x() + offset, target.z() + radius, allowed); + } + if (landing == null && radius != 0) { + landing = eligibleDryLanding(planner, plan, target, target.x() - radius, target.z() + offset, allowed); + } + if (landing == null && radius != 0) { + landing = eligibleDryLanding(planner, plan, target, target.x() + radius, target.z() + offset, allowed); + } + if (landing != null) { + return Optional.of(new Result(target.featureId(), target.family(), target.biome(), + landing.x(), landing.y(), landing.z())); + } + } + } + } + return Optional.empty(); + } + + private static SubterrainPosition eligibleDryLanding(SubterrainPlanner planner, SubterrainPlan plan, + Result target, int x, int z, Predicate allowed) { + SubterrainPosition landing = dryOwnedLanding(planner, plan, x, z); + return landing != null && allowed.test(new Result(target.featureId(), target.family(), target.biome(), + landing.x(), landing.y(), landing.z())) ? landing : null; + } + + private static SubterrainPosition dryOwnedLanding(SubterrainPlanner planner, SubterrainPlan plan, int x, int z) { + SubterrainPosition landing = plan.dryLandingAt(x, z); + if (landing == null) { + return null; + } + SubterrainCell feet = planner.sample(landing.x(), landing.y(), landing.z()); + SubterrainCell head = planner.sample(landing.x(), landing.y() + 1, landing.z()); + SubterrainCell floor = planner.sample(landing.x(), landing.y() - 1, landing.z()); + return feet.kind() == SubterrainCell.Kind.AIR && head.kind() == SubterrainCell.Kind.AIR && floor.solid() + && feet.room().featureId().equals(plan.id()) && head.room().featureId().equals(plan.id()) + ? landing : null; + } + public record Query(String featureId, IrisSubterrainFamily family, String biome) { public Query { featureId = Objects.requireNonNullElse(featureId, "").trim(); diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java index 337e5f7b3..3ef31e087 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java @@ -10,6 +10,8 @@ public final class SubterrainPlan { private final boolean rotated; private final String id; private final SubterrainBounds bounds; + private final double shapePhase; + private final ThreadLocal columnCache = ThreadLocal.withInitial(ColumnCache::new); SubterrainPlan(Options options) { definition = Objects.requireNonNull(options.definition()); @@ -18,6 +20,7 @@ public final class SubterrainPlan { centerZ = options.centerZ(); rotated = options.rotated(); id = options.id(); + shapePhase = (SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt()) >>> 11) * 0x1.0p-53 * Math.PI * 2; int along = family() == IrisSubterrainFamily.CENOTE ? definition.radius() : definition.length() / 2; int across = definition.radius(); int extentX = (rotated ? across : along) + 2; @@ -66,35 +69,64 @@ public final class SubterrainPlan { return anchor; } if (family() == IrisSubterrainFamily.TRAVERTINE_TERRACES) { - int path = definition.radius() - 4; - anchor = occupiedAnchor(rotated ? centerX + path : centerX, y, - rotated ? centerZ : centerZ + path); - if (anchor != null) { - return anchor; + Column center = cachedColumn(0, 0); + if (center != null) { + int path = center.pathOffset(); + anchor = occupiedAnchor(rotated ? centerX + path : centerX, y, + rotated ? centerZ : centerZ + path); + if (anchor != null) { + return anchor; + } } - int firstTerrace = -definition.length() / 2 + 1; - anchor = occupiedAnchor(rotated ? centerX + path : centerX + firstTerrace, y, - rotated ? centerZ + firstTerrace : centerZ + path); - if (anchor != null) { - return anchor; + } + for (int u = -definition.length() / 2; u <= definition.length() / 2; u++) { + for (int v = -definition.radius(); v <= definition.radius(); v++) { + int x = rotated ? centerX + v : centerX + u; + int z = rotated ? centerZ + u : centerZ + v; + Column column = family() == IrisSubterrainFamily.LAVA_TUBE ? tube(u, v, centerY) : cachedColumn(u, v); + if (column == null || column.pathDistance() > 2) { + continue; + } + int candidateY = Math.max(column.floor(), column.head()) + 2; + SubterrainCell cell = sample(x, candidateY, z); + if (cell.occupied() && cell.room().reservedPassage()) { + return new SubterrainPosition(x, candidateY, z); + } } } throw new IllegalStateException("Subterrain plan has no occupied anchor: " + id); } + public SubterrainPosition dryLandingAt(int x, int z) { + int u = rotated ? z - centerZ : x - centerX; + int v = rotated ? x - centerX : z - centerZ; + Column column = family() == IrisSubterrainFamily.LAVA_TUBE ? tube(u, v, centerY) : cachedColumn(u, v); + if (column == null) { + return null; + } + for (int y = Math.max(bounds.minY() + 1, column.floor() + 1); + y < Math.min(bounds.maxY(), column.ceiling() - 1); y++) { + if (sample(x, y, z).kind() == SubterrainCell.Kind.AIR + && sample(x, y + 1, z).kind() == SubterrainCell.Kind.AIR + && sample(x, y - 1, z).solid()) { + return new SubterrainPosition(x, y, z); + } + } + return null; + } + public SubterrainCell sample(int x, int y, int z) { if (!bounds.contains(x, y, z)) { return SubterrainCell.OUTSIDE; } int u = rotated ? z - centerZ : x - centerX; int v = rotated ? x - centerX : z - centerZ; - Column column = switch (family()) { - case CENOTE -> cenote(u, v); - case TECTONIC_FAULT -> fault(u, v); - case LAVA_TUBE -> tube(u, v, y); - case TRAVERTINE_TERRACES -> terraces(u, v); - }; - if (column == null || y < column.floor() - 2 || y > column.ceiling() + 2) { + Column column = family() == IrisSubterrainFamily.LAVA_TUBE ? tube(u, v, y) : cachedColumn(u, v); + if (column == null) { + return SubterrainCell.OUTSIDE; + } + int sealFloor = family() == IrisSubterrainFamily.CENOTE ? baseY() : column.floor(); + if (y < sealFloor - 2 || y > column.ceiling() + 2) { return SubterrainCell.OUTSIDE; } SubterrainCell.Kind kind = classify(column, u, v, y); @@ -115,6 +147,28 @@ public final class SubterrainPlan { return new SubterrainCell(kind, material, room); } + private Column cachedColumn(int u, int v) { + ColumnCache cache = columnCache.get(); + long key = ((long) u << 32) ^ (v & 0xffffffffL); + for (int i = 0; i < cache.count; i++) { + if (cache.keys[i] == key) { + return cache.columns[i]; + } + } + Column column = switch (family()) { + case CENOTE -> cenote(u, v); + case TECTONIC_FAULT -> fault(u, v); + case TRAVERTINE_TERRACES -> terraces(u, v); + case LAVA_TUBE -> throw new IllegalStateException("Tube columns depend on height"); + }; + int slot = cache.next; + cache.keys[slot] = key; + cache.columns[slot] = column; + cache.next = (slot + 1) % cache.keys.length; + cache.count = Math.min(cache.keys.length, cache.count + 1); + return column; + } + private SubterrainCell.Kind classify(Column column, int u, int v, int y) { if (!column.interior() || y <= column.floor() || y >= column.ceiling() || column.rim()) { return SubterrainCell.Kind.SOLID; @@ -136,21 +190,36 @@ public final class SubterrainPlan { return false; } int spacing = definition.pillarSpacing(); - if (spacing > 0 && Math.abs(Math.floorMod(u + spacing / 2, spacing) - spacing / 2) <= 1 - && Math.abs(Math.floorMod(v + spacing / 2, spacing) - spacing / 2) <= 1) { - return true; + if (spacing > 0) { + int pillarU = Math.floorDiv(u + spacing / 2, spacing); + int pillarV = Math.floorDiv(v + spacing / 2, spacing); + long pillarHash = SubterrainPlanner.mix(definition.seedSalt() ^ id.hashCode() + ^ pillarU * 73428767L ^ pillarV * 912931L); + int jitter = Math.max(2, spacing / 5); + int offsetU = u - pillarU * spacing - (int) Math.floorMod(pillarHash, jitter * 2 + 1) + jitter; + int offsetV = v - pillarV * spacing - (int) Math.floorMod(pillarHash >>> 8, jitter * 2 + 1) + jitter; + double relativeHeight = (y - column.floor()) / (double) Math.max(1, column.ceiling() - column.floor()); + double taper = relativeHeight * 2 - 1; + double radius = 1.3 + 1.2 * taper * taper; + double leanU = ((pillarHash >>> 16 & 255L) / 255.0 - 0.5) * 2; + double leanV = ((pillarHash >>> 24 & 255L) / 255.0 - 0.5) * 2; + double distanceU = offsetU - leanU * taper; + double distanceV = offsetV - leanV * taper; + if (distanceU * distanceU + distanceV * distanceV <= radius * radius) { + return true; + } } if (column.head() >= y) { return false; } int cellU = Math.floorDiv(u, 12); int cellV = Math.floorDiv(v, 12); - long hash = SubterrainPlanner.mix(definition.seedSalt() ^ cellU * 73428767L ^ cellV * 912931L); + long hash = SubterrainPlanner.mix(definition.seedSalt() ^ id.hashCode() ^ cellU * 73428767L ^ cellV * 912931L); int anchorU = cellU * 12 + 3 + (int) Math.floorMod(hash, 6); int anchorV = cellV * 12 + 3 + (int) Math.floorMod(hash >>> 8, 6); - int distance = Math.max(Math.abs(u - anchorU), Math.abs(v - anchorV)); + double distance = Math.hypot(u - anchorU, v - anchorV); int height = (int) Math.floor((column.ceiling() - column.floor() - 1) * definition.formationFraction()); - int taperHeight = Math.max(0, height - distance * Math.max(1, height / 3)); + int taperHeight = Math.max(0, (int) Math.floor(height - distance * Math.max(1, height / 3))); return distance <= 2 && taperHeight > 0 && (y <= column.floor() + taperHeight || y >= column.ceiling() - taperHeight); } @@ -164,22 +233,25 @@ public final class SubterrainPlan { double normalized = Math.min(1, distance / radius); int ceiling = baseY() + Math.max(3, (int) Math.floor(definition.height() * Math.sqrt(1 - normalized * normalized))); int head = baseY() + definition.fluidDepth(); + double bank = Math.max(0, Math.min(1, (normalized - 0.65) / 0.25)); + double slope = bank * bank * (3 - 2 * bank); + int floor = baseY() + (int) Math.round((definition.fluidDepth() + 2) * slope); boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2; - return new Column(baseY(), ceiling, head, distance < radius, rim, + return new Column(floor, ceiling, head, distance < radius, rim, radius - distance, Math.abs(v), 0); } private Column fault(int u, int v) { - int halfLength = definition.length() / 2; - int radius = definition.radius(); - if (Math.abs(u) > halfLength + 2 || Math.abs(v) > radius + 2) { + Footprint footprint = footprint(u, v); + if (footprint == null) { return null; } - int step = Math.max(2, radius / 4); - int shelf = Math.max(0, (Math.abs(v) - radius / 2) / step) * Math.max(2, definition.height() / 8); - return new Column(baseY() + shelf, baseY() + definition.height(), baseY() - 1, - Math.abs(u) < halfLength && Math.abs(v) < radius, false, - Math.min(halfLength - Math.abs(u), radius - Math.abs(v)), Math.abs(v), 0); + int step = Math.max(2, definition.radius() / 4); + int shelf = Math.max(0, (int) (Math.abs(footprint.offset()) - footprint.width() * 0.5) / step) + * Math.max(2, definition.height() / 8); + int ceiling = vaultedCeiling(u, footprint, baseY() + shelf, baseY() - 1); + return new Column(baseY() + shelf, ceiling, baseY() - 1, + footprint.boundary() > 0, false, footprint.boundary(), Math.abs(footprint.offset()), footprint.path()); } private Column tube(int u, int v, int y) { @@ -190,7 +262,13 @@ public final class SubterrainPlan { if (Math.abs(u) > halfLength + 2 || Math.abs(offset) > width + 2) { return null; } - double arch = Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, Math.abs(offset) / (double) width), 2))); + double normalizedAlong = Math.min(1, Math.abs(u / (double) Math.max(1, halfLength))); + double endTaper = Math.sqrt(Math.max(0, 1 - Math.pow(normalizedAlong, 12))); + double taperedWidth = width * endTaper; + if (Math.abs(offset) > taperedWidth + 2) { + return null; + } + double arch = Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, Math.abs(offset) / Math.max(1, taperedWidth)), 2))) * endTaper; int floor = centerY - Math.max(2, (int) Math.floor(definition.height() * 0.5 * arch)); int ceiling = centerY + Math.max(2, (int) Math.floor(definition.height() * 0.5 * arch)); int head = baseY() + definition.fluidDepth(); @@ -209,48 +287,100 @@ public final class SubterrainPlan { } } boolean rim = definition.fluidDepth() > 0 && floor >= head && floor < head + 2; - return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < width, - rim, Math.min(halfLength - Math.abs(u), width - Math.abs(offset)), Math.abs(offset), path); + return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth, + rim, Math.min(halfLength - Math.abs(u), taperedWidth - Math.abs(offset)), Math.abs(offset), path); } private Column terraces(int u, int v) { - int halfLength = definition.length() / 2; - int radius = definition.radius(); - if (Math.abs(u) > halfLength + 2 || Math.abs(v) > radius + 2) { + Footprint footprint = footprint(u, v); + if (footprint == null) { return null; } - int segment = Math.max(4, definition.length() / definition.terraceCount()); - int index = Math.max(0, Math.min(definition.terraceCount() - 1, Math.floorDiv(u + halfLength, segment))); + double warped = u + definition.radius() * 0.2 * Math.sin(v / (double) Math.max(6, definition.radius()) * 2.4 + shapePhase) + + definition.radius() * 0.08 * Math.sin(u / 29.0 + v / 19.0 + shapePhase); + double segment = Math.max(4.0, definition.length() / (double) definition.terraceCount()); + double terrace = (warped + definition.length() / 2.0) / segment; + int index = Math.max(0, Math.min(definition.terraceCount() - 1, (int) Math.floor(terrace))); int rise = Math.max(1, (definition.height() / 3) / definition.terraceCount()); int floor = baseY() + index * rise; int head = floor + definition.fluidDepth(); - int local = Math.floorMod(u + halfLength, segment); - boolean rim = definition.fluidDepth() > 0 && (local <= 1 || local >= segment - 2 || Math.abs(v) >= radius - 2); - int ceiling = baseY() + definition.height(); - if (rim) { - floor = head + 1; - } - double pathDistance = Math.abs(Math.abs(v) - (radius - 4)); - if (pathDistance <= 2) { + double local = (terrace - Math.floor(terrace)) * segment; + boolean rim = definition.fluidDepth() > 0 && (local <= 1.5 || local >= segment - 1.5 || footprint.boundary() <= 2); + double pathOffset = Math.copySign(Math.max(0, footprint.width() - 4), footprint.offset()); + double pathDistance = Math.abs(footprint.offset() - pathOffset); + if (rim || pathDistance <= 2) { floor = Math.max(floor, head + 1); } - return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(v) < radius, - false, Math.min(halfLength - Math.abs(u), radius - Math.abs(v)), pathDistance, - v < 0 ? -radius + 4 : radius - 4); + int ceiling = vaultedCeiling(u, footprint, floor, head); + return new Column(floor, ceiling, head, footprint.boundary() > 0, + false, footprint.boundary(), pathDistance, footprint.path() + (int) Math.round(pathOffset)); + } + + private Profile profile(int u) { + ColumnCache cache = columnCache.get(); + for (int i = 0; i < cache.profileCount; i++) { + if (cache.profileKeys[i] == u) { + return cache.profiles[i]; + } + } + double along = Math.min(1, Math.abs(u / (double) Math.max(1, definition.length() / 2))); + double taper = Math.sqrt(Math.max(0, 1 - Math.pow(along, 6))); + double width = definition.radius() * taper * (0.83 + 0.08 * Math.sin(u / 31.0 + shapePhase) + + 0.05 * Math.sin(u / 13.0 + shapePhase * 1.7)); + double bend = family() == IrisSubterrainFamily.TECTONIC_FAULT ? 0.08 : 0.12; + int path = (int) Math.round(definition.radius() * bend * Math.sin(u / 47.0 + shapePhase) * taper); + double vaultWave = 0.87 + 0.07 * Math.sin(u / 37.0 + shapePhase) + 0.04 * Math.sin(u / 17.0 - shapePhase); + Profile profile = new Profile(Math.min(width, definition.radius() - Math.abs(path)), path, vaultWave); + int slot = cache.profileNext; + cache.profileKeys[slot] = u; + cache.profiles[slot] = profile; + cache.profileNext = (slot + 1) % cache.profiles.length; + cache.profileCount = Math.min(cache.profiles.length, cache.profileCount + 1); + return profile; + } + + private Footprint footprint(int u, int v) { + int halfLength = definition.length() / 2; + if (Math.abs(u) > halfLength + 2) { + return null; + } + Profile profile = profile(u); + double width = profile.width(); + int path = profile.path(); + double offset = v - path; + double boundary = Math.min(halfLength - Math.abs(u), width - Math.abs(offset)); + if (boundary < -2) { + return null; + } + return new Footprint(width, offset, boundary, path); + } + + private int vaultedCeiling(int u, Footprint footprint, int floor, int head) { + double cross = Math.min(1, Math.abs(footprint.offset()) / Math.max(1, footprint.width())); + double arch = Math.sqrt(Math.max(0, 1 - cross * cross)); + double wave = profile(u).vaultWave(); + double roofVariation = 0.05 * Math.sin(u / 37.0 + footprint.offset() / 17.0 + shapePhase) * arch + + 0.06 * footprint.offset() / Math.max(1, footprint.width()) * Math.sin(u / 41.0 - shapePhase); + int ceiling = baseY() + (int) Math.floor(definition.height() * wave * (0.4 + 0.6 * arch + roofVariation)); + return Math.min(baseY() + definition.height(), Math.max(Math.max(floor, head) + 3, ceiling)); } private SubterrainPosition occupiedAnchor(int x, int preferredY, int z) { - if (sample(x, preferredY, z).occupied()) { + if (anchorCell(sample(x, preferredY, z))) { return new SubterrainPosition(x, preferredY, z); } for (int candidateY = bounds.minY(); candidateY <= bounds.maxY(); candidateY++) { - if (sample(x, candidateY, z).occupied()) { + if (anchorCell(sample(x, candidateY, z))) { return new SubterrainPosition(x, candidateY, z); } } return null; } + private boolean anchorCell(SubterrainCell cell) { + return cell.occupied() && (family() != IrisSubterrainFamily.TRAVERTINE_TERRACES || cell.room().reservedPassage()); + } + private int baseY() { return centerY - definition.height() / 2; } @@ -259,6 +389,23 @@ public final class SubterrainPlan { boolean rotated, String id) { } + private static final class ColumnCache { + private final long[] keys = new long[8]; + private final Column[] columns = new Column[8]; + private final int[] profileKeys = new int[3]; + private final Profile[] profiles = new Profile[3]; + private int profileCount; + private int profileNext; + private int count; + private int next; + } + + private record Profile(double width, int path, double vaultWave) { + } + + private record Footprint(double width, double offset, double boundary, int path) { + } + private record Column(int floor, int ceiling, int head, boolean interior, boolean rim, double boundary, double pathDistance, int pathOffset) { } diff --git a/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java index b84bf94f8..fddf19988 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java @@ -27,6 +27,9 @@ import art.arcane.iris.integration.ExternalDataSVC; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.locator.Locator; +import art.arcane.iris.generation.locator.BiomeLocator; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.structure.placement.LootResolver; import art.arcane.iris.structure.placement.PlacedObject; import art.arcane.iris.generation.runtime.WrongEngineBroException; @@ -600,7 +603,7 @@ public final class EngineBukkitOps { } public static void gotoBiome(Engine engine, IrisBiome biome, Player player, boolean teleport) { - find(engine, Locator.surfaceBiome(biome.getLoadKey()), player, teleport, "Biome " + biome.getName()); + find(engine, BiomeLocator.forBiome(engine, biome.getLoadKey(), player.getLocation().getBlockY()), player, teleport, "Biome " + biome.getName()); } public static void gotoObject(Engine engine, String s, Player player, boolean teleport) { @@ -625,12 +628,14 @@ public final class EngineBukkitOps { } private static void find(Engine engine, Locator locator, Player player, boolean teleport, String message) { - find(engine, locator, player, 120_000, location -> { + find(engine, locator, player, 120_000, teleport, location -> { if (location == null) { ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_COULD_NOT_FIND_WITHIN_SEARCH_RANGE, MessageArgument.untrusted("message", String.valueOf(message)))); return; } - if (teleport) { + if (teleport && locator instanceof BiomeLocator) { + teleportToCave(player, location, message); + } else if (teleport) { J.runEntity(player, () -> teleportAsyncSafely(player, location)); ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_TELEPORTING, MessageArgument.untrusted("message", String.valueOf(message)))); } else { @@ -639,7 +644,7 @@ public final class EngineBukkitOps { }); } - private static void find(Engine engine, Locator locator, Player player, long timeout, Consumer consumer) { + private static void find(Engine engine, Locator locator, Player player, long timeout, boolean resolveLanding, Consumer consumer) { AtomicLong checks = new AtomicLong(); long ms = M.ms(); World world = player.getWorld(); @@ -667,8 +672,15 @@ public final class EngineBukkitOps { try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_locator_result"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { int by = engine.getMinHeight() + engine.getHeight(bx, bz, false) + 2; + Location destination = new Location(world, bx, by, bz); + if (locator instanceof BiomeLocator caveLocator) { + SubterrainPosition target = caveLocator.position(engine, at); + destination = target == null ? null : resolveLanding + ? caveDestination(engine, caveLocator, world, target).get() + : new Location(world, target.x(), target.y(), target.z()); + } resultDispatched = dispatchLocateResult( - player, world, consumer, new Location(world, bx, by, bz), playerId, search); + player, world, consumer, destination, playerId, search); } } else { resultDispatched = dispatchLocateResult(player, world, consumer, null, playerId, search); @@ -678,6 +690,14 @@ public final class EngineBukkitOps { Thread.currentThread().interrupt(); } catch (WrongEngineBroException | ExecutionException e) { IrisLogging.reportError(e); + if (search != null) { + resultDispatched = dispatchLocateFailure(player, world, playerId, search); + } + } catch (RuntimeException e) { + IrisLogging.reportError(e); + if (search != null) { + resultDispatched = dispatchLocateFailure(player, world, playerId, search); + } } finally { if (search != null && !resultDispatched) { ACTIVE_LOCATE_REQUESTS.remove(playerId, search); @@ -704,6 +724,43 @@ public final class EngineBukkitOps { }.execute(new VolmitSender(player)); } + static CompletableFuture caveDestination(Engine engine, BiomeLocator locator, World world, + SubterrainPosition target) { + CompletableFuture destination = new CompletableFuture<>(); + BukkitPlatform.chunkAtAsync(world, target.x() >> 4, target.z() >> 4, true) + .thenCompose(chunk -> locator.prepareRead(engine, new Position2(target.x() >> 4, target.z() >> 4))) + .whenComplete((read, error) -> { + if (error != null) { + destination.completeExceptionally(error); + return; + } + boolean scheduled = J.runRegion(world, target.x() >> 4, target.z() >> 4, () -> { + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_cave_landing"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + SubterrainPosition landing = locator.landing(engine, engine.getWorld().platformWorld(), target, read); + destination.complete(landing == null ? null + : new Location(world, landing.x() + 0.5, landing.y(), landing.z() + 0.5)); + } catch (Throwable failure) { + destination.completeExceptionally(failure); + } + }); + if (!scheduled) { + destination.completeExceptionally(new IllegalStateException("Could not schedule cave landing lookup")); + } + }); + return destination; + } + + private static boolean dispatchLocateFailure(Player player, World world, UUID playerId, + CompletableFuture search) { + return J.runEntity(player, () -> { + if (!ACTIVE_LOCATE_REQUESTS.remove(playerId, search) || !player.isOnline() || !world.equals(player.getWorld())) { + return; + } + ComponentMessenger.sendSection(player, "Could not complete the search. See the server console for details."); + }); + } + private static boolean dispatchLocateResult(Player player, World world, Consumer consumer, Location location, UUID playerId, CompletableFuture search) { @@ -722,6 +779,30 @@ public final class EngineBukkitOps { return scheduled; } + static void teleportToCave(Player player, Location location, String message) { + try { + player.teleportAsync(location).whenComplete((teleported, error) -> { + if (error != null) { + IrisLogging.reportError(error); + } + J.runEntity(player, () -> { + if (!player.isOnline()) { + return; + } + if (error != null || !Boolean.TRUE.equals(teleported)) { + ComponentMessenger.sendSection(player, "Could not teleport to " + message + "."); + } else { + ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_TELEPORTING, + MessageArgument.untrusted("message", message))); + } + }); + }); + } catch (Throwable error) { + IrisLogging.reportError(error); + ComponentMessenger.sendSection(player, "Could not teleport to " + message + "."); + } + } + private static void teleportAsyncSafely(Player player, Location location) { if (player == null || location == null) { return; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationFindCatalog.java b/core/src/main/java/art/arcane/iris/world/history/GenerationFindCatalog.java index 16b80b88f..5f4b62fd2 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationFindCatalog.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationFindCatalog.java @@ -30,6 +30,14 @@ public final class GenerationFindCatalog { return find(biomes(engine), key); } + public static boolean hasRetainedCaveBiome(Engine engine, String key) { + return containsKey(retained(engine).caveBiomes, normalizeKey(key)); + } + + public static boolean hasRetainedSurfaceBiome(Engine engine, String key) { + return containsKey(retained(engine).surfaceBiomes, normalizeKey(key)); + } + public static KList regions(Engine engine) { return merge(engine.getDimension().getAllRegions(engine), retained(engine).regions); } @@ -96,9 +104,11 @@ public final class GenerationFindCatalog { return; } for (String key : semantics.surfaceBiomeKeys()) { + catalog.surfaceBiomes.add(normalizeKey(key)); addBiome(catalog, key); } for (String key : semantics.caveBiomeKeys()) { + catalog.caveBiomes.add(normalizeKey(key)); addBiome(catalog, key); } for (String key : semantics.regionKeys()) { @@ -181,6 +191,8 @@ public final class GenerationFindCatalog { private static final class RetainedCatalog { private final long activeActivationId; private final Map biomes = new LinkedHashMap<>(); + private final Set surfaceBiomes = new LinkedHashSet<>(); + private final Set caveBiomes = new LinkedHashSet<>(); private final Map regions = new LinkedHashMap<>(); private final Set objects = new LinkedHashSet<>(); private final Set structures = new LinkedHashSet<>(); diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java index bf596718e..83857db64 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java @@ -26,6 +26,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; +import java.util.function.Predicate; public final class GenerationHistory { private static final int MAXIMUM_CACHED_BOUNDARIES = 4; @@ -848,6 +849,12 @@ public final class GenerationHistory { return semantics.findNearest(query, ChunkGenerationSemantics::sealed); } + public synchronized Optional findRecorded( + GenerationSemanticIndex.Query query, Predicate eligibility + ) { + return semantics.findNearest(query, record -> record.sealed() && eligibility.test(record)); + } + public synchronized Optional findRecordedRiver( GenerationSemanticIndex.RiverQuery query ) { diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java index c7eee0905..6b0a9ff5c 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java @@ -18,6 +18,7 @@ import java.util.Optional; import java.util.OptionalLong; import java.util.function.IntConsumer; import java.util.function.Predicate; +import java.util.function.BooleanSupplier; public final class GenerationSemanticQueries { private GenerationSemanticQueries() { @@ -25,12 +26,25 @@ public final class GenerationSemanticQueries { public static Optional nearestSubterrain( Engine engine, SubterrainLocator.Query query, int x, int worldY, int z, int maximumDistance) { + return nearestSubterrain(engine, query, x, worldY, z, maximumDistance, () -> true); + } + + public static Optional nearestSubterrain( + Engine engine, SubterrainLocator.Query query, int x, int worldY, int z, int maximumDistance, + BooleanSupplier running) { + return nearestSubterrain(engine, query, x, worldY, z, maximumDistance, running, ignored -> true); + } + + public static Optional nearestSubterrain( + Engine engine, SubterrainLocator.Query query, int x, int worldY, int z, int maximumDistance, + BooleanSupplier running, Predicate allowed) { Engine requiredEngine = Objects.requireNonNull(engine, "engine"); Objects.requireNonNull(query, "query"); GenerationHistoryRuntimeRouter router = requiredEngine instanceof IrisEngine irisEngine ? irisEngine.getGenerationHistoryRuntimeRouter().orElse(null) : null; if (router == null) { - return SubterrainLocator.nearest(requiredEngine, query, x, worldY, z, maximumDistance); + return SubterrainLocator.nearest(requiredEngine.getComplex().getSubterrainPlanner(), query, x, worldY, z, + maximumDistance, allowed, running); } GenerationHistory history = router.history(); SubterrainLocator.Result nearest = null; @@ -43,7 +57,7 @@ public final class GenerationSemanticQueries { SubterrainPlanner planner = router.biomes().subterrainPlanner(activation.activationId()); SubterrainLocator.Result candidate = SubterrainLocator.nearest(planner, query, x, worldY, z, maximumDistance, result -> allowsSubterrainPrediction(requiredEngine, history, - activation.activationId(), result)).orElse(null); + activation.activationId(), result) && allowed.test(result), running).orElse(null); if (candidate != null && (nearest == null || distanceSquared(x, worldY, z, candidate) .compareTo(distanceSquared(x, worldY, z, nearest)) < 0)) { nearest = candidate; @@ -55,6 +69,24 @@ public final class GenerationSemanticQueries { return Optional.ofNullable(nearest); } + public static Optional drySubterrainLanding( + Engine engine, SubterrainLocator.Result target, BooleanSupplier running) { + GenerationHistoryRuntimeRouter router = engine instanceof IrisEngine irisEngine + ? irisEngine.getGenerationHistoryRuntimeRouter().orElse(null) : null; + if (router == null) { + return SubterrainLocator.dryLanding(engine.getComplex().getSubterrainPlanner(), target, running); + } + GenerationHistory history = router.history(); + GenerationActivation owner = history.resolveActivation(target.x() >> 4, target.z() >> 4); + try { + SubterrainPlanner planner = router.biomes().subterrainPlanner(owner.activationId()); + return SubterrainLocator.dryLanding(planner, target, running, + landing -> allowsSubterrainPrediction(engine, history, owner.activationId(), landing)); + } catch (IOException failure) { + throw new UncheckedIOException("Unable to resolve saved subterrain landing", failure); + } + } + private static boolean allowsSubterrainPrediction(Engine engine, GenerationHistory history, long activationId, SubterrainLocator.Result result) { int chunkX = Math.floorDiv(result.x(), 16); diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java index 3122b1f19..8f6107a12 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java @@ -120,6 +120,15 @@ public final class SavedBiomeRuntime implements AutoCloseable { } } + public CompletableFuture readChunkAsync(int chunkX, int chunkZ) { + return queryAsync(chunkX, chunkZ).thenApply(prepared -> { + if (prepared.failure() != null) { + throw prepared.failure(); + } + return new ReadSession(new ReadOptions(this, prepared, chunkX, chunkZ)); + }); + } + public boolean mayHaveBlockDropRules(String material) { if (engine.getData().hasBlockDropRules(material)) { return true; @@ -573,6 +582,32 @@ public final class SavedBiomeRuntime implements AutoCloseable { return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); } + public static final class ReadSession { + private final ReadOptions options; + + private ReadSession(ReadOptions options) { + this.options = options; + } + + public Optional resolve(int blockX, int worldY, int blockZ, boolean surface) { + if (blockX >> 4 != options.chunkX() || blockZ >> 4 != options.chunkZ()) { + throw new IllegalArgumentException("Saved biome read coordinates must remain inside the prepared chunk"); + } + SavedBiomeRuntime runtime = options.runtime(); + runtime.consumption.readLock().lock(); + try { + runtime.requireOpen(); + return runtime.resolve(options.query(), blockX, worldY, blockZ, + surface ? QueryKind.SURFACE : QueryKind.VOLUME); + } finally { + runtime.consumption.readLock().unlock(); + } + } + } + + private record ReadOptions(SavedBiomeRuntime runtime, PreparedQuery query, int chunkX, int chunkZ) { + } + private record PreparedQuery(Optional chunk, Map environments, Map cellFailures, diff --git a/core/src/test/java/art/arcane/iris/generation/locator/BiomeLocatorTest.java b/core/src/test/java/art/arcane/iris/generation/locator/BiomeLocatorTest.java new file mode 100644 index 000000000..b1b3cdc9f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/locator/BiomeLocatorTest.java @@ -0,0 +1,270 @@ +package art.arcane.iris.generation.locator; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.SavedBiomeRuntime; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationSemanticIndex; +import art.arcane.iris.world.history.ChunkGenerationSemantics; +import java.util.function.Predicate; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.ArgumentMatchers.any; + +public class BiomeLocatorTest { + @Test + public void unownedActivePredictionDoesNotQueueSavedBiomeReads() { + Fixture fixture = new Fixture(); + IrisEngine engine = (IrisEngine) fixture.engine; + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + GenerationHistory history = mock(GenerationHistory.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(router.biomes()).thenReturn(saved); + when(history.isActiveUnowned(0, 0)).thenReturn(true); + when(history.semantics(0, 0)).thenReturn(Optional.empty()); + when(engine.getHeight()).thenReturn(384); + when(engine.getCaveBiome(8, 44, 8)).thenReturn(biome("deep")); + assertEquals(new SubterrainPosition(8, -20, 8), fixture.locator.position(engine, new Position2(0, 0))); + verify(saved, never()).readChunkAsync(0, 0); + } + + @Test + public void recordedPositionReadsSavedVerticalIdentityWithoutActiveDefinition() { + Fixture fixture = new Fixture(); + IrisEngine retained = (IrisEngine) fixture.engine; + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + BiomeEnvironment environment = mock(BiomeEnvironment.class); + when(retained.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(mock(GenerationHistory.class)); + when(router.biomes()).thenReturn(saved); + when(environment.biome()).thenReturn(biome("deep")); + when(saved.resolve(8, -20, 8, false)).thenReturn(Optional.of(environment)); + SavedBiomeRuntime.ReadSession read = mock(SavedBiomeRuntime.ReadSession.class); + when(saved.readChunkAsync(0, 0)).thenReturn(CompletableFuture.completedFuture(read)); + when(read.resolve(8, -20, 8, false)).thenReturn(Optional.of(environment)); + when(retained.getHeight()).thenReturn(384); + assertEquals(new SubterrainPosition(8, -20, 8), fixture.locator.position(retained, new Position2(0, 0))); + verify(retained, never()).getCaveBiome(8, 44, 8); + } + + @Test + public void recordedMetadataWithoutVolumeMatchDoesNotBoundSearch() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + GenerationHistory history = mock(GenerationHistory.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + Position2 near = new Position2(1, 0); + Position2 far = new Position2(4, 0); + BiomeLocator locator = spy(new BiomeLocator("deep", false)); + doReturn(null).when(locator).position(engine, near); + doReturn(new SubterrainPosition(65, -20, 3)).when(locator).position(engine, far); + ChunkGenerationSemantics nearRecord = mock(ChunkGenerationSemantics.class); + when(nearRecord.chunkX()).thenReturn(1); + GenerationSemanticIndex.Match nearMatch = new GenerationSemanticIndex.Match( + GenerationSemanticIndex.SemanticKind.CAVE_BIOME, "deep", + new GenerationSemanticIndex.ChunkReference(1, 0, 1), Optional.empty()); + GenerationSemanticIndex.Match farMatch = new GenerationSemanticIndex.Match( + GenerationSemanticIndex.SemanticKind.CAVE_BIOME, "deep", + new GenerationSemanticIndex.ChunkReference(4, 0, 1), Optional.empty()); + when(history.findRecorded(any(), any())).thenAnswer(invocation -> { + Predicate eligible = invocation.getArgument(1); + return Optional.of(eligible.test(nearRecord) ? nearMatch : farMatch); + }); + Locator.SearchCandidate candidate = locator.nearestRecordedCandidate(engine, new Position2(0, 0), 20); + assertEquals(far, candidate.chunk()); + assertEquals(65, candidate.blockX()); + verify(locator).position(engine, near); + verify(locator).position(engine, far); + } + + @Test + public void mantleCandidatesRequireDryCavernAndCanonicalVolumeIdentity() { + Fixture fixture = new Fixture(); + when(fixture.engine.getCaveOrMantleBiome(8, 44, 8)).thenReturn(biome("deep")); + when(fixture.engine.getBiomeOrMantle(8, 44, 8)).thenReturn(biome("surface")); + assertNull(fixture.locator.cavernPosition(fixture.engine, 8, 44, 8, new MatterCavern(true, "", (byte) 0))); + when(fixture.engine.getBiomeOrMantle(8, 44, 8)).thenReturn(biome("deep")); + assertNull(fixture.locator.cavernPosition(fixture.engine, 8, 44, 8, new MatterCavern(false, "", (byte) 0))); + assertNull(fixture.locator.cavernPosition(fixture.engine, 8, 44, 8, new MatterCavern(true, "", (byte) 1))); + assertNull(fixture.locator.cavernPosition(fixture.engine, 8, 44, 8, new MatterCavern(true, "", (byte) 2))); + assertEquals(new SubterrainPosition(8, -20, 8), fixture.locator.cavernPosition(fixture.engine, 8, 44, 8, + new MatterCavern(true, "", (byte) 3))); + } + + @Test + public void recordedCavePositionFindsBiomeAwayFromChunkCenter() { + Fixture fixture = new Fixture(); + IrisEngine retained = (IrisEngine) fixture.engine; + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + SavedBiomeRuntime.ReadSession read = mock(SavedBiomeRuntime.ReadSession.class); + BiomeEnvironment environment = mock(BiomeEnvironment.class); + when(environment.biome()).thenReturn(biome("deep")); + when(retained.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(mock(GenerationHistory.class)); + when(router.biomes()).thenReturn(saved); + when(saved.readChunkAsync(0, 0)).thenReturn(CompletableFuture.completedFuture(read)); + when(read.resolve(0, -20, 0, false)).thenReturn(Optional.of(environment)); + when(retained.getHeight()).thenReturn(384); + IrisDimension dimension = mock(IrisDimension.class); + when(retained.getDimension()).thenReturn(dimension); + when(dimension.getAllRegions(retained)).thenReturn(new KList<>(new IrisRegion().setCaveBiomes(new KList<>("deep")))); + when(dimension.getCarving()).thenReturn(new KList<>()); + when(dimension.getSubterrainFeatures()).thenReturn(new KList<>()); + when(retained.getAllBiomes()).thenReturn(new KList<>()); + when(retained.getData()).thenReturn(mock(IrisData.class, RETURNS_DEEP_STUBS)); + when(retained.getData().getBiomeLoader().load("deep")).thenReturn(biome("deep")); + when(retained.getCaveBiome(8, 8)).thenReturn(biome("other")); + when(retained.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty()); + BiomeLocator locator = (BiomeLocator) BiomeLocator.forBiome(retained, "deep", 59); + when(retained.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + assertEquals(new SubterrainPosition(0, -20, 0), locator.position(retained, new Position2(0, 0))); + verify(read).resolve(0, -20, 0, false); + } + + @Test + public void liveLandingReadsExactRetainedBiomeDespiteDifferentActiveBiome() { + Fixture fixture = new Fixture(); + IrisEngine retained = (IrisEngine) fixture.engine; + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + BiomeEnvironment environment = mock(BiomeEnvironment.class); + when(retained.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(mock(GenerationHistory.class)); + when(router.biomes()).thenReturn(saved); + when(environment.biome()).thenReturn(biome("deep")); + SavedBiomeRuntime.ReadSession read = mock(SavedBiomeRuntime.ReadSession.class); + when(read.resolve(8, -20, 8, false)).thenReturn(Optional.of(environment)); + when(retained.getBiomeOrMantle(8, 44, 8)).thenReturn(biome("active-surface")); + assertEquals(new SubterrainPosition(8, -20, 8), fixture.locator.landing(retained, fixture.world, + new SubterrainPosition(8, -15, 8), Optional.of(read))); + verify(retained, never()).getBiomeOrMantle(8, 44, 8); + verify(read).resolve(8, -20, 8, false); + verify(saved, never()).resolve(8, -20, 8, false); + } + + @Test + public void registeredCarvingBiomeUsesCaveSearchAndSurfaceKeepsItsLocator() { + Engine engine = catalogEngine(); + IrisBiome surface = biome("surface"); + surface.setCarvingBiome("cave"); + when(engine.getAllBiomes()).thenReturn(new KList<>(surface, biome("cave"))); + assertTrue(BiomeLocator.forBiome(engine, "cave", 80) instanceof BiomeLocator); + assertFalse(BiomeLocator.forBiome(engine, "surface", 80) instanceof BiomeLocator); + } + + @Test + public void sharedSurfaceAndCaveKeyStillReturnsSurfacePosition() { + Engine engine = catalogEngine(); + IrisRegion region = new IrisRegion().setLandBiomes(new KList<>("shared")).setCaveBiomes(new KList<>("shared")); + when(engine.getDimension().getAllRegions(engine)).thenReturn(new KList<>(region)); + IrisBiome shared = biome("shared"); + when(engine.getData().getBiomeLoader().load("shared")).thenReturn(shared); + when(engine.getSurfaceBiome(8, 8)).thenReturn(shared); + when(engine.getHeight(8, 8, false)).thenReturn(100); + BiomeLocator locator = (BiomeLocator) BiomeLocator.forBiome(engine, "shared", 80); + assertTrue(locator.matches(engine, new Position2(0, 0))); + assertEquals(new SubterrainPosition(8, 102, 8), locator.position(engine, new Position2(0, 0))); + } + + private static Engine catalogEngine() { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + IrisDimension dimension = mock(IrisDimension.class); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getCarving()).thenReturn(new KList<>()); + when(dimension.getSubterrainFeatures()).thenReturn(new KList<>()); + when(engine.getAllBiomes()).thenReturn(new KList<>()); + return engine; + } + + @Test + public void depthBiomeMatchRetainsAbsoluteNegativeY() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(engine.getHeight(8, 8)).thenReturn(100); + IrisBiome cave = biome("deep"); + when(engine.getCaveBiome(8, 11, 8)).thenReturn(cave); + BiomeLocator locator = new BiomeLocator("deep", false); + assertEquals(new SubterrainPosition(8, -53, 8), locator.position(engine, new Position2(0, 0))); + assertNull(new BiomeLocator("other", false).position(engine, new Position2(0, 0))); + } + + @Test + public void landingUsesActualCaveFloorAndExactBiomeInsteadOfSurface() { + Fixture fixture = new Fixture(); + assertEquals(new SubterrainPosition(8, -20, 8), fixture.locator.landing(fixture.engine, fixture.world, + new SubterrainPosition(8, -15, 8), Optional.empty())); + when(fixture.engine.getBiomeOrMantle(8, 44, 8)).thenReturn(biome("surface")); + assertNull(fixture.locator.landing(fixture.engine, fixture.world, new SubterrainPosition(8, -15, 8), Optional.empty())); + } + + @Test + public void fluidAndHazardousFloorsCannotBeUsedAsLanding() { + Fixture fixture = new Fixture(); + when(fixture.floor.isWaterLogged()).thenReturn(true); + assertNull(fixture.locator.landing(fixture.engine, fixture.world, new SubterrainPosition(8, -15, 8), Optional.empty())); + when(fixture.floor.isWaterLogged()).thenReturn(false); + when(fixture.floor.materialKey()).thenReturn("minecraft:magma_block"); + assertNull(fixture.locator.landing(fixture.engine, fixture.world, new SubterrainPosition(8, -15, 8), Optional.empty())); + } + + private static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + private static final class Fixture { + private final Engine engine = mock(IrisEngine.class); + private final NativeWorld world = mock(NativeWorld.class); + private final NativeBlockState floor = mock(NativeBlockState.class); + private final BiomeLocator locator = new BiomeLocator("deep", false); + + private Fixture() { + NativeBlockState air = mock(NativeBlockState.class); + when(air.isAir()).thenReturn(true); + when(floor.isSolid()).thenReturn(true); + when(floor.materialKey()).thenReturn("minecraft:stone"); + when(world.minHeight()).thenReturn(-64); + when(world.maxHeight()).thenReturn(320); + when(world.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(floor); + when(world.getBlock(8, -20, 8)).thenReturn(air); + when(world.getBlock(8, -19, 8)).thenReturn(air); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt())).thenReturn(100); + when(engine.getBiomeOrMantle(8, 44, 8)).thenReturn(biome("deep")); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java index 68413a783..ffc092466 100644 --- a/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.IrisSubterrainFluid; import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.pack.value.IrisRange; import org.junit.Test; @@ -20,6 +21,31 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public class SubterrainLocatorTest { + @Test + public void wetCenoteSelectsDryOwnedBankOutsideCenterChunk() { + SubterrainPlanner water = wetCenote(IrisSubterrainFluid.WATER); + SubterrainLocator.Result center = SubterrainLocator.nearest(water, + new SubterrainLocator.Query("basin", null, "wet-cave"), 0, 0, 0, 4096, ignored -> true).orElseThrow(); + SubterrainCell centerAir = water.sample(center.x(), center.y(), center.z()); + assertEquals(SubterrainCell.Kind.WATER, water.sample(center.x(), centerAir.room().fluidHeadY(), center.z()).kind()); + SubterrainLocator.Result bank = SubterrainLocator.dryLanding(water, center, () -> true).orElseThrow(); + assertTrue(center.x() >> 4 != bank.x() >> 4 || center.z() >> 4 != bank.z() >> 4); + assertEquals(SubterrainCell.Kind.AIR, water.sample(bank.x(), bank.y(), bank.z()).kind()); + assertEquals(SubterrainCell.Kind.AIR, water.sample(bank.x(), bank.y() + 1, bank.z()).kind()); + assertTrue(water.sample(bank.x(), bank.y() - 1, bank.z()).solid()); + assertEquals(center.featureId(), water.sample(bank.x(), bank.y(), bank.z()).room().featureId()); + SubterrainPlanner lava = wetCenote(IrisSubterrainFluid.LAVA); + assertEquals(bank, SubterrainLocator.dryLanding(lava, center, () -> true).orElseThrow()); + assertFalse(SubterrainLocator.dryLanding(water, center, () -> false).isPresent()); + } + + private static SubterrainPlanner wetCenote(IrisSubterrainFluid fluid) { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave") + .setProbability(1).setWorldYRange(new IrisRange(-100, 100)).setRadius(28).setHeight(40) + .setFluidDepth(8).setFluid(fluid).setPillarSpacing(0).setFormationFraction(0); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -128, 256)); + } + @Test public void wideDistanceSearchReturnsOwnedAbsolutePositionWithoutGenerating() { SubterrainPlanner planner = planner(); @@ -50,6 +76,12 @@ public class SubterrainLocatorTest { new SubterrainLocator.Query("", null, ""), 0, 0, 0, 1024, ignored -> false).isPresent()); } + @Test + public void cancelledSearchStopsBeforeInspectingPlans() { + assertFalse(SubterrainLocator.nearest(planner(), new SubterrainLocator.Query("", null, "wet-cave"), + 0, 0, 0, 32768, ignored -> true, () -> false).isPresent()); + } + @Test public void repeatedSearchAndLargeNegativeCoordinatesAreStable() { Engine engine = engine(planner()); diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java index f1ca9ded1..ae60f78d3 100644 --- a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java @@ -8,6 +8,8 @@ import org.junit.Test; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.HashSet; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -23,9 +25,6 @@ public class SubterrainPlannerTest { for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { IrisSubterrainFeature feature = compactFeature(IrisSubterrainFamily.TRAVERTINE_TERRACES).setFluid(fluid); SubterrainPlan pillar = direct(feature); - for (int y = pillar.bounds().minY(); y <= pillar.bounds().maxY(); y++) { - assertFalse(pillar.sample(0, y, 0).occupied()); - } SubterrainPosition pillarAnchor = pillar.anchor(); assertTrue(pillar.sample(pillarAnchor.x(), pillarAnchor.y(), pillarAnchor.z()).occupied()); assertTrue(pillar.sample(pillarAnchor.x(), pillarAnchor.y(), pillarAnchor.z()).room().reservedPassage()); @@ -173,17 +172,81 @@ public class SubterrainPlannerTest { } @Test - public void faultWallsRemainParallelBeyondTwoHundredBlocksAndHaveShelves() { - SubterrainPlan plan = direct(feature(IrisSubterrainFamily.TECTONIC_FAULT)); - for (int x = -110; x <= 110; x++) { - assertTrue(plan.sample(x, 0, 0).occupied()); - assertTrue(plan.sample(x, 0, 32).solid()); - assertTrue(plan.sample(x, 0, -32).solid()); + public void faultsAndTerracesHaveTaperedIrregularWallsAndVaultedCeilings() { + for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.TECTONIC_FAULT, + IrisSubterrainFamily.TRAVERTINE_TERRACES)) { + SubterrainPlan plan = direct(feature(family)); + Set widths = new HashSet<>(); + Set ceilings = new HashSet<>(); + for (int x = -100; x <= 100; x += 5) { + int width = 0; + for (int z = -32; z <= 32; z++) { + SubterrainCell cell = plan.sample(x, 0, z); + if (cell.occupied()) { + width++; + ceilings.add(cell.room().ceilingY()); + } + } + widths.add(width); + } + assertTrue(family.toString(), widths.size() > 4); + assertTrue(family.toString(), ceilings.size() > 5); + assertFalse(plan.sample(120, 0, 28).occupied()); + assertFalse(plan.sample(-120, 0, -28).occupied()); + assertTrue(plan.sample(0, 0, 0).room().ceilingY() > plan.sample(0, 0, 20).room().ceilingY()); } - int centralFloor = plan.sample(0, 0, 0).room().floorY(); - int ledgeFloor = plan.sample(0, 0, 28).room().floorY(); - assertTrue(ledgeFloor > centralFloor); - assertTrue(plan.sample(0, ledgeFloor, 28).solid()); + } + + @Test + public void organicShapesRemainSealedInsideMaximumBoundsAcrossSeedsAndRotation() { + for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.TECTONIC_FAULT, + IrisSubterrainFamily.TRAVERTINE_TERRACES, IrisSubterrainFamily.LAVA_TUBE)) { + IrisSubterrainFeature feature = feature(family).setLength(2048).setRadius(256).setSpacing(4096); + for (int seed = 0; seed < 4; seed++) { + for (boolean rotated : List.of(false, true)) { + SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options( + SubterrainPlanner.Definition.from(feature), 0, 0, 0, rotated, feature.getId() + ":" + seed)); + SubterrainBounds bounds = plan.bounds(); + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + for (int x = bounds.minX(); x <= bounds.maxX(); x += 13) { + assertFalse(plan.sample(x, y, bounds.minZ()).occupied()); + assertFalse(plan.sample(x, y, bounds.maxZ()).occupied()); + } + for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 13) { + assertFalse(plan.sample(bounds.minX(), y, z).occupied()); + assertFalse(plan.sample(bounds.maxX(), y, z).occupied()); + } + } + } + } + } + } + + @Test + public void organicGeometryVariesPerPlanAndRotatesWithoutChangingOccupancy() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.TRAVERTINE_TERRACES); + SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature); + SubterrainPlan original = new SubterrainPlan(new SubterrainPlan.Options(definition, 0, 0, 0, false, "terrace:one")); + SubterrainPlan rotated = new SubterrainPlan(new SubterrainPlan.Options(definition, 0, 0, 0, true, "terrace:one")); + SubterrainPlan another = new SubterrainPlan(new SubterrainPlan.Options(definition, 0, 0, 0, false, "terrace:two")); + int changed = 0; + for (int x = -120; x <= 120; x += 4) { + for (int z = -30; z <= 30; z += 3) { + for (int y = -16; y <= 16; y += 4) { + SubterrainCell cell = original.sample(x, y, z); + SubterrainCell turned = rotated.sample(z, y, x); + assertEquals(cell.kind(), turned.kind()); + if (cell.owned()) { + assertEquals(cell.room().floorY(), turned.room().floorY()); + assertEquals(cell.room().ceilingY(), turned.room().ceilingY()); + } + if (cell.kind() != another.sample(x, y, z).kind()) { + changed++; + } + } + } + } + assertTrue(changed > 100); } @Test @@ -217,9 +280,17 @@ public class SubterrainPlannerTest { assertTrue(plan.sample(90, 0, 0).room().floorY() > plan.sample(-90, 0, 0).room().floorY()); SubterrainPlanner planner = planner(feature(IrisSubterrainFamily.TRAVERTINE_TERRACES)); assertTrue(assertRetained(planner, List.of(plan), SubterrainCell.Kind.WATER) > 1000); - int rimX = -128 + 42; - assertTrue(plan.sample(rimX, -10, 0).solid()); - assertTrue(plan.sample(0, 0, 28).room().floorY() > plan.sample(0, 0, 28).room().fluidHeadY()); + Set rimLocations = new HashSet<>(); + for (int z = -16; z <= 16; z += 4) { + for (int x = -90; x < -35; x++) { + SubterrainCell cell = plan.sample(x, -10, z); + if (cell.owned() && cell.room().floorY() > cell.room().fluidHeadY()) { + rimLocations.add(x); + break; + } + } + } + assertTrue(rimLocations.size() > 2); } @Test @@ -227,8 +298,21 @@ public class SubterrainPlannerTest { IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setPillarSpacing(12).setFormationFraction(0.3); SubterrainPlan plan = direct(feature); SubterrainRoom room = plan.sample(12, 0, 12).room(); - for (int y = room.floorY(); y <= room.ceilingY(); y++) { - assertTrue("pillar y=" + y, plan.sample(12, y, 12).solid()); + Set previous = new HashSet<>(); + for (int y = room.floorY() + 1; y < room.ceilingY(); y++) { + Set layer = new HashSet<>(); + for (int x = 7; x <= 17; x++) { + for (int z = 7; z <= 17; z++) { + if (plan.sample(x, y, z).solid()) { + layer.add(new SubterrainPosition(x, 0, z)); + } + } + } + assertFalse("pillar layer y=" + y, layer.isEmpty()); + if (!previous.isEmpty()) { + assertTrue("pillar connected y=" + y, layer.stream().anyMatch(previous::contains)); + } + previous = layer; } assertTrue(plan.sample(0, 0, 0).room().reservedPassage()); assertFalse(plan.sample(0, 0, 0).solid()); diff --git a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsCaveDestinationTest.java b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsCaveDestinationTest.java new file mode 100644 index 000000000..77ab140ea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsCaveDestinationTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.locator.BiomeLocator; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.SavedBiomeRuntime; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CompletableFuture; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class EngineBukkitOpsCaveDestinationTest { + @Test + public void landingWaitsForChunkLoadThenRunsOnDestinationRegion() throws Exception { + World world = mock(World.class); + NativeWorld nativeWorld = mock(NativeWorld.class); + Engine engine = mock(Engine.class); + GenerationSessionLease lease = mock(GenerationSessionLease.class); + when(engine.acquireGenerationLease("bukkit_cave_landing")).thenReturn(lease); + when(engine.getWorld()).thenReturn(IrisWorld.builder().platformWorld(nativeWorld).build()); + BiomeLocator locator = mock(BiomeLocator.class); + SubterrainPosition target = new SubterrainPosition(-25, -40, 35); + SubterrainPosition landing = new SubterrainPosition(-24, -42, 36); + SavedBiomeRuntime.ReadSession readSession = mock(SavedBiomeRuntime.ReadSession.class); + CompletableFuture> prepared = new CompletableFuture<>(); + when(locator.prepareRead(engine, new Position2(-2, 2))).thenReturn(prepared); + when(locator.landing(engine, nativeWorld, target, Optional.of(readSession))).thenReturn(landing); + CompletableFuture loaded = new CompletableFuture<>(); + AtomicReference regionTask = new AtomicReference<>(); + try (MockedStatic platform = mockStatic(BukkitPlatform.class); + MockedStatic scheduler = mockStatic(J.class); + MockedStatic context = mockStatic(IrisContext.class)) { + platform.when(() -> BukkitPlatform.chunkAtAsync(world, -2, 2, true)).thenReturn(loaded); + scheduler.when(() -> J.runRegion(eq(world), eq(-2), eq(2), any(Runnable.class))).thenAnswer(call -> { + regionTask.set(call.getArgument(3)); + return true; + }); + context.when(() -> IrisContext.open(eq(engine), anyLong(), any())).thenReturn(mock(IrisContext.Scope.class)); + CompletableFuture result = EngineBukkitOps.caveDestination(engine, locator, world, target); + assertFalse(result.isDone()); + verifyNoInteractions(locator); + loaded.complete(mock(Chunk.class)); + assertFalse(result.isDone()); + assertNull(regionTask.get()); + prepared.complete(Optional.of(readSession)); + assertFalse(result.isDone()); + regionTask.get().run(); + assertTrue(result.isDone()); + assertEquals(-23.5, result.get().getX(), 0); + assertEquals(-42, result.get().getY(), 0); + assertEquals(36.5, result.get().getZ(), 0); + verify(locator).landing(engine, nativeWorld, target, Optional.of(readSession)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationFindCatalogTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationFindCatalogTest.java index c97dbd572..56742ddb4 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationFindCatalogTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationFindCatalogTest.java @@ -1,6 +1,9 @@ package art.arcane.iris.world.history; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.locator.BiomeLocator; +import art.arcane.iris.generation.locator.Locator; +import art.arcane.volmlib.util.math.Position2; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.biome.IrisBiome; @@ -31,6 +34,34 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class GenerationFindCatalogTest { + @Test + public void retainedCaveRolesSelectCaveSearchWithoutActiveDefinitions() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.engine.getDimension().getCarving()).thenReturn(new KList<>()); + when(fixture.engine.getDimension().getSubterrainFeatures()).thenReturn(new KList<>()); + doAnswer(invocation -> { + GenerationSemanticIndex.RecordConsumer consumer = invocation.getArgument(0); + consumer.accept(ChunkGenerationSemantics.builder(0, 0, 1L) + .addCaveBiome("old-cave").addCaveBiome("shared") + .addSurfaceBiome("old-surface").addSurfaceBiome("shared").seal().build()); + consumer.accept(ChunkGenerationSemantics.builder(1, 0, 1L).addCaveBiome("unsealed").build()); + consumer.accept(ChunkGenerationSemantics.builder(2, 0, 2L).addCaveBiome("current").seal().build()); + return null; + }).when(fixture.history).forEachRecordedSemantic(any()); + assertTrue(GenerationFindCatalog.hasRetainedCaveBiome(fixture.engine, "OLD-CAVE")); + assertFalse(GenerationFindCatalog.hasRetainedSurfaceBiome(fixture.engine, "old-cave")); + assertTrue(BiomeLocator.forBiome(fixture.engine, "old-cave", -30) instanceof BiomeLocator); + assertFalse(BiomeLocator.forBiome(fixture.engine, "old-surface", -30) instanceof BiomeLocator); + assertFalse(GenerationFindCatalog.hasRetainedCaveBiome(fixture.engine, "unsealed")); + assertFalse(GenerationFindCatalog.hasRetainedCaveBiome(fixture.engine, "current")); + IrisBiome shared = new IrisBiome(); + shared.setLoadKey("shared"); + when(fixture.engine.getSurfaceBiome(8, 8)).thenReturn(shared); + Locator locator = BiomeLocator.forBiome(fixture.engine, "shared", -30); + assertTrue(locator.matches(fixture.engine, new Position2(0, 0))); + verify(fixture.history, never()).packRoot(anyLong()); + } + @Test public void findsRecordedNamesWithoutLoadingArchivedPacksOrRuntimes() throws Exception { Fixture fixture = new Fixture(); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java index c367ca599..fb4fe6f1b 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java @@ -26,6 +26,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -368,6 +369,59 @@ public class SavedBiomeRuntimeTest { } } + @Test + public void asynchronousChunkSessionWaitsForDiskAndConsumesTheCompletedRead() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.empty(); + }); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + CompletableFuture reading = runtime.readChunkAsync(0, 0); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertFalse(reading.isDone()); + assertEquals(1, runtime.pendingQueryCount()); + assertThrows(TimeoutException.class, () -> reading.get(100L, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + SavedBiomeRuntime.ReadSession session = reading.get(5L, TimeUnit.SECONDS); + awaitIdle(runtime); + assertEquals(Optional.empty(), session.resolve(0, 0, 0, true)); + assertEquals(Optional.empty(), session.resolve(15, -32, 15, false)); + assertEquals(0, runtime.pendingQueryCount()); + verify(store).get(0, 0); + } finally { + release.countDown(); + } + } + + @Test + public void asynchronousChunkSessionSurvivesQueryEvictionAndRejectsClosedOrOtherChunks() throws Exception { + allowUnownedChunks(); + SavedBiomeRuntime.ReadSession session; + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + session = runtime.readChunkAsync(0, 0).get(5L, TimeUnit.SECONDS); + for (int chunkX = 1; chunkX <= 130; chunkX++) { + runtime.readChunkAsync(chunkX, 0).get(5L, TimeUnit.SECONDS); + } + awaitIdle(runtime); + assertEquals(128, runtime.cachedQueryCount()); + assertEquals(Optional.empty(), session.resolve(0, 0, 0, true)); + assertEquals(Optional.empty(), session.resolve(15, 32, 15, false)); + assertThrows(IllegalArgumentException.class, () -> session.resolve(16, 0, 0, true)); + assertThrows(IllegalArgumentException.class, () -> session.resolve(-1, 0, 0, false)); + assertEquals(0, runtime.pendingQueryCount()); + verify(store).get(0, 0); + } + assertThrows(IllegalStateException.class, () -> session.resolve(0, 0, 0, true)); + verify(store).get(0, 0); + } + @Test public void backgroundPreviewWaitsForTheReadWithoutRepeatingTheQuery() throws Exception { allowUnownedChunks(); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java index 9e01eaa03..07dec4af6 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java @@ -7,6 +7,7 @@ import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; import art.arcane.iris.generation.subterrain.SubterrainPlan; import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.pack.value.IrisRange; import org.junit.Test; import java.util.List; @@ -21,6 +22,39 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class SavedSubterrainQueriesTest { + @Test + public void dryShoreUsesRetainedPlannerAndRejectsAnotherActivationsChunks() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + GenerationActivation old = GenerationActivation.initial("a".repeat(64), 1L); + GenerationActivation active = GenerationActivation.next(2L, "b".repeat(64), 1L, 2L, 64); + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("old-basin").setProbability(1D) + .setBiome("retired-cave").setRadius(28).setHeight(40).setFluidDepth(8) + .setWorldYRange(new IrisRange(-100, 100)).setFormationFraction(0).setPillarSpacing(0); + SubterrainPlanner historical = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -128, 256)); + SubterrainPlan plan = historical.plansForBounds(0, 0, 511, 511).getFirst(); + SubterrainPosition anchor = plan.anchor(); + SubterrainLocator.Result target = new SubterrainLocator.Result(plan.id(), plan.family(), plan.biome(), + anchor.x(), anchor.y(), anchor.z()); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(router.biomes()).thenReturn(saved); + when(saved.subterrainPlanner(old.activationId())).thenReturn(historical); + when(history.resolveActivation(anyInt(), anyInt())).thenReturn(old); + SubterrainLocator.Result dry = GenerationSemanticQueries.drySubterrainLanding(engine, target, () -> true).orElseThrow(); + assertTrue(target.x() >> 4 != dry.x() >> 4 || target.z() >> 4 != dry.z() >> 4); + assertTrue(historical.sample(dry.x(), dry.y() - 1, dry.z()).solid()); + verify(engine, never()).getComplex(); + verify(engine, never()).getActiveGenerationRuntimeBinding(); + verify(saved, never()).subterrainPlanner(active.activationId()); + when(history.resolveActivation(anyInt(), anyInt())).thenReturn(active); + when(history.resolveActivation(target.x() >> 4, target.z() >> 4)).thenReturn(old); + assertTrue(GenerationSemanticQueries.drySubterrainLanding(engine, target, () -> true).isEmpty()); + assertTrue(GenerationSemanticQueries.drySubterrainLanding(engine, target, () -> false).isEmpty()); + } + @Test public void locateUsesRetainedDefinitionOnlyInsideItsOwnedChunks() throws Exception { IrisEngine engine = mock(IrisEngine.class);