diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 4eaaa83c7..ec6ac9765 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.tools; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; import com.google.common.util.concurrent.AtomicDouble; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -46,13 +47,16 @@ import art.arcane.volmlib.util.scheduling.FoliaScheduler; import lombok.Data; import lombok.experimental.Accessors; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.World; import org.bukkit.WorldCreator; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; import java.io.IOException; import java.util.Locale; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -230,6 +234,7 @@ public class IrisCreator { addToBukkitYml(); J.s(() -> IrisServices.get(MultiverseCoreLink.class).updateWorld(world, dimension)); } + scheduleSenderTeleport(world); if (pregen != null) { CompletableFuture ff = new CompletableFuture<>(); @@ -254,6 +259,73 @@ public class IrisCreator { return world; } + static Player createTeleportTarget(VolmitSender sender, boolean studio, boolean benchmark) { + if (studio || benchmark || sender == null || !sender.isPlayer()) { + return null; + } + return sender.player(); + } + + static CompletableFuture teleportSenderToCreatedWorld( + Player player, + World world, + WorldRuntimeControlService runtimeControl + ) { + Player requiredPlayer = Objects.requireNonNull(player, "player"); + World requiredWorld = Objects.requireNonNull(world, "world"); + WorldRuntimeControlService requiredRuntime = Objects.requireNonNull(runtimeControl, "runtimeControl"); + Location entryAnchor = requiredRuntime.resolveEntryAnchor(requiredWorld); + if (entryAnchor == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Unable to resolve the entry anchor for world \"" + requiredWorld.getName() + "\".")); + } + + int chunkX = entryAnchor.getBlockX() >> 4; + int chunkZ = entryAnchor.getBlockZ() >> 4; + return requiredRuntime.requestChunkAsync(requiredWorld, chunkX, chunkZ, true) + .thenCompose(chunk -> requiredRuntime.resolveSafeEntry(requiredWorld, entryAnchor)) + .thenCompose(safeEntry -> { + if (safeEntry == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Unable to resolve a safe entry for world \"" + requiredWorld.getName() + "\".")); + } + return requiredRuntime.teleport(requiredPlayer, safeEntry); + }); + } + + private void scheduleSenderTeleport(World world) { + Player player = createTeleportTarget(sender, studio, benchmark); + if (player == null) { + return; + } + + CompletableFuture teleportFuture; + try { + teleportFuture = teleportSenderToCreatedWorld(player, world, WorldRuntimeControlService.get()); + } catch (Throwable e) { + reportSenderTeleportFailure(player, world, e); + return; + } + + teleportFuture.whenComplete((success, throwable) -> { + if (throwable != null) { + reportSenderTeleportFailure(player, world, throwable); + return; + } + if (!Boolean.TRUE.equals(success)) { + reportSenderTeleportFailure(player, world, new IllegalStateException( + "The runtime teleport operation returned false for player \"" + player.getName() + "\".")); + } + }); + } + + private void reportSenderTeleportFailure(Player player, World world, Throwable throwable) { + IrisLogging.reportError("World \"" + world.getName() + + "\" was created, but automatic teleport failed for player \"" + player.getName() + "\".", throwable); + J.runEntity(player, () -> new VolmitSender(player).sendMessage(C.YELLOW + + "The world was created, but automatic teleport failed. Try /iris teleport world=" + world.getName())); + } + private void reportStudioProgress(double progress, String stage) { BiConsumer consumer = studioProgressConsumer; if (consumer == null) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index b7eee71b9..9e5c99698 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -25,6 +25,7 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.engine.object.ObjectPlaceMode; @@ -403,10 +404,12 @@ public final class IrisStructureLocator { } int sideExtension = placement.isOverbore() - ? Math.max(1, placement.getOverboreRadius()) + ? Math.max(0, placement.getOverboreRadius()) : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; + IrisStructureCarveShape overboreShape = placement.resolvedOverboreShape(); int topExtension = placement.isOverbore() - ? (int) Math.ceil(Math.max(1D, placement.getOverboreHeight()) * 1.8D) + ? overboreShape.maximumCeilingExtension( + placement.getOverboreHeight(), placement.resolvedOverboreErosionStrength()) : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; int bottomExtension = placement.isOverbore() ? Math.max(0, placement.getOverboreFloor()) : 0; int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); @@ -419,7 +422,8 @@ public final class IrisStructureLocator { Long2IntOpenHashMap surfaceHeights = new Long2IntOpenHashMap(); surfaceHeights.defaultReturnValue(Integer.MIN_VALUE); - if (placement.isBore() && !placement.isOverbore()) { + if ((placement.isBore() && !placement.isOverbore()) + || (placement.isOverbore() && overboreShape == IrisStructureCarveShape.BOX)) { maximumShift = resolveBurialEnvelopeShift( engine, bounds[0] - sideExtension, diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index e025a10c1..7983ea3ba 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -37,6 +37,7 @@ import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructureStiltSettings; import art.arcane.iris.spi.IrisLogging; @@ -65,10 +66,8 @@ import java.util.Objects; public class IrisStructureComponent extends IrisMantleComponent { private static final long MAX_BORE_VOLUME = 6_000_000L; private static final long MAX_OVERBORE_VOLUME = 48_000_000L; - private static final double OVERBORE_MIN_BOUNDARY = 0.2; - private static final double OVERBORE_MIN_BOUNDARY_SQUARED = OVERBORE_MIN_BOUNDARY * OVERBORE_MIN_BOUNDARY; - private static final double OVERBORE_BOUNDARY_SPAN = 0.8; - private static final double OVERBORE_MAX_UP_REACH = 1.8; + private static final int MAX_OVERBORE_FOOTPRINT_COLUMNS = 1_048_576; + private static final double OVERBORE_ROLL_FREQUENCY_RATIO = 3D / 7D; private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 3); public IrisStructureComponent(EngineMantle engineMantle) { @@ -128,7 +127,7 @@ public class IrisStructureComponent extends IrisMantleComponent { } if (placement.isOverbore()) { - overboreStructure(writer, pieces, placement.getOverboreRadius(), placement.getOverboreHeight(), placement.getOverboreFloor()); + overboreStructure(writer, pieces, placement); } else if (placement.isBore()) { boreStructure(writer, pieces, placement.getBorePadding()); } @@ -286,102 +285,95 @@ public class IrisStructureComponent extends IrisMantleComponent { } } - private void overboreStructure(MantleWriter writer, KList pieces, int radius, int ceiling, int floorDepth) { + private void overboreStructure(MantleWriter writer, KList pieces, + IrisStructurePlacement placement) { int[] bounds = computePieceBounds(pieces); if (bounds == null) { return; } - int margin = Math.max(1, radius); - int head = Math.max(0, ceiling); - int floorCut = Math.max(0, floorDepth); + IrisStructureCarveShape shape = placement.resolvedOverboreShape(); + int margin = Math.max(0, placement.getOverboreRadius()); + int head = Math.max(0, placement.getOverboreHeight()); + int floorCut = Math.max(0, placement.getOverboreFloor()); + double strength = placement.resolvedOverboreErosionStrength(); int mantleOffset = getEngineMantle().getEngine().getMinHeight(); int worldMin = getEngineMantle().getEngine().getMinHeight() + 1; int worldMax = getEngineMantle().getEngine().getMinHeight() + getEngineMantle().getEngine().getHeight() - 1; + int upExtension = shape.maximumCeilingExtension(head, strength); - double freq = 0.07; - double rollFreq = 0.03; - double reachSide = margin; - double reachUp = head < 1 ? 1.0 : head; - double reachDown = floorCut < 1 ? 1.0 : floorCut; - double upReachMin = 0.4; - double upReachSpan = 1.4; - int sideExt = overboreSideExtension(margin); - int upExt = overboreUpExtension(reachUp); - - long work = 0L; - for (PlacedStructurePiece p : pieces) { - long wx = (long) (p.getMaxX() - p.getMinX() + 1) + 2L * sideExt; - long wz = (long) (p.getMaxZ() - p.getMinZ() + 1) + 2L * sideExt; - long wy = (long) (p.getMaxY() - p.getMinY() + 1) + upExt + floorCut; - work += wx * wy * wz; - } - if (work > MAX_OVERBORE_VOLUME) { - IrisLogging.warn("Skipping structure overbore of " + work + " blocks (cap " + MAX_OVERBORE_VOLUME + "); reduce overboreRadius/overboreHeight or use larger spacing."); + if (shape == IrisStructureCarveShape.BOX) { + carveOverboreBox(writer, bounds, margin, head, floorCut, mantleOffset, worldMin, worldMax); return; } - RNG noiseRng = new RNG(seed() + Cache.key(bounds[0], bounds[2])); - CNG blob = CNG.signature(noiseRng); - CNG roll = CNG.signature(noiseRng.nextParallelRNG(0x2A17)); - - if (IrisSettings.get().getGeneral().isDebug()) { - IrisLogging.info("Overbore carving organic cavern: pieces=" + pieces.size() + " margin=" + margin + " head=" + head + " floorCut=" + floorCut + " work=" + work); + long work = overboreCandidateVolume(bounds, margin, upExtension, floorCut); + if (work > MAX_OVERBORE_VOLUME) { + warnOverboreVolume(work); + return; + } + StructureCarvingFootprint footprint = StructureCarvingFootprint.from( + pieces, margin, MAX_OVERBORE_FOOTPRINT_COLUMNS); + if (footprint == null) { + IrisLogging.warn("Structure overbore footprint was empty or exceeded " + + MAX_OVERBORE_FOOTPRINT_COLUMNS + " columns; skipping its expanded carve."); + return; } - for (PlacedStructurePiece p : pieces) { - int pMinX = p.getMinX(); - int pMinY = p.getMinY(); - int pMinZ = p.getMinZ(); - int pMaxX = p.getMaxX(); - int pMaxY = p.getMaxY(); - int pMaxZ = p.getMaxZ(); - int exMinX = pMinX - sideExt; - int exMaxX = pMaxX + sideExt; - int exMinZ = pMinZ - sideExt; - int exMaxZ = pMaxZ + sideExt; - int exMinY = Math.max(worldMin, pMinY - floorCut); - int exMaxY = Math.min(worldMax, pMaxY + upExt); - for (int bx = exMinX; bx <= exMaxX; bx++) { - double dx = bx < pMinX ? pMinX - bx : bx > pMaxX ? bx - pMaxX : 0; - double nx = dx / reachSide; - for (int bz = exMinZ; bz <= exMaxZ; bz++) { - double dz = bz < pMinZ ? pMinZ - bz : bz > pMaxZ ? bz - pMaxZ : 0; - double nz = dz / reachSide; - double nxz = nx * nx + nz * nz; - if (nxz > 1.0) { + double frequency = placement.resolvedOverboreErosionFrequency(); + boolean eroded = shape == IrisStructureCarveShape.ERODED && strength > 0D; + CNG blob = null; + CNG roll = null; + if (eroded) { + RNG noiseRng = new RNG(seed() + Cache.key(bounds[0], bounds[2])); + blob = CNG.signature(noiseRng); + roll = CNG.signature(noiseRng.nextParallelRNG(0x2A17)); + } + + if (IrisSettings.get().getGeneral().isDebug()) { + IrisLogging.info("Overbore carving structure cavern: shape=" + shape + " pieces=" + pieces.size() + + " footprint=" + footprint.width() + "x" + footprint.depth() + + " margin=" + margin + " head=" + head + " floorCut=" + floorCut + " work=" + work); + } + + double sideReachSquared = (double) margin * margin; + double downReach = Math.max(1D, floorCut); + for (int bz = footprint.minZ(); bz <= footprint.maxZ(); bz++) { + int footprintIndex = footprint.indexAt(footprint.minX(), bz); + for (int bx = footprint.minX(); bx <= footprint.maxX(); bx++, footprintIndex++) { + long horizontalDistanceSquared = footprint.distanceSquaredAt(footprintIndex); + if (horizontalDistanceSquared < 0L || horizontalDistanceSquared > sideReachSquared) { + continue; + } + double normalizedHorizontalDistanceSquared = sideReachSquared == 0D + ? 0D : horizontalDistanceSquared / sideReachSquared; + int sourceMinY = footprint.sourceMinYAt(footprintIndex); + int sourceMaxY = footprint.sourceMaxYAt(footprintIndex); + double upReach = eroded + ? erodedUpReach(roll, frequency, strength, bx, bz, head) + : Math.max(1D, head); + int columnUpExtension = eroded + ? (int) Math.ceil(upReach) : head; + int minY = Math.max(worldMin, sourceMinY - floorCut); + int maxY = Math.min(worldMax, sourceMaxY + columnUpExtension); + for (int by = minY; by <= maxY; by++) { + double normalizedY = normalizedVerticalDistance( + by, sourceMinY, sourceMaxY, upReach, downReach); + double distanceSquared = normalizedHorizontalDistanceSquared + + normalizedY * normalizedY; + if (distanceSquared <= 0D) { + writer.carveDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); continue; } - double w = roll.fitDouble(0.0, 1.0, bx * rollFreq, bz * rollFreq) * 0.7 - + roll.fitDouble(0.0, 1.0, bx * rollFreq * 3.0, bz * rollFreq * 3.0) * 0.3; - double contrast = (w - 0.5) * 2.6 + 0.5; - if (contrast < 0.0) { - contrast = 0.0; - } else if (contrast > 1.0) { - contrast = 1.0; + if (distanceSquared > 1D) { + continue; } - double upReach = reachUp * (upReachMin + upReachSpan * contrast); - if (upReach < 1.0) { - upReach = 1.0; + if (!eroded) { + writer.carveDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); + continue; } - for (int by = exMinY; by <= exMaxY; by++) { - double ny; - if (by > pMaxY) { - ny = (by - pMaxY) / upReach; - } else if (by < pMinY) { - ny = (pMinY - by) / reachDown; - } else { - ny = 0.0; - } - double distanceSquared = nxz + ny * ny; - if (distanceSquared > 1.0) { - continue; - } - if (distanceSquared > OVERBORE_MIN_BOUNDARY_SQUARED) { - double n = blob.fitDouble(0.0, 1.0, bx * freq, by * freq, bz * freq); - if (!shouldCarveOverboreCell(distanceSquared, n)) { - continue; - } - } + double noise = blob.fitDouble( + 0D, 1D, bx * frequency, by * frequency, bz * frequency); + if (shouldCarveOverboreCell(shape, distanceSquared, noise, strength)) { writer.carveDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); } } @@ -389,28 +381,100 @@ public class IrisStructureComponent extends IrisMantleComponent { } } - static int overboreSideExtension(int radius) { - return Math.max(1, radius); + private void carveOverboreBox(MantleWriter writer, int[] bounds, int margin, int head, int floorCut, + int mantleOffset, int worldMin, int worldMax) { + int minX = bounds[0] - margin; + int minY = Math.max(worldMin, bounds[1] - floorCut); + int minZ = bounds[2] - margin; + int maxX = bounds[3] + margin; + int maxY = Math.min(worldMax, bounds[4] + head); + int maxZ = bounds[5] + margin; + long volume = inclusiveVolume(minX, minY, minZ, maxX, maxY, maxZ); + if (volume > MAX_OVERBORE_VOLUME) { + warnOverboreVolume(volume); + return; + } + for (int bx = minX; bx <= maxX; bx++) { + for (int by = minY; by <= maxY; by++) { + for (int bz = minZ; bz <= maxZ; bz++) { + writer.carveDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); + } + } + } } - static int overboreUpExtension(double reachUp) { - return (int) Math.ceil(Math.max(1.0, reachUp) * OVERBORE_MAX_UP_REACH); + static double erodedUpReach(CNG roll, double frequency, double strength, int x, int z, int head) { + if (head <= 0) { + return 0D; + } + double clampedStrength = Math.max(0D, Math.min(1D, strength)); + if (clampedStrength <= 0D) { + return Math.max(1D, head); + } + double rollFrequency = frequency * OVERBORE_ROLL_FREQUENCY_RATIO; + double sample = roll.fitDouble(0D, 1D, x * rollFrequency, z * rollFrequency) * 0.7D + + roll.fitDouble(0D, 1D, x * rollFrequency * 3D, z * rollFrequency * 3D) * 0.3D; + double contrast = Math.max(0D, Math.min(1D, (sample - 0.5D) * 2.6D + 0.5D)); + double roundedReach = Math.max(1D, head); + double erodedReach = Math.max(1D, head * (0.4D + 1.4D * contrast)); + return roundedReach + clampedStrength * (erodedReach - roundedReach); } - static double overboreBoundaryLimit(double noise) { + private static double normalizedVerticalDistance(int y, int sourceMinY, int sourceMaxY, + double upReach, double downReach) { + if (y > sourceMaxY) { + return (y - sourceMaxY) / upReach; + } + if (y < sourceMinY) { + return (sourceMinY - y) / downReach; + } + return 0D; + } + + private static long overboreCandidateVolume(int[] bounds, int margin, int upExtension, + int floorCut) { + return inclusiveVolume( + bounds[0] - margin, bounds[1] - floorCut, bounds[2] - margin, + bounds[3] + margin, bounds[4] + upExtension, bounds[5] + margin); + } + + private static long inclusiveVolume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + long width = (long) maxX - minX + 1L; + long height = (long) maxY - minY + 1L; + long depth = (long) maxZ - minZ + 1L; + if (width <= 0L || height <= 0L || depth <= 0L + || width > Long.MAX_VALUE / height + || width * height > Long.MAX_VALUE / depth) { + return Long.MAX_VALUE; + } + return width * height * depth; + } + + private static void warnOverboreVolume(long volume) { + IrisLogging.warn("Skipping structure overbore of " + volume + " blocks (cap " + + MAX_OVERBORE_VOLUME + "); reduce overboreRadius/overboreHeight or use larger spacing."); + } + + static double overboreBoundaryLimit(double noise, double strength) { double clampedNoise = Math.max(0.0, Math.min(1.0, noise)); - return OVERBORE_MIN_BOUNDARY + OVERBORE_BOUNDARY_SPAN * clampedNoise; + double clampedStrength = Math.max(0.0, Math.min(1.0, strength)); + return 1D - clampedStrength * (1D - clampedNoise); } - static boolean shouldCarveOverboreCell(double distanceSquared, double noise) { + static boolean shouldCarveOverboreCell(IrisStructureCarveShape shape, double distanceSquared, + double noise, double strength) { if (distanceSquared <= 0.0) { return true; } - if (distanceSquared > 1.0) { - return false; - } - double limit = overboreBoundaryLimit(noise); - return distanceSquared <= limit * limit; + IrisStructureCarveShape resolvedShape = shape == null ? IrisStructureCarveShape.ERODED : shape; + return switch (resolvedShape) { + case BOX -> true; + case ROUNDED -> distanceSquared <= 1D; + case ERODED -> { + double limit = overboreBoundaryLimit(noise, strength); + yield distanceSquared <= 1D && distanceSquared <= limit * limit; + } + }; } private void clearIntersectingObjectTrees(MantleWriter writer, IrisStructureLocator.ResolvedPlacement resolved) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprint.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprint.java new file mode 100644 index 000000000..8638d93ef --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprint.java @@ -0,0 +1,420 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import it.unimi.dsi.fastutil.longs.Long2LongMap; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; + +import java.util.Arrays; + +final class StructureCarvingFootprint { + static final int DEFAULT_MAX_CELLS = 1_048_576; + + private final int minX; + private final int maxX; + private final int minZ; + private final int maxZ; + private final int width; + private final int depth; + private final long[] distanceSquared; + private final int[] nearestSourceIds; + private final int[] sourceMinY; + private final int[] sourceMaxY; + + private StructureCarvingFootprint(FootprintData data) { + minX = data.minX(); + maxX = data.maxX(); + minZ = data.minZ(); + maxZ = data.maxZ(); + width = data.width(); + depth = data.depth(); + distanceSquared = data.distanceSquared(); + nearestSourceIds = data.nearestSourceIds(); + sourceMinY = data.sourceMinY(); + sourceMaxY = data.sourceMaxY(); + } + + static StructureCarvingFootprint from(KList pieces, int padding) { + return from(pieces, padding, DEFAULT_MAX_CELLS); + } + + static StructureCarvingFootprint from(KList pieces, int padding, int maxCells) { + if (padding < 0) { + throw new IllegalArgumentException("padding must be non-negative"); + } + if (maxCells < 1) { + throw new IllegalArgumentException("maxCells must be positive"); + } + if (pieces == null || pieces.isEmpty()) { + return null; + } + + SourceCollection sources = collectSources(pieces); + if (sources == null || sources.columns().isEmpty()) { + return null; + } + + Bounds bounds = paddedBounds(sources.bounds(), padding, maxCells); + if (bounds == null) { + return null; + } + + int[] nearestSourceIds = new int[bounds.cellCount()]; + Arrays.fill(nearestSourceIds, -1); + int sourceCount = sources.columns().size(); + int[] sourceX = new int[sourceCount]; + int[] sourceZ = new int[sourceCount]; + int[] sourceMinY = new int[sourceCount]; + int[] sourceMaxY = new int[sourceCount]; + int sourceId = 0; + for (Long2LongMap.Entry entry : sources.columns().long2LongEntrySet()) { + int localX = unpackX(entry.getLongKey()) - bounds.minX(); + int localZ = unpackZ(entry.getLongKey()) - bounds.minZ(); + long heights = entry.getLongValue(); + sourceX[sourceId] = localX; + sourceZ[sourceId] = localZ; + sourceMinY[sourceId] = unpackMinY(heights); + sourceMaxY[sourceId] = unpackMaxY(heights); + nearestSourceIds[localZ * bounds.width() + localX] = sourceId; + sourceId++; + } + + transformRows(nearestSourceIds, sourceX, sourceZ, bounds.width(), bounds.depth()); + long[] distanceSquared = transformColumns( + nearestSourceIds, sourceX, sourceZ, bounds.width(), bounds.depth()); + FootprintData data = new FootprintData( + bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + bounds.width(), bounds.depth(), distanceSquared, nearestSourceIds, + sourceMinY, sourceMaxY); + return new StructureCarvingFootprint(data); + } + + int minX() { + return minX; + } + + int maxX() { + return maxX; + } + + int minZ() { + return minZ; + } + + int maxZ() { + return maxZ; + } + + int width() { + return width; + } + + int depth() { + return depth; + } + + boolean contains(int worldX, int worldZ) { + return worldX >= minX && worldX <= maxX && worldZ >= minZ && worldZ <= maxZ; + } + + int indexAt(int worldX, int worldZ) { + if (!contains(worldX, worldZ)) { + return -1; + } + return (worldZ - minZ) * width + worldX - minX; + } + + long distanceSquaredAt(int worldX, int worldZ) { + return distanceSquaredAt(requireIndex(worldX, worldZ)); + } + + long distanceSquaredAt(int index) { + return distanceSquared[index]; + } + + int sourceMinYAt(int worldX, int worldZ) { + return sourceMinYAt(requireIndex(worldX, worldZ)); + } + + int sourceMinYAt(int index) { + return sourceMinY[nearestSourceIds[index]]; + } + + int sourceMaxYAt(int worldX, int worldZ) { + return sourceMaxYAt(requireIndex(worldX, worldZ)); + } + + int sourceMaxYAt(int index) { + return sourceMaxY[nearestSourceIds[index]]; + } + + Column sample(int worldX, int worldZ) { + int index = indexAt(worldX, worldZ); + if (index < 0) { + return null; + } + return new Column(distanceSquaredAt(index), sourceMinYAt(index), sourceMaxYAt(index)); + } + + private static SourceCollection collectSources(KList pieces) { + Long2LongOpenHashMap columns = new Long2LongOpenHashMap(); + MutableBounds bounds = new MutableBounds(); + for (PlacedStructurePiece piece : pieces) { + if (piece == null) { + continue; + } + IrisObject object = piece.getObject(); + IrisObjectRotation rotation = piece.getRotation(); + if (object == null || rotation == null || object.getBlocks() == null) { + continue; + } + for (IrisBlockVector local : object.getBlocks().keys()) { + if (local == null || B.isAir(object.getBlocks().get(local))) { + continue; + } + IrisBlockVector rotated = rotation.rotate(local.clone()); + long worldX = (long) piece.getX() + rotated.getBlockX(); + long worldY = (long) piece.getY() + rotated.getBlockY(); + long worldZ = (long) piece.getZ() + rotated.getBlockZ(); + if (!fitsInteger(worldX) || !fitsInteger(worldY) || !fitsInteger(worldZ)) { + return null; + } + mergeColumn(columns, (int) worldX, (int) worldY, (int) worldZ); + bounds.include((int) worldX, (int) worldZ); + } + } + return columns.isEmpty() ? null : new SourceCollection(columns, bounds.freeze()); + } + + private static Bounds paddedBounds(MutableBoundsSnapshot sourceBounds, int padding, int maxCells) { + long paddedMinX = (long) sourceBounds.minX() - padding; + long paddedMaxX = (long) sourceBounds.maxX() + padding; + long paddedMinZ = (long) sourceBounds.minZ() - padding; + long paddedMaxZ = (long) sourceBounds.maxZ() + padding; + if (!fitsInteger(paddedMinX) || !fitsInteger(paddedMaxX) + || !fitsInteger(paddedMinZ) || !fitsInteger(paddedMaxZ)) { + return null; + } + long width = paddedMaxX - paddedMinX + 1L; + long depth = paddedMaxZ - paddedMinZ + 1L; + if (width < 1L || depth < 1L || width > Integer.MAX_VALUE || depth > Integer.MAX_VALUE + || width > maxCells / depth) { + return null; + } + int cellCount = (int) (width * depth); + return new Bounds( + (int) paddedMinX, (int) paddedMaxX, (int) paddedMinZ, (int) paddedMaxZ, + (int) width, (int) depth, cellCount); + } + + private static void mergeColumn(Long2LongOpenHashMap columns, int x, int y, int z) { + long key = pack(x, z); + if (!columns.containsKey(key)) { + columns.put(key, packHeights(y, y)); + return; + } + long existing = columns.get(key); + columns.put(key, packHeights( + Math.min(y, unpackMinY(existing)), Math.max(y, unpackMaxY(existing)))); + } + + private static void transformRows(int[] nearestSourceIds, int[] sourceX, int[] sourceZ, + int width, int depth) { + for (int z = 0; z < depth; z++) { + int rowStart = z * width; + int previousSource = -1; + for (int x = 0; x < width; x++) { + int index = rowStart + x; + int sourceId = nearestSourceIds[index]; + if (isExplicitSource(sourceId, x, z, sourceX, sourceZ)) { + previousSource = sourceId; + } else { + nearestSourceIds[index] = previousSource; + } + } + int nextSource = -1; + for (int x = width - 1; x >= 0; x--) { + int index = rowStart + x; + int currentSource = nearestSourceIds[index]; + if (isExplicitSource(currentSource, x, z, sourceX, sourceZ)) { + nextSource = currentSource; + } + if (isBetterHorizontalSource(nextSource, currentSource, x, sourceX)) { + nearestSourceIds[index] = nextSource; + } + } + } + } + + private static long[] transformColumns(int[] nearestSourceIds, int[] sourceX, int[] sourceZ, + int width, int depth) { + long[] distanceSquared = new long[nearestSourceIds.length]; + int[] siteRows = new int[depth]; + int[] siteSourceIds = new int[depth]; + int[] starts = new int[depth]; + long[] siteCosts = new long[depth]; + for (int x = 0; x < width; x++) { + int siteCount = buildEnvelope( + nearestSourceIds, sourceX, width, depth, x, + siteRows, siteSourceIds, starts, siteCosts); + int siteIndex = 0; + for (int z = 0; z < depth; z++) { + while (siteIndex + 1 < siteCount && starts[siteIndex + 1] <= z) { + siteIndex++; + } + int sourceId = siteSourceIds[siteIndex]; + int index = z * width + x; + long deltaX = (long) x - sourceX[sourceId]; + long deltaZ = (long) z - sourceZ[sourceId]; + nearestSourceIds[index] = sourceId; + distanceSquared[index] = deltaX * deltaX + deltaZ * deltaZ; + } + } + return distanceSquared; + } + + private static int buildEnvelope(int[] nearestSourceIds, int[] sourceX, int width, int depth, + int x, int[] siteRows, int[] siteSourceIds, + int[] starts, long[] siteCosts) { + int siteCount = 0; + for (int z = 0; z < depth; z++) { + int sourceId = nearestSourceIds[z * width + x]; + if (sourceId < 0) { + continue; + } + long deltaX = (long) x - sourceX[sourceId]; + long cost = deltaX * deltaX; + int start = Integer.MIN_VALUE; + while (siteCount > 0) { + start = firstStrictlyBetterAt( + siteRows[siteCount - 1], siteCosts[siteCount - 1], z, cost); + if (start > starts[siteCount - 1]) { + break; + } + siteCount--; + } + if (siteCount == 0) { + start = Integer.MIN_VALUE; + } + siteRows[siteCount] = z; + siteSourceIds[siteCount] = sourceId; + siteCosts[siteCount] = cost; + starts[siteCount] = start; + siteCount++; + } + return siteCount; + } + + private static int firstStrictlyBetterAt(int existingRow, long existingCost, + int candidateRow, long candidateCost) { + long numerator = candidateCost + (long) candidateRow * candidateRow + - existingCost - (long) existingRow * existingRow; + long denominator = 2L * (candidateRow - existingRow); + long start = Math.floorDiv(numerator, denominator) + 1L; + if (start < Integer.MIN_VALUE) { + return Integer.MIN_VALUE; + } + if (start > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) start; + } + + private static boolean isExplicitSource(int sourceId, int x, int z, + int[] sourceX, int[] sourceZ) { + return sourceId >= 0 && sourceX[sourceId] == x && sourceZ[sourceId] == z; + } + + private static boolean isBetterHorizontalSource(int candidateId, int currentId, + int x, int[] sourceX) { + if (candidateId < 0) { + return false; + } + if (currentId < 0) { + return true; + } + long candidateDelta = (long) x - sourceX[candidateId]; + long currentDelta = (long) x - sourceX[currentId]; + long candidateDistance = candidateDelta * candidateDelta; + long currentDistance = currentDelta * currentDelta; + return candidateDistance < currentDistance + || candidateDistance == currentDistance && sourceX[candidateId] < sourceX[currentId]; + } + + private int requireIndex(int worldX, int worldZ) { + int index = indexAt(worldX, worldZ); + if (index < 0) { + throw new IndexOutOfBoundsException( + "coordinate outside carving footprint: " + worldX + "," + worldZ); + } + return index; + } + + private static boolean fitsInteger(long value) { + return value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE; + } + + private static long pack(int x, int z) { + return (long) x << 32 | z & 0xffffffffL; + } + + private static int unpackX(long packed) { + return (int) (packed >> 32); + } + + private static int unpackZ(long packed) { + return (int) packed; + } + + private static long packHeights(int minY, int maxY) { + return (long) minY << 32 | maxY & 0xffffffffL; + } + + private static int unpackMinY(long packed) { + return (int) (packed >> 32); + } + + private static int unpackMaxY(long packed) { + return (int) packed; + } + + record Column(long distanceSquared, int sourceMinY, int sourceMaxY) { + } + + private record Bounds(int minX, int maxX, int minZ, int maxZ, + int width, int depth, int cellCount) { + } + + private record FootprintData(int minX, int maxX, int minZ, int maxZ, + int width, int depth, long[] distanceSquared, + int[] nearestSourceIds, int[] sourceMinY, int[] sourceMaxY) { + } + + private record SourceCollection(Long2LongOpenHashMap columns, MutableBoundsSnapshot bounds) { + } + + private record MutableBoundsSnapshot(int minX, int maxX, int minZ, int maxZ) { + } + + private static final class MutableBounds { + private int minX = Integer.MAX_VALUE; + private int maxX = Integer.MIN_VALUE; + private int minZ = Integer.MAX_VALUE; + private int maxZ = Integer.MIN_VALUE; + + private void include(int x, int z) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minZ = Math.min(minZ, z); + maxZ = Math.max(maxZ, z); + } + + private MutableBoundsSnapshot freeze() { + return new MutableBoundsSnapshot(minX, maxX, minZ, maxZ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureCarveShape.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureCarveShape.java new file mode 100644 index 000000000..127793924 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureCarveShape.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls the shape of the expanded terrain carve around an overbored structure.") +public enum IrisStructureCarveShape { + @Desc("Carves the full expanded rectangular volume with straight walls, ceiling, and floor.") + BOX, + + @Desc("Carves a smooth rounded volume around the structure without erosion noise.") + ROUNDED, + + @Desc("Carves a rounded volume whose outer boundary is eroded by deterministic noise.") + ERODED; + + public double maximumCeilingScale() { + return maximumCeilingScale(1D); + } + + public double maximumCeilingScale(double erosionStrength) { + double strength = Double.isFinite(erosionStrength) + ? Math.max(0D, Math.min(1D, erosionStrength)) : 1D; + return switch (this) { + case BOX, ROUNDED -> 1D; + case ERODED -> 1D + 0.8D * strength; + }; + } + + public int maximumCeilingExtension(int configuredHeight) { + return maximumCeilingExtension(configuredHeight, 1D); + } + + public int maximumCeilingExtension(int configuredHeight, double erosionStrength) { + return (int) Math.ceil(Math.max(0, configuredHeight) * maximumCeilingScale(erosionStrength)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index 9cd53d208..5b22e239d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -35,6 +35,11 @@ import lombok.experimental.Accessors; @Desc("Attaches structures to a biome, region or dimension and controls where and how often they generate. This is independent of a structure's own native generation, so you can add custom placements in tandem with native generation or instead of it.") @Data public class IrisStructurePlacement { + private static final double DEFAULT_OVERBORE_EROSION_STRENGTH = 0.8D; + private static final double DEFAULT_OVERBORE_EROSION_FREQUENCY = 0.07D; + private static final double MIN_OVERBORE_EROSION_FREQUENCY = 0.001D; + private static final double MAX_OVERBORE_EROSION_FREQUENCY = 1D; + @ArrayType(type = String.class, min = 1) @RegistryListStructure @Desc("Editable Iris structure resources to place here. Every key must resolve to a structures/*.json resource in this pack. Live vanilla, mod, and datapack registry keys are controlled through importedStructures unless explicitly cloned into Iris resources.") @@ -98,7 +103,7 @@ public class IrisStructurePlacement { @MinNumber(0) @MaxNumber(128) - @Desc("when overbore=true, how many blocks of terrain to carve horizontally outward from the structure's bounding box. Larger values produce a wider open cavern around the structure. Larger values also widen the mantle write window for every chunk near the structure, so increase it deliberately.") + @Desc("when overbore=true, how many blocks of terrain to carve horizontally outward. BOX expands the assembled structure bounds; ROUNDED and ERODED expand the transformed non-air block footprint. Larger values produce a wider open cavern and widen the mantle write window for nearby chunks, so increase this deliberately.") private int overboreRadius = 24; @MinNumber(0) @@ -111,9 +116,41 @@ public class IrisStructurePlacement { @Desc("when overbore=true, how many blocks of terrain to carve below the structure's floor. 0 keeps the floor solid for support; small values recess the cavern floor around the structure.") private int overboreFloor = 0; + @Desc("when overbore=true, controls whether the expanded carve has straight, rounded, or noise-eroded boundaries.") + private IrisStructureCarveShape overboreShape = IrisStructureCarveShape.ERODED; + + @MinNumber(0) + @MaxNumber(1) + @Desc("when overboreShape=ERODED, controls how strongly noise cuts into the rounded outer shell. 0 matches ROUNDED; 1 may erode back to the mandatory structure footprint but never remove its required clearance.") + private double overboreErosionStrength = DEFAULT_OVERBORE_EROSION_STRENGTH; + + @MinNumber(MIN_OVERBORE_EROSION_FREQUENCY) + @MaxNumber(MAX_OVERBORE_EROSION_FREQUENCY) + @Desc("when overboreShape=ERODED, controls the spatial frequency of boundary noise. Higher values produce smaller, busier erosion features.") + private double overboreErosionFrequency = DEFAULT_OVERBORE_EROSION_FREQUENCY; + @Desc("Optional foundation columns placed beneath the assembled structure's occupied bottom cells. Columns pass through air and fluids until they reach solid ground, up to maxDepth.") private IrisStructureStiltSettings stilt = null; @Desc("If false, this placement is skipped underwater.") private boolean underwater = false; + + public IrisStructureCarveShape resolvedOverboreShape() { + return overboreShape == null ? IrisStructureCarveShape.ERODED : overboreShape; + } + + public double resolvedOverboreErosionStrength() { + if (!Double.isFinite(overboreErosionStrength)) { + return DEFAULT_OVERBORE_EROSION_STRENGTH; + } + return Math.max(0D, Math.min(1D, overboreErosionStrength)); + } + + public double resolvedOverboreErosionFrequency() { + if (!Double.isFinite(overboreErosionFrequency)) { + return DEFAULT_OVERBORE_EROSION_FREQUENCY; + } + return Math.max(MIN_OVERBORE_EROSION_FREQUENCY, + Math.min(MAX_OVERBORE_EROSION_FREQUENCY, overboreErosionFrequency)); + } } diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index ef75a162e..2723dffaf 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -153,9 +153,20 @@ public class SchemaBuilderParityTest { .getJSONObject(stilt.getString("$ref").substring("#/definitions/".length())); JSONObject stiltProperties = stiltDefinition.getJSONObject("properties"); JSONObject maxDepth = stiltProperties.getJSONObject("maxDepth"); + JSONObject overboreShape = properties.getJSONObject("overboreShape"); + JSONObject overboreErosionStrength = properties.getJSONObject("overboreErosionStrength"); + JSONObject overboreErosionFrequency = properties.getJSONObject("overboreErosionFrequency"); + String overboreShapeDefinition = overboreShape.getString("$ref") + .substring("#/definitions/".length()); assertTrue(properties.has("structures")); assertTrue(properties.has("distribution")); + assertEquals(List.of("BOX", "ROUNDED", "ERODED"), oneOfValues( + schema.getJSONObject("definitions"), overboreShapeDefinition)); + assertEquals(0D, overboreErosionStrength.getDouble("minimum"), 0D); + assertEquals(1D, overboreErosionStrength.getDouble("maximum"), 0D); + assertEquals(0.001D, overboreErosionFrequency.getDouble("minimum"), 0D); + assertEquals(1D, overboreErosionFrequency.getDouble("maximum"), 0D); assertEquals("object", stilt.getString("type")); assertTrue(stiltProperties.has("palette")); assertTrue(stiltProperties.has("supportNonOccluding")); @@ -190,6 +201,15 @@ public class SchemaBuilderParityTest { return values; } + private static List oneOfValues(JSONObject definitions, String key) { + JSONArray array = definitions.getJSONObject(key).getJSONArray("oneOf"); + List values = new ArrayList<>(array.length()); + for (int index = 0; index < array.length(); index++) { + values.add(array.getJSONObject(index).getString("const")); + } + return values; + } + @Desc("Registry dependent model.") public static class RegistryModel { @Desc("Potion field.") diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java new file mode 100644 index 000000000..fde3e2712 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java @@ -0,0 +1,102 @@ +package art.arcane.iris.core.tools; + +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.util.common.plugin.VolmitSender; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.InOrder; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +public class IrisCreatorTeleportTest { + @Test + public void createTeleportTarget_onlySelectsProductionPlayerSender() { + Player player = mock(Player.class); + VolmitSender playerSender = mock(VolmitSender.class); + VolmitSender consoleSender = mock(VolmitSender.class); + doReturn(true).when(playerSender).isPlayer(); + doReturn(player).when(playerSender).player(); + doReturn(false).when(consoleSender).isPlayer(); + + assertSame(player, IrisCreator.createTeleportTarget(playerSender, false, false)); + assertNull(IrisCreator.createTeleportTarget(consoleSender, false, false)); + assertNull(IrisCreator.createTeleportTarget(playerSender, true, false)); + assertNull(IrisCreator.createTeleportTarget(playerSender, false, true)); + } + + @Test + public void teleportSenderToCreatedWorld_loadsAndResolvesSafeEntryBeforeTeleport() { + Player player = mock(Player.class); + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + WorldRuntimeControlService runtimeControl = mock(WorldRuntimeControlService.class); + Location anchor = new Location(world, 32.5D, 80D, -15.5D); + Location safeEntry = new Location(world, 32.5D, 94D, -15.5D); + doReturn(anchor).when(runtimeControl).resolveEntryAnchor(world); + doReturn(CompletableFuture.completedFuture(chunk)) + .when(runtimeControl).requestChunkAsync(world, 2, -1, true); + doReturn(CompletableFuture.completedFuture(safeEntry)) + .when(runtimeControl).resolveSafeEntry(world, anchor); + doReturn(CompletableFuture.completedFuture(true)) + .when(runtimeControl).teleport(player, safeEntry); + + CompletableFuture result = IrisCreator.teleportSenderToCreatedWorld(player, world, runtimeControl); + + assertTrue(result.join()); + InOrder order = inOrder(runtimeControl); + order.verify(runtimeControl).resolveEntryAnchor(world); + order.verify(runtimeControl).requestChunkAsync(world, 2, -1, true); + order.verify(runtimeControl).resolveSafeEntry(world, anchor); + order.verify(runtimeControl).teleport(player, safeEntry); + } + + @Test + public void teleportSenderToCreatedWorld_preservesFalseTeleportResult() { + Player player = mock(Player.class); + World world = mock(World.class); + WorldRuntimeControlService runtimeControl = mock(WorldRuntimeControlService.class); + Location anchor = new Location(world, 0.5D, 80D, 0.5D); + doReturn(anchor).when(runtimeControl).resolveEntryAnchor(world); + doReturn(CompletableFuture.completedFuture(mock(Chunk.class))) + .when(runtimeControl).requestChunkAsync(world, 0, 0, true); + doReturn(CompletableFuture.completedFuture(anchor)) + .when(runtimeControl).resolveSafeEntry(world, anchor); + doReturn(CompletableFuture.completedFuture(false)) + .when(runtimeControl).teleport(player, anchor); + + CompletableFuture result = IrisCreator.teleportSenderToCreatedWorld(player, world, runtimeControl); + + assertFalse(result.join()); + } + + @Test + public void teleportSenderToCreatedWorld_failsWhenSafeEntryCannotResolve() { + Player player = mock(Player.class); + World world = mock(World.class); + WorldRuntimeControlService runtimeControl = mock(WorldRuntimeControlService.class); + Location anchor = new Location(world, 0.5D, 80D, 0.5D); + doReturn("irisworld").when(world).getName(); + doReturn(anchor).when(runtimeControl).resolveEntryAnchor(world); + doReturn(CompletableFuture.completedFuture(mock(Chunk.class))) + .when(runtimeControl).requestChunkAsync(world, 0, 0, true); + doReturn(CompletableFuture.completedFuture(null)) + .when(runtimeControl).resolveSafeEntry(world, anchor); + + CompletableFuture result = IrisCreator.teleportSenderToCreatedWorld(player, world, runtimeControl); + + assertThrows(CompletionException.class, result::join); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index 1326ad8aa..00290143b 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.engine.object.ObjectPlaceMode; @@ -469,8 +470,41 @@ public class IrisStructureLocatorContractTest { KList pieces = new KList<>(); pieces.add(piece(0, 60, 0, 1, 80, 1)); - assertEquals(Integer.valueOf(-17), IrisStructureLocator.resolveUndergroundBurialShift( + assertEquals(Integer.valueOf(-14), IrisStructureLocator.resolveUndergroundBurialShift( engine, pieces, placement, 60, -63, 319)); + + placement.setOverboreErosionStrength(0D); + assertEquals(Integer.valueOf(-1), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + + placement.setOverboreShape(IrisStructureCarveShape.ROUNDED); + assertEquals(Integer.valueOf(-1), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + + placement.setOverboreHeight(0); + assertEquals(Integer.valueOf(0), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + } + + @Test + public void zeroOverboreRadiusDoesNotExpandTheBurialEnvelope() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(164); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setUnderground(true) + .setMinHeight(-64) + .setMaxHeight(100) + .setOverbore(true) + .setOverboreShape(IrisStructureCarveShape.ROUNDED) + .setOverboreRadius(0) + .setOverboreHeight(0); + KList pieces = new KList<>(); + pieces.add(piece(4, 60, 7, 4, 80, 7)); + + assertEquals(Integer.valueOf(0), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + verify(engine, times(1)).getHeight(4, 7, true); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java index 4f59a0776..5b4a94b90 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.object.IrisStructureCarveShape; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -8,47 +9,56 @@ import static org.junit.Assert.assertTrue; public class IrisStructureComponentOverboreTest { @Test - public void exactPieceInteriorAlwaysCarves() { - assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.0, 0.0)); - assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.0, 1.0)); - } - - @Test - public void lowNoiseRemovesTheFormerFlatShell() { - double oneBlockAtRadiusSix = 1.0 / 6.0; + public void exactStructureFootprintAlwaysCarves() { assertTrue(IrisStructureComponent.shouldCarveOverboreCell( - oneBlockAtRadiusSix * oneBlockAtRadiusSix, 0.0)); - assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.25 * 0.25, 0.0)); + IrisStructureCarveShape.ERODED, 0D, 0D, 1D)); + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.ROUNDED, 0D, 0D, 1D)); } @Test - public void maximumNoiseStopsAtConfiguredReach() { - assertTrue(IrisStructureComponent.shouldCarveOverboreCell(1.0, 1.0)); - assertFalse(IrisStructureComponent.shouldCarveOverboreCell(1.000001, 1.0)); + public void boxModeKeepsStraightCandidateVolume() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.BOX, 100D, 0D, 1D)); } @Test - public void diagonalCornersUseEuclideanFalloff() { - assertTrue(IrisStructureComponent.shouldCarveOverboreCell(1.0, 1.0)); - assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.8 * 0.8 + 0.8 * 0.8, 1.0)); + public void roundedModeStopsAtConfiguredReach() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.ROUNDED, 1D, 0D, 1D)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.ROUNDED, 1.000001D, 1D, 0D)); } @Test - public void boundaryNoiseIsClampedAndDeterministic() { - assertEquals(0.2, IrisStructureComponent.overboreBoundaryLimit(-10.0), 0.0); - assertEquals(1.0, IrisStructureComponent.overboreBoundaryLimit(10.0), 0.0); - assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.36, 0.5)); - assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.360001, 0.5)); + public void zeroErosionStrengthMatchesRoundedMode() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.ERODED, 1D, 0D, 0D)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell( + IrisStructureCarveShape.ERODED, 1.000001D, 1D, 0D)); } @Test - public void candidateExtensionsDoNotScanImpossibleOuterShell() { - assertEquals(6, IrisStructureComponent.overboreSideExtension(6)); - assertEquals(18, IrisStructureComponent.overboreUpExtension(10.0)); + public void zeroConfiguredCeilingHasNoErodedExtension() { + assertEquals(0D, IrisStructureComponent.erodedUpReach(null, 0.16D, 1D, 0, 0, 0), 0D); + } - long oldVolume = (41L + 18L) * (31L + 27L) * (35L + 18L); - long newVolume = (41L + 12L) * (31L + 18L) * (35L + 12L); - assertTrue(newVolume < oldVolume); + @Test + public void zeroErosionStrengthKeepsTheRoundedCeiling() { + assertEquals(10D, IrisStructureComponent.erodedUpReach(null, 0.16D, 0D, 0, 0, 10), 0D); + } + + @Test + public void erosionStrengthAndNoiseAreClampedDeterministically() { + assertEquals(0.2D, IrisStructureComponent.overboreBoundaryLimit(-10D, 0.8D), 1.0E-12D); + assertEquals(1D, IrisStructureComponent.overboreBoundaryLimit(10D, 0.8D), 0D); + assertEquals(0.5D, IrisStructureComponent.overboreBoundaryLimit(0.5D, 1D), 0D); + assertEquals(1D, IrisStructureComponent.overboreBoundaryLimit(0D, -1D), 0D); + assertEquals(0D, IrisStructureComponent.overboreBoundaryLimit(0D, 10D), 0D); + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + null, 0.25D, 0.5D, 1D)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell( + null, 0.250001D, 0.5D, 1D)); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprintTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprintTest.java new file mode 100644 index 000000000..5e68215f0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprintTest.java @@ -0,0 +1,192 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureCarvingFootprintTest { + @Test + public void preservesIrregularFootprintInsteadOfFillingItsBoundingBox() { + PlacedStructurePiece piece = piece(10, 40, 20, IrisObjectRotation.of(0, 0, 0), + block(0, 0, 0), block(1, 0, 0), block(2, 0, 0), + block(0, 0, 1), block(0, 0, 2)); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from(pieces(piece), 0); + + assertNotNull(footprint); + assertEquals(10, footprint.minX()); + assertEquals(12, footprint.maxX()); + assertEquals(20, footprint.minZ()); + assertEquals(22, footprint.maxZ()); + assertEquals(3, footprint.width()); + assertEquals(3, footprint.depth()); + assertEquals(0L, footprint.distanceSquaredAt(12, 20)); + assertEquals(4L, footprint.distanceSquaredAt(12, 22)); + } + + @Test + public void reportsExactDistanceAndNearestColumnHeights() { + PlacedStructurePiece piece = piece(0, 0, 0, IrisObjectRotation.of(0, 0, 0), + block(0, 10, 0), block(0, 12, 0), + block(4, 30, 0), block(4, 35, 0)); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from(pieces(piece), 2); + StructureCarvingFootprint.Column nearestRight = footprint.sample(3, 2); + StructureCarvingFootprint.Column tied = footprint.sample(2, 0); + + assertNotNull(nearestRight); + assertEquals(5L, nearestRight.distanceSquared()); + assertEquals(30, nearestRight.sourceMinY()); + assertEquals(35, nearestRight.sourceMaxY()); + assertNotNull(tied); + assertEquals(4L, tied.distanceSquared()); + assertEquals(10, tied.sourceMinY()); + assertEquals(12, tied.sourceMaxY()); + } + + @Test + public void rotatesAndMergesOverlappingColumns() { + PlacedStructurePiece rotated = piece(10, 20, 30, IrisObjectRotation.of(0, 90, 0), + block(2, 1, 0)); + PlacedStructurePiece overlapping = piece(10, 5, 28, IrisObjectRotation.of(0, 0, 0), + block(0, 3, 0), block(0, 9, 0)); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from( + pieces(rotated, overlapping), 0); + + assertNotNull(footprint); + assertEquals(10, footprint.minX()); + assertEquals(10, footprint.maxX()); + assertEquals(28, footprint.minZ()); + assertEquals(28, footprint.maxZ()); + assertEquals(0L, footprint.distanceSquaredAt(10, 28)); + assertEquals(8, footprint.sourceMinYAt(10, 28)); + assertEquals(21, footprint.sourceMaxYAt(10, 28)); + } + + @Test + public void matchesBruteForceSquaredDistancesAcrossSparseColumns() { + int[][] sourceBlocks = new int[][]{ + block(0, 2, 0), block(3, 4, 1), block(1, 6, 4), block(5, 8, 5) + }; + PlacedStructurePiece piece = piece( + -8, 30, 11, IrisObjectRotation.of(0, 0, 0), sourceBlocks); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from(pieces(piece), 3); + + assertNotNull(footprint); + for (int z = footprint.minZ(); z <= footprint.maxZ(); z++) { + for (int x = footprint.minX(); x <= footprint.maxX(); x++) { + long expected = Long.MAX_VALUE; + for (int[] source : sourceBlocks) { + long deltaX = (long) x - (piece.getX() + source[0]); + long deltaZ = (long) z - (piece.getZ() + source[2]); + expected = Math.min(expected, deltaX * deltaX + deltaZ * deltaZ); + } + assertEquals(expected, footprint.distanceSquaredAt(x, z)); + } + } + } + + @Test + public void returnsNullForEmptyInvalidOrOversizedInputs() { + assertNull(StructureCarvingFootprint.from(null, 0)); + assertNull(StructureCarvingFootprint.from(new KList<>(), 0)); + + KList invalidPieces = new KList<>(); + invalidPieces.add((PlacedStructurePiece) null); + invalidPieces.add(new PlacedStructurePiece( + null, null, 0, 0, 0, null, 0, 0, 0, 0, 0, 0)); + assertNull(StructureCarvingFootprint.from(invalidPieces, 0)); + + PlacedStructurePiece piece = piece(0, 0, 0, IrisObjectRotation.of(0, 0, 0), block(0, 0, 0)); + assertNull(StructureCarvingFootprint.from(pieces(piece), 2, 24)); + PlacedStructurePiece overflowing = piece( + Integer.MAX_VALUE, 0, 0, IrisObjectRotation.of(0, 0, 0), block(1, 0, 0)); + assertNull(StructureCarvingFootprint.from(pieces(overflowing), 0)); + + try { + StructureCarvingFootprint.from(pieces(piece), -1); + fail("Expected negative padding to fail"); + } catch (IllegalArgumentException e) { + assertEquals("padding must be non-negative", e.getMessage()); + } + } + + @Test + public void includesTheEntirePaddingBoundary() { + PlacedStructurePiece piece = piece(7, 14, -4, IrisObjectRotation.of(0, 0, 0), block(0, 0, 0)); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from(pieces(piece), 2, 25); + + assertNotNull(footprint); + assertEquals(5, footprint.minX()); + assertEquals(9, footprint.maxX()); + assertEquals(-6, footprint.minZ()); + assertEquals(-2, footprint.maxZ()); + assertEquals(5, footprint.width()); + assertEquals(5, footprint.depth()); + assertTrue(footprint.contains(5, -6)); + assertTrue(footprint.contains(9, -2)); + assertFalse(footprint.contains(4, -6)); + assertEquals(8L, footprint.distanceSquaredAt(5, -6)); + assertNull(footprint.sample(4, -6)); + } + + @Test + public void explicitAirDoesNotExpandTheExteriorFootprint() { + IrisObject object = new IrisObject(1, 1, 1); + PlatformBlockState solid = mock(PlatformBlockState.class); + PlatformBlockState air = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + object.getBlocks().put(new IrisBlockVector(0, 0, 0), solid); + object.getBlocks().put(new IrisBlockVector(8, 4, 8), air); + PlacedStructurePiece piece = new PlacedStructurePiece( + null, object, 10, 20, 30, IrisObjectRotation.of(0, 0, 0), + 10, 20, 30, 18, 24, 38); + + StructureCarvingFootprint footprint = StructureCarvingFootprint.from(pieces(piece), 0); + + assertNotNull(footprint); + assertEquals(10, footprint.minX()); + assertEquals(10, footprint.maxX()); + assertEquals(30, footprint.minZ()); + assertEquals(30, footprint.maxZ()); + } + + private static PlacedStructurePiece piece(int x, int y, int z, IrisObjectRotation rotation, + int[]... blocks) { + IrisObject object = new IrisObject(1, 1, 1); + PlatformBlockState state = mock(PlatformBlockState.class); + for (int[] block : blocks) { + object.getBlocks().put(new IrisBlockVector(block[0], block[1], block[2]), state); + } + return new PlacedStructurePiece( + null, object, x, y, z, rotation, x, y, z, x, y, z); + } + + private static KList pieces(PlacedStructurePiece... pieces) { + KList result = new KList<>(); + for (PlacedStructurePiece piece : pieces) { + result.add(piece); + } + return result; + } + + private static int[] block(int x, int y, int z) { + return new int[]{x, y, z}; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java new file mode 100644 index 000000000..9595362d0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class IrisStructurePlacementCarveSettingsTest { + @Test + public void defaultsSelectErodedOverbore() { + IrisStructurePlacement placement = new IrisStructurePlacement(); + + assertEquals(IrisStructureCarveShape.ERODED, placement.getOverboreShape()); + assertEquals(IrisStructureCarveShape.ERODED, placement.resolvedOverboreShape()); + assertEquals(0.8D, placement.getOverboreErosionStrength(), 0D); + assertEquals(0.8D, placement.resolvedOverboreErosionStrength(), 0D); + assertEquals(0.07D, placement.getOverboreErosionFrequency(), 0D); + assertEquals(0.07D, placement.resolvedOverboreErosionFrequency(), 0D); + } + + @Test + public void carveShapesExposeTheirMaximumCeilingScale() { + assertEquals(1D, IrisStructureCarveShape.BOX.maximumCeilingScale(), 0D); + assertEquals(1D, IrisStructureCarveShape.ROUNDED.maximumCeilingScale(), 0D); + assertEquals(1.8D, IrisStructureCarveShape.ERODED.maximumCeilingScale(), 0D); + assertEquals(10, IrisStructureCarveShape.BOX.maximumCeilingExtension(10)); + assertEquals(10, IrisStructureCarveShape.ROUNDED.maximumCeilingExtension(10)); + assertEquals(18, IrisStructureCarveShape.ERODED.maximumCeilingExtension(10)); + assertEquals(10, IrisStructureCarveShape.ERODED.maximumCeilingExtension(10, 0D)); + assertEquals(14, IrisStructureCarveShape.ERODED.maximumCeilingExtension(10, 0.5D)); + assertEquals(0, IrisStructureCarveShape.ERODED.maximumCeilingExtension(0)); + } + + @Test + public void nullAndNonFiniteValuesResolveToDefaults() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setOverboreShape(null) + .setOverboreErosionStrength(Double.NaN) + .setOverboreErosionFrequency(Double.POSITIVE_INFINITY); + + assertEquals(IrisStructureCarveShape.ERODED, placement.resolvedOverboreShape()); + assertEquals(0.8D, placement.resolvedOverboreErosionStrength(), 0D); + assertEquals(0.07D, placement.resolvedOverboreErosionFrequency(), 0D); + + placement.setOverboreErosionStrength(Double.NEGATIVE_INFINITY); + placement.setOverboreErosionFrequency(Double.NaN); + + assertEquals(0.8D, placement.resolvedOverboreErosionStrength(), 0D); + assertEquals(0.07D, placement.resolvedOverboreErosionFrequency(), 0D); + } + + @Test + public void finiteValuesClampToAuthoredBounds() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setOverboreErosionStrength(-1D) + .setOverboreErosionFrequency(0D); + + assertEquals(0D, placement.resolvedOverboreErosionStrength(), 0D); + assertEquals(0.001D, placement.resolvedOverboreErosionFrequency(), 0D); + + placement.setOverboreErosionStrength(2D); + placement.setOverboreErosionFrequency(2D); + + assertEquals(1D, placement.resolvedOverboreErosionStrength(), 0D); + assertEquals(1D, placement.resolvedOverboreErosionFrequency(), 0D); + } + + @Test + public void numericSchemaBoundsMatchRuntimeBounds() throws NoSuchFieldException { + assertBounds("overboreErosionStrength", 0D, 1D); + assertBounds("overboreErosionFrequency", 0.001D, 1D); + } + + private void assertBounds(String fieldName, double expectedMinimum, + double expectedMaximum) throws NoSuchFieldException { + Field field = IrisStructurePlacement.class.getDeclaredField(fieldName); + MinNumber minimum = field.getAnnotation(MinNumber.class); + MaxNumber maximum = field.getAnnotation(MaxNumber.class); + + assertNotNull(minimum); + assertNotNull(maximum); + assertEquals(expectedMinimum, minimum.value(), 0D); + assertEquals(expectedMaximum, maximum.value(), 0D); + } +}