From 5053a644c7eb981e13776f1f5df37b8688f83ae5 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 05:03:14 -0400 Subject: [PATCH] Grow speleothems from decorators and keep them supported Dripstone and sulfur spikes are placed as oriented, thickness-graded columns by the surface and ceiling decorators through a shared IrisSpeleothems helper, replacing the ad hoc dripstone fixups, and the stacking path is unified for both directions. The perfection modifier renormalizes a spike whose support a pool replaced, dropping the unsupported run and re-tipping what remains. Surface sturdiness is asked per face now, so a downward spike tests the ceiling it hangs from. --- .../iris/modded/ModdedBlockResolution.java | 6 +- .../iris/modded/ModdedDecoratorHooks.java | 4 +- .../ModdedBlockResolutionContractTest.java | 43 +++ .../iris/engine/decorator/DecoratorCore.java | 238 +++++++------- .../decorator/DecoratorPlatformHooks.java | 2 +- .../engine/decorator/IrisSpeleothems.java | 153 +++++++++ .../modifier/IrisPerfectionModifier.java | 38 +++ .../engine/decorator/DecoratorCoreTest.java | 307 +++++++++++++++++- .../IrisPerfectionModifierSpeleothemTest.java | 175 ++++++++++ 9 files changed, 839 insertions(+), 127 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/decorator/IrisSpeleothems.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisPerfectionModifierSpeleothemTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java index 77d73be35..36331cf55 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java @@ -33,7 +33,7 @@ import net.minecraft.world.level.EmptyBlockGetter; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.LeavesBlock; -import net.minecraft.world.level.block.PointedDripstoneBlock; +import net.minecraft.world.level.block.SpeleothemBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.SpeleothemThickness; @@ -476,8 +476,8 @@ public final class ModdedBlockResolution { public static boolean isUpdatable(BlockState state) { return isStorage(state) - || (state.getBlock() instanceof PointedDripstoneBlock - && state.getValue(PointedDripstoneBlock.THICKNESS) == SpeleothemThickness.TIP); + || (state.getBlock() instanceof SpeleothemBlock + && state.getValue(SpeleothemBlock.THICKNESS) == SpeleothemThickness.TIP); } public static boolean isFoliage(BlockState state) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java index 5a46711ab..4d80acee8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java @@ -95,8 +95,8 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi } @Override - public boolean canGoOn(PlatformBlockState surface) { - return isFaceSturdy((BlockState) surface.nativeHandle(), Direction.UP); + public boolean canGoOn(PlatformBlockState surface, boolean upward) { + return isFaceSturdy((BlockState) surface.nativeHandle(), upward ? Direction.UP : Direction.DOWN); } private static boolean isFaceSturdy(BlockState state, Direction face) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java index ce3613743..d346849c1 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java @@ -1,8 +1,16 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; +import art.arcane.iris.engine.decorator.IrisSpeleothems; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; + import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.SpeleothemBlock; +import net.minecraft.world.level.block.state.properties.SpeleothemThickness; import org.junit.BeforeClass; import org.junit.Test; @@ -60,4 +68,39 @@ public class ModdedBlockResolutionContractTest { assertFalse(ModdedBlockResolution.canPlaceOnto(Blocks.CACTUS, Blocks.STONE)); assertTrue(ModdedBlockResolution.isDecorant(Blocks.CACTUS.defaultBlockState())); } + + @Test + public void sulfurAndDripstoneTipsReceivePostLoadUpdates() { + for (Block block : new Block[]{Blocks.SULFUR_SPIKE, Blocks.POINTED_DRIPSTONE}) { + for (SpeleothemThickness thickness : SpeleothemThickness.values()) { + assertEquals(thickness == SpeleothemThickness.TIP, + ModdedBlockResolution.isUpdatable(block.defaultBlockState() + .setValue(SpeleothemBlock.THICKNESS, thickness))); + } + } + assertFalse(ModdedBlockResolution.isUpdatable(Blocks.STONE.defaultBlockState())); + } + @Test + public void nativeSpikesUseModdedSupportFaces() { + ModdedDecoratorHooks hooks = new ModdedDecoratorHooks(); + DecoratorPlatformHooks.Bindings previous = DecoratorPlatformHooks.bind(hooks, hooks); + try { + for (String material : new String[]{"minecraft:sulfur_spike", "minecraft:pointed_dripstone"}) { + for (boolean upward : new boolean[]{true, false}) { + Hunk output = Hunk.newArrayHunk(1, 3, 1); + PlatformBlockState spike = ModdedBlockResolution.get(material + + "[vertical_direction=" + (upward ? "up" : "down") + ",thickness=tip]"); + int supportY = upward ? 0 : 2; + output.set(0, 1, 0, spike); + output.set(0, supportY, 0, ModdedBlockResolution.get("minecraft:stone_slab[type=top]")); + assertEquals(upward, IrisSpeleothems.isSupported(spike, output, 0, 0, 1)); + output.set(0, supportY, 0, ModdedBlockResolution.get("minecraft:stone_slab[type=bottom]")); + assertEquals(!upward, IrisSpeleothems.isSupported(spike, output, 0, 0, 1)); + } + } + } finally { + DecoratorPlatformHooks.restore(previous); + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index 6420bd650..7df6777ae 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -115,7 +115,7 @@ final class DecoratorCore { PlatformBlockState bdx = data.get(x, height, z); PlatformBlockState bd = decorator.pickBlockData(rng, irisData, realX, realZ); - if (!underwater && !canGoOn(bd, bdx) + if (!IrisSpeleothems.isSpike(bd) && !underwater && !canGoOn(bd, bdx) && !decorator.isForcePlace() && decorator.getForceBlock() == null) { return; } @@ -137,6 +137,11 @@ final class DecoratorCore { } } + if (IrisSpeleothems.isSpike(bd)) { + placeSingleSpike(bd, data, x, z, height + 1, true, underwater && !caveSkipFluid); + return; + } + String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { int lowerY = height + 1; @@ -165,8 +170,11 @@ final class DecoratorCore { } private static boolean matchesPalette(PlatformBlockState[] palette, PlatformBlockState surface) { + if (surface == null) { + return false; + } for (int i = 0; i < palette.length; i++) { - if (palette[i].equals(surface)) { + if (palette[i] == surface || surface.matches(palette[i])) { return true; } } @@ -181,6 +189,12 @@ final class DecoratorCore { if (bd == null) { return; } + if (IrisSpeleothems.isSpike(bd)) { + if (height + 1 < data.getHeight() && allowsSurface(decorator, data.get(x, height + 1, z), irisData)) { + placeSingleSpike(bd, data, x, z, height, false, false); + } + return; + } if (applyFixFaces) { bd = fixFacesForHunk(bd, data, x, z, realX, height, realZ, mantle); } @@ -194,72 +208,48 @@ final class DecoratorCore { return; } - int effectiveMax = max; - if (opts.underwater && height < opts.fluidHeight) { - effectiveMax = opts.fluidHeight; - } - - int stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax); - - if (stack == 1) { - int targetY = height + 1; - if (targetY >= data.getHeight()) { - return; - } - - PlatformBlockState existing = data.get(x, targetY, z); - if (!canReplaceStackTarget(existing, opts.underwater) - || (opts.caveSkipFluid && B.isFluid(existing))) { - return; - } - - PlatformBlockState block = decorator.pickBlockDataTop(rng, irisData, realX, realZ); - if (block == null || (!opts.underwater && !canGoOn(block, data.get(x, height, z)))) { - return; - } - - block = stackedVineBlock(block, stack, 0); - data.set(x, targetY, z, block); + PlatformBlockState support = data.get(x, height, z); + if (!allowsSurface(decorator, support, irisData)) { return; } - - PlatformBlockState bdx = data.get(x, height, z); - + int effectiveMax = opts.underwater ? Math.min(max, opts.fluidHeight - height) : max; + int stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax); + int placed = 0; + boolean hasSpikes = false; for (int i = 0; i < stack; i++) { - int h = height + i; - double threshold = ((double) i) / (stack - 1); - PlatformBlockState bd = threshold >= decorator.getTopThreshold() - ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) - : decorator.pickBlockData(rng, irisData, realX, realZ); - - if (bd == null) { + int y = height + 1 + i; + if (y >= data.getHeight()) { break; } - - if (i == 0 && !opts.underwater && !canGoOn(bd, bdx)) { - break; - } - - if (opts.underwater && height + 1 + i > opts.fluidHeight) { - break; - } - - if (height + 1 + i >= data.getHeight()) { - break; - } - - PlatformBlockState existing = data.get(x, height + 1 + i, z); + PlatformBlockState existing = data.get(x, y, z); if (!canReplaceStackTarget(existing, opts.underwater) || (opts.caveSkipFluid && B.isFluid(existing))) { break; } - - if (IrisProceduralBlocks.materialKey(bd).equals("minecraft:pointed_dripstone")) { - bd = dripstoneBlock(stack, i, "up"); + double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); + PlatformBlockState block = threshold >= decorator.getTopThreshold() + ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) + : decorator.pickBlockData(rng, irisData, realX, realZ); + if (block == null) { + break; } - - bd = stackedVineBlock(bd, stack, i); - data.set(x, height + 1 + i, z, bd); + if (IrisSpeleothems.isSpike(block)) { + if (!IrisSpeleothems.canPlace(block, data, x, z, y, true, opts.underwater)) { + break; + } + block = IrisSpeleothems.orient(block, existing, true); + hasSpikes = true; + } else if (i == 0 && !opts.underwater && !canGoOn(block, support)) { + break; + } + data.set(x, y, z, stackedVineBlock(block, stack, i)); + placed++; + } + if (placed > 0 && placed < stack) { + finishVineTip(data, x, z, height + placed); + } + if (hasSpikes) { + IrisSpeleothems.finishColumn(data, x, z, height + 1, placed, true); } } @@ -269,46 +259,46 @@ final class DecoratorCore { if (height < 0 || height >= data.getHeight()) { return; } - - int stack = computeStack(decorator, rng, realX, realZ, irisData, max); - - if (stack == 1) { - if (opts.caveSkipFluid && B.isFluid(data.get(x, height, z))) { - return; - } - PlatformBlockState block = decorator.pickBlockDataTop(rng, irisData, realX, realZ); - if (block == null) { - return; - } - block = stackedVineBlock(block, stack, 0); - data.set(x, height, z, fixFacesForHunk(block, data, x, z, realX, height, realZ, mantle)); + PlatformBlockState support = height + 1 < data.getHeight() ? data.get(x, height + 1, z) : null; + if (!allowsSurface(decorator, support, irisData)) { return; } - + int stack = computeStack(decorator, rng, realX, realZ, irisData, max); + int placed = 0; + boolean hasSpikes = false; for (int i = 0; i < stack; i++) { - int h = height - i; - if (h < 0 || h < minHeight) { + int y = height - i; + if (y < 0 || y < minHeight) { break; } - - double threshold = ((double) i) / (double) (stack - 1); - PlatformBlockState bd = threshold >= decorator.getTopThreshold() + PlatformBlockState existing = data.get(x, y, z); + if (!canReplaceStackTarget(existing, opts.underwater) + || (opts.caveSkipFluid && B.isFluid(existing))) { + break; + } + double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); + PlatformBlockState block = threshold >= decorator.getTopThreshold() ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) : decorator.pickBlockData(rng, irisData, realX, realZ); - - if (bd == null) { + if (block == null) { break; } - - if (IrisProceduralBlocks.materialKey(bd).equals("minecraft:pointed_dripstone")) { - bd = dripstoneBlock(stack, i, "down"); + if (IrisSpeleothems.isSpike(block)) { + if (!IrisSpeleothems.canPlace(block, data, x, z, y, false, opts.underwater)) { + break; + } + block = IrisSpeleothems.orient(block, existing, false); + hasSpikes = true; } - - bd = stackedVineBlock(bd, stack, i); - if (opts.caveSkipFluid && B.isFluid(data.get(x, h, z))) { - break; - } - data.set(x, h, z, fixFacesForHunk(bd, data, x, z, realX, h, realZ, mantle)); + block = stackedVineBlock(block, stack, i); + data.set(x, y, z, fixFacesForHunk(block, data, x, z, realX, y, realZ, mantle)); + placed++; + } + if (placed > 0 && placed < stack) { + finishVineTip(data, x, z, height - placed + 1); + } + if (hasSpikes) { + IrisSpeleothems.finishColumn(data, x, z, height, placed, false); } } @@ -321,6 +311,13 @@ final class DecoratorCore { return; } + if (IrisSpeleothems.isSpike(bd)) { + if (max > 1 && allowsSurface(decorator, data.get(xf, height, zf), irisData)) { + placeSingleSpike(bd, data, xf, zf, height + 1, true, false); + } + return; + } + String half = IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { int lowerY = height + 1; @@ -357,6 +354,7 @@ final class DecoratorCore { } int placed = 0; + boolean hasSpikes = false; for (int i = 0; i < stack; i++) { int h = height + 1 + i; if (h >= height + max || h >= data.getHeight()) { @@ -369,10 +367,24 @@ final class DecoratorCore { if (bd == null) { break; } + if (IrisSpeleothems.isSpike(bd)) { + if (!allowsSurface(decorator, data.get(xf, height, zf), irisData) + || !IrisSpeleothems.canPlace(bd, data, xf, zf, h, true, false)) { + break; + } + bd = IrisSpeleothems.orient(bd, data.get(xf, h, zf), true); + hasSpikes = true; + } bd = stackedVineBlock(bd, stack, i); data.set(xf, h, zf, bd); placed++; } + if (placed > 0 && placed < stack) { + finishVineTip(data, xf, zf, height + placed); + } + if (hasSpikes) { + IrisSpeleothems.finishColumn(data, xf, zf, height + 1, placed, true); + } return placed; } @@ -437,11 +449,7 @@ final class DecoratorCore { if (!B.canPlaceOnto(decorator, surface)) { return false; } - DecoratorPlatformHooks.SurfaceSturdiness sturdiness = DecoratorPlatformHooks.surfaceSturdiness(); - if (sturdiness != null) { - return sturdiness.canGoOn(surface); - } - return ((BlockData) surface.nativeHandle()).isFaceSturdy(BlockFace.UP, BlockSupport.FULL); + return IrisSpeleothems.isSturdy(surface, true); } static boolean isValidShorelineSupport(IrisDecorator decorator, PlatformBlockState decorant, PlatformBlockState surface) { @@ -477,37 +485,27 @@ final class DecoratorCore { return stack; } - private static volatile PlatformBlockState[] dripstoneUp; - private static volatile PlatformBlockState[] dripstoneDown; - - private static PlatformBlockState[] buildDripstoneArr(String direction) { - String[] order = {"tip", "frustum", "base"}; - PlatformBlockState[] arr = new PlatformBlockState[3]; - for (int k = 0; k < 3; k++) { - arr[k] = B.getState("minecraft:pointed_dripstone[thickness=" + order[k] + ",vertical_direction=" + direction + "]"); - } - return arr; + private static boolean allowsSurface(IrisDecorator decorator, PlatformBlockState surface, IrisData data) { + return decorator.isForcePlace() + || (decorator.getWhitelist() == null || matchesPalette(decorator.getWhitelistArray(data), surface)) + && (decorator.getBlacklist() == null || !matchesPalette(decorator.getBlacklistArray(data), surface)); } - private static PlatformBlockState dripstoneBlock(int stack, int i, String direction) { - int thIdx; - if (i == stack - 1) { - thIdx = 0; - } else if (i == stack - 2) { - thIdx = 1; - } else { - thIdx = 2; + private static void placeSingleSpike(PlatformBlockState spike, Hunk data, + int x, int z, int y, boolean upward, boolean allowWater) { + if (!IrisSpeleothems.canPlace(spike, data, x, z, y, upward, allowWater)) { + return; } - if (direction.equals("up")) { - if (dripstoneUp == null) { - dripstoneUp = buildDripstoneArr("up"); - } - return dripstoneUp[thIdx]; + data.set(x, y, z, IrisSpeleothems.orient(spike, data.get(x, y, z), upward)); + IrisSpeleothems.finishColumn(data, x, z, y, 1, upward); + } + + private static void finishVineTip(Hunk data, int x, int z, int y) { + PlatformBlockState state = data.get(x, y, z); + PlatformBlockState tip = stackedVineBlock(state, 1, 0); + if (tip != state) { + data.set(x, y, z, tip); } - if (dripstoneDown == null) { - dripstoneDown = buildDripstoneArr("down"); - } - return dripstoneDown[thIdx]; } static String stackedVineKey(PlatformBlockState state, int stack, int index) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java index b14f63ab0..034132558 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java @@ -34,7 +34,7 @@ public final class DecoratorPlatformHooks { } public interface SurfaceSturdiness { - boolean canGoOn(PlatformBlockState surface); + boolean canGoOn(PlatformBlockState surface, boolean upward); } public static synchronized Bindings bind(FaceFixer faceFixer, SurfaceSturdiness surfaceSturdiness) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSpeleothems.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSpeleothems.java new file mode 100644 index 000000000..ea31e0a8a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSpeleothems.java @@ -0,0 +1,153 @@ +package art.arcane.iris.engine.decorator; + +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.hunk.Hunk; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockSupport; +import org.bukkit.block.data.BlockData; + +import java.util.concurrent.ConcurrentHashMap; + +public final class IrisSpeleothems { + private static final String[] SPIKE_THICKNESSES = {"tip", "frustum", "base", "middle", "tip_merge"}; + private static final ConcurrentHashMap SPIKE_STATES = new ConcurrentHashMap<>(); + + private IrisSpeleothems() { + } + + public static boolean isSpike(PlatformBlockState state) { + if (state == null || state.key() == null) { + return false; + } + String material = IrisProceduralBlocks.materialKey(state); + return material.equals("minecraft:pointed_dripstone") || material.equals("minecraft:sulfur_spike"); + } + + public static boolean isSupported(PlatformBlockState state, Hunk data, int x, int z, int y) { + return isSupported(state, data, x, z, y, + "up".equals(IrisProceduralBlocks.propertyValue(state, "vertical_direction"))); + } + + public static void finishAtTip(Hunk data, int x, int z, int y) { + PlatformBlockState state = data.get(x, y, z); + if (!isSpike(state)) { + return; + } + boolean upward = "up".equals(IrisProceduralBlocks.propertyValue(state, "vertical_direction")); + int frontY = y + (upward ? 1 : -1); + PlatformBlockState front = frontY >= 0 && frontY < data.getHeight() ? data.get(x, frontY, z) : null; + if (!sameSpike(state, front, upward)) { + finishColumn(data, x, z, y, 1, upward); + } + } + + public static void finishColumn(Hunk data, int x, int z, int start, int placed, boolean upward) { + int step = upward ? 1 : -1; + for (int i = placed - 1; i >= 0; i--) { + int y = start + step * i; + PlatformBlockState state = data.get(x, y, z); + if (isSpike(state)) { + finishSpike(data, x, z, y, state, upward); + } + } + if (placed == 0) { + return; + } + PlatformBlockState state = data.get(x, start, z); + if (!isSpike(state)) { + return; + } + for (int y = start - step; y >= 0 && y < data.getHeight(); y -= step) { + PlatformBlockState behind = data.get(x, y, z); + if (!sameSpike(state, behind, upward)) { + break; + } + finishSpike(data, x, z, y, behind, upward); + } + } + + static boolean canPlace(PlatformBlockState spike, Hunk data, + int x, int z, int y, boolean upward, boolean allowWater) { + int supportY = y + (upward ? -1 : 1); + if (y < 0 || y >= data.getHeight() || supportY < 0 || supportY >= data.getHeight()) { + return false; + } + PlatformBlockState existing = data.get(x, y, z); + if (!B.isAir(existing) && !(allowWater && existing != null && existing.isWater())) { + return false; + } + return isSupported(spike, data, x, z, y, upward); + } + + static PlatformBlockState orient(PlatformBlockState state, PlatformBlockState existing, boolean upward) { + if (existing != null && existing.isWater() && !state.isWaterLogged()) { + state = state.withProperty("waterlogged", "true"); + } + return spikeBlock(state, upward, 0); + } + + static boolean isSturdy(PlatformBlockState surface, boolean upward) { + if (surface == null || B.isAir(surface) || B.isFluid(surface)) { + return false; + } + DecoratorPlatformHooks.SurfaceSturdiness sturdiness = DecoratorPlatformHooks.surfaceSturdiness(); + if (sturdiness != null) { + return sturdiness.canGoOn(surface, upward); + } + return ((BlockData) surface.nativeHandle()).isFaceSturdy(upward ? BlockFace.UP : BlockFace.DOWN, BlockSupport.FULL); + } + + private static boolean isSupported(PlatformBlockState state, Hunk data, + int x, int z, int y, boolean upward) { + int supportY = y + (upward ? -1 : 1); + if (supportY < 0 || supportY >= data.getHeight()) { + return false; + } + PlatformBlockState support = data.get(x, supportY, z); + return sameSpike(state, support, upward) || isSturdy(support, upward); + } + + private static PlatformBlockState[][] buildSpikeStates(PlatformBlockState state) { + PlatformBlockState[][] states = new PlatformBlockState[2][SPIKE_THICKNESSES.length]; + for (int direction = 0; direction < states.length; direction++) { + PlatformBlockState directed = state.withProperty("vertical_direction", direction == 0 ? "up" : "down"); + for (int thickness = 0; thickness < SPIKE_THICKNESSES.length; thickness++) { + states[direction][thickness] = directed.withProperty("thickness", SPIKE_THICKNESSES[thickness]); + } + } + return states; + } + + private static boolean sameSpike(PlatformBlockState state, PlatformBlockState other, boolean upward) { + return isSpike(other) + && IrisProceduralBlocks.materialKey(state).equals(IrisProceduralBlocks.materialKey(other)) + && (upward ? "up" : "down").equals(IrisProceduralBlocks.propertyValue(other, "vertical_direction")); + } + + private static PlatformBlockState spikeBlock(PlatformBlockState state, boolean upward, int thickness) { + return SPIKE_STATES.computeIfAbsent(state, IrisSpeleothems::buildSpikeStates)[upward ? 0 : 1][thickness]; + } + + private static void finishSpike(Hunk data, int x, int z, int y, PlatformBlockState state, boolean upward) { + int step = upward ? 1 : -1; + int frontY = y + step; + int backY = y - step; + PlatformBlockState front = frontY >= 0 && frontY < data.getHeight() ? data.get(x, frontY, z) : null; + PlatformBlockState back = backY >= 0 && backY < data.getHeight() ? data.get(x, backY, z) : null; + int thickness = 0; + if (sameSpike(state, front, !upward)) { + String frontThickness = IrisProceduralBlocks.propertyValue(front, "thickness"); + if ("tip".equals(frontThickness) || "tip_merge".equals(frontThickness)) { + thickness = 4; + data.set(x, frontY, z, spikeBlock(front, !upward, 4)); + } + } else if (sameSpike(state, front, upward)) { + String frontThickness = IrisProceduralBlocks.propertyValue(front, "thickness"); + thickness = "tip".equals(frontThickness) || "tip_merge".equals(frontThickness) + ? 1 : sameSpike(state, back, upward) ? 3 : 2; + } + data.set(x, y, z, spikeBlock(state, upward, thickness)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPerfectionModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPerfectionModifier.java index 29c3e878d..292490d7a 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPerfectionModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPerfectionModifier.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.modifier; +import art.arcane.iris.engine.decorator.IrisSpeleothems; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedModifier; import art.arcane.iris.engine.object.IrisProceduralBlocks; @@ -104,6 +105,9 @@ public class IrisPerfectionModifier extends EngineAssignedModifier= 0; k--) { PlatformBlockState b = output.get(finalI, k, j); + if (IrisSpeleothems.isSpike(b)) { + b = normalizeSpike(b, output, finalI, j, k, States.AIR, States.WATER); + } boolean now = b != null && !(B.isAir(b) || B.isFluid(b)); if (now != inside) { @@ -161,6 +165,40 @@ public class IrisPerfectionModifier extends EngineAssignedModifier output, + int x, int z, int y, PlatformBlockState air, PlatformBlockState water) { + if (IrisSpeleothems.isSupported(state, output, x, z, y)) { + IrisSpeleothems.finishAtTip(output, x, z, y); + return output.get(x, y, z); + } + + String material = IrisProceduralBlocks.materialKey(state); + String direction = IrisProceduralBlocks.propertyValue(state, "vertical_direction"); + boolean upward = "up".equals(direction); + int step = upward ? 1 : -1; + int nextY = y; + while (nextY >= 0 && nextY < output.getHeight()) { + PlatformBlockState current = output.get(x, nextY, z); + if (!IrisSpeleothems.isSpike(current) + || !material.equals(IrisProceduralBlocks.materialKey(current)) + || !direction.equals(IrisProceduralBlocks.propertyValue(current, "vertical_direction"))) { + break; + } + output.set(x, nextY, z, current.isWaterLogged() ? water : air); + nextY += step; + } + + if (nextY >= 0 && nextY < output.getHeight()) { + PlatformBlockState retained = output.get(x, nextY, z); + if (IrisSpeleothems.isSpike(retained) + && material.equals(IrisProceduralBlocks.materialKey(retained)) + && !direction.equals(IrisProceduralBlocks.propertyValue(retained, "vertical_direction"))) { + IrisSpeleothems.finishColumn(output, x, z, nextY, 1, !upward); + } + } + return output.get(x, y, z); + } + private void hideOres(Hunk output, boolean multicore) { BurstExecutor burst = burst().burst(multicore); int height = output.getHeight(); diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java index 14841123d..78ebca824 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java @@ -5,12 +5,20 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.BlockFace; import org.bukkit.block.BlockSupport; import org.bukkit.block.data.BlockData; import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.LinkedHashMap; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -21,8 +29,11 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class DecoratorCoreTest { @@ -429,6 +440,211 @@ public class DecoratorCoreTest { assertEquals("minecraft:twisting_vines", DecoratorCore.stackedVineKey(vine, 3, 2)); } + @Test + public void sulfurStalagmiteKeepsSulfurMaterialAndTapersToOneTip() { + Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, true, false); + + assertSpikeColumn(output, "minecraft:sulfur_spike", true, false, + "base", "middle", "middle", "frustum", "tip"); + } + + @Test + public void sulfurStalactiteKeepsWaterloggingAndPointsDown() { + Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, false, true); + + assertSpikeColumn(output, "minecraft:sulfur_spike", false, true, + "base", "middle", "middle", "frustum", "tip"); + } + + @Test + public void shortSulfurSpikesHaveCorrectTipsInBothDirections() { + for (boolean upward : new boolean[]{true, false}) { + Hunk single = placeSpikeColumn("minecraft:sulfur_spike", 1, upward, false); + Hunk pair = placeSpikeColumn("minecraft:sulfur_spike", 2, upward, false); + + assertSpikeColumn(single, "minecraft:sulfur_spike", upward, false, "tip"); + assertSpikeColumn(pair, "minecraft:sulfur_spike", upward, false, "frustum", "tip"); + } + } + + @Test + public void pointedDripstoneRetainsWaterloggingWhenStacked() { + Hunk output = placeSpikeColumn("minecraft:pointed_dripstone", 4, true, true); + + assertSpikeColumn(output, "minecraft:pointed_dripstone", true, true, + "base", "middle", "frustum", "tip"); + } + + @Test + public void clippedSpikesRebuildTheirFreeEndWithoutOverwritingObstructions() { + for (boolean upward : new boolean[]{true, false}) { + SpikeFixture fixture = new SpikeFixture(5, upward); + PlatformBlockState obstruction = sturdyState(); + fixture.output.set(0, upward ? 4 : 2, 0, obstruction); + fixture.place(); + + assertSame(obstruction, fixture.output.get(0, upward ? 4 : 2, 0)); + assertEquals("tip", fixture.property(3, "thickness")); + assertEquals("frustum", fixture.property(upward ? 2 : 4, "thickness")); + assertEquals("base", fixture.property(upward ? 1 : 5, "thickness")); + } + } + + @Test + public void ceilingSpikesRequireTheUndersideOfTheirSupport() { + SpikeFixture fixture = new SpikeFixture(3, false); + BlockData support = (BlockData) fixture.output.get(0, 4, 0).nativeHandle(); + when(support.isFaceSturdy(BlockFace.DOWN, BlockSupport.FULL)).thenReturn(false); + fixture.place(); + + assertTrue(fixture.output.get(0, 3, 0).isAir()); + } + + @Test + public void spikeStacksHonorSurfacePalettesInBothDirections() { + for (boolean upward : new boolean[]{true, false}) { + SpikeFixture fixture = new SpikeFixture(3, upward); + PlatformBlockState allowed = sturdyState(); + when(fixture.decorator.getWhitelist()).thenReturn(new KList<>()); + when(fixture.decorator.getWhitelistArray(fixture.data)).thenReturn(new PlatformBlockState[]{allowed}); + fixture.place(); + assertTrue(fixture.output.get(0, upward ? 1 : 3, 0).isAir()); + + PlatformBlockState support = fixture.output.get(0, upward ? 0 : 4, 0); + when(support.matches(allowed)).thenReturn(true); + fixture.place(); + assertEquals("tip", fixture.property(upward ? 3 : 1, "thickness")); + + when(fixture.decorator.getBlacklist()).thenReturn(new KList<>()); + when(fixture.decorator.getBlacklistArray(fixture.data)).thenReturn(new PlatformBlockState[]{allowed}); + fixture.output.set(0, upward ? 1 : 3, 0, airState()); + fixture.place(); + assertTrue(fixture.output.get(0, upward ? 1 : 3, 0).isAir()); + } + } + + @Test + public void submergedSpikesWaterlogAndStopAtLava() { + for (boolean upward : new boolean[]{true, false}) { + SpikeFixture fixture = new SpikeFixture(4, upward); + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState lava = mock(PlatformBlockState.class); + when(water.isFluid()).thenReturn(true); + when(water.isWater()).thenReturn(true); + when(lava.isFluid()).thenReturn(true); + int first = upward ? 1 : 4; + int second = upward ? 2 : 3; + fixture.output.set(0, first, 0, water); + fixture.output.set(0, second, 0, lava); + fixture.opts.underwater = true; + fixture.opts.fluidHeight = 4; + fixture.place(); + + assertEquals("true", fixture.property(first, "waterlogged")); + assertEquals("tip", fixture.property(first, "thickness")); + assertSame(lava, fixture.output.get(0, second, 0)); + } + } + + @Test + public void oppositeSulfurTipsMergeWithoutReplacingEitherColumn() { + for (boolean upward : new boolean[]{true, false}) { + SpikeFixture fixture = new SpikeFixture(5, upward); + int oppositeY = 3; + PlatformBlockState opposite = spikeState("minecraft:sulfur_spike", Map.of( + "thickness", "tip", "vertical_direction", upward ? "down" : "up", "waterlogged", "false")); + fixture.output.set(0, oppositeY, 0, opposite); + fixture.place(); + + assertEquals("tip_merge", fixture.property(oppositeY, "thickness")); + assertEquals(upward ? "down" : "up", fixture.property(oppositeY, "vertical_direction")); + assertEquals("tip_merge", fixture.property(upward ? 2 : 4, "thickness")); + assertEquals("frustum", fixture.property(upward ? 1 : 5, "thickness")); + } + } + + @Test + public void sulfurTipsDoNotMergeWithPointedDripstone() { + SpikeFixture fixture = new SpikeFixture(4, true); + PlatformBlockState dripstone = spikeState("minecraft:pointed_dripstone", Map.of( + "thickness", "tip", "vertical_direction", "down", "waterlogged", "false")); + fixture.output.set(0, 3, 0, dripstone); + fixture.place(); + + assertEquals("tip", fixture.property(2, "thickness")); + assertSame(dripstone, fixture.output.get(0, 3, 0)); + } + + @Test + public void singleDecoratorsNormalizeNativeSpikeDirectionAndThickness() { + for (boolean upward : new boolean[]{true, false}) { + SpikeFixture fixture = new SpikeFixture(1, upward); + if (upward) { + DecoratorCore.placeSurfaceSingle(fixture.decorator, 0, 0, 0, 0, 0, + fixture.output, new RNG(1L), fixture.data, false, false, null); + } else { + DecoratorCore.placeSingleAt(fixture.decorator, 0, 0, 0, 1, 0, + fixture.output, new RNG(1L), fixture.data, true, null); + } + assertEquals("tip", fixture.property(1, "thickness")); + assertEquals(upward ? "up" : "down", fixture.property(1, "vertical_direction")); + } + } + + @Test + public void extendingSpikeRepairsTheExistingTipBehindIt() { + SpikeFixture fixture = new SpikeFixture(3, true); + PlatformBlockState spike = spikeState("minecraft:sulfur_spike", Map.of( + "thickness", "tip", "vertical_direction", "up", "waterlogged", "false")); + fixture.output.set(0, 0, 0, spike); + fixture.place(); + + assertEquals("base", fixture.property(0, "thickness")); + assertEquals("middle", fixture.property(1, "thickness")); + assertEquals("frustum", fixture.property(2, "thickness")); + assertEquals("tip", fixture.property(3, "thickness")); + } + + @Test + public void singleSpikeChecksPaletteBeforeReplacingItsSupport() { + SpikeFixture fixture = new SpikeFixture(1, true); + PlatformBlockState original = fixture.output.get(0, 0, 0); + PlatformBlockState replacement = sturdyState(); + IrisBlockData forced = mock(IrisBlockData.class); + when(forced.getBlockData(fixture.data)).thenReturn(replacement); + when(fixture.decorator.getForceBlock()).thenReturn(forced); + when(fixture.decorator.getWhitelist()).thenReturn(new KList<>()); + when(fixture.decorator.getWhitelistArray(fixture.data)).thenReturn(new PlatformBlockState[]{original}); + + DecoratorCore.placeSurfaceSingle(fixture.decorator, 0, 0, 0, 0, 0, + fixture.output, new RNG(1L), fixture.data, false, false, null); + + assertSame(replacement, fixture.output.get(0, 0, 0)); + assertEquals("tip", fixture.property(1, "thickness")); + } + + @Test + public void clippedCeilingVinesRetainTheirGrowingTip() { + SpikeFixture fixture = new SpikeFixture(5, false); + PlatformBlockState plant = mock(PlatformBlockState.class); + PlatformBlockState tip = mock(PlatformBlockState.class); + PlatformBlockState obstruction = sturdyState(); + when(plant.key()).thenReturn("minecraft:weeping_vines_plant"); + when(tip.key()).thenReturn("minecraft:weeping_vines"); + when(fixture.decorator.pickBlockData(any(RNG.class), eq(fixture.data), anyDouble(), anyDouble())).thenReturn(tip); + when(fixture.decorator.pickBlockDataTop(any(RNG.class), eq(fixture.data), anyDouble(), anyDouble())).thenReturn(tip); + fixture.output.set(0, 3, 0, obstruction); + try (MockedStatic blocks = mockStatic(B.class, CALLS_REAL_METHODS)) { + blocks.when(() -> B.getState("minecraft:weeping_vines_plant")).thenReturn(plant); + blocks.when(() -> B.getState("minecraft:weeping_vines")).thenReturn(tip); + fixture.place(); + } + + assertSame(plant, fixture.output.get(0, 5, 0)); + assertSame(tip, fixture.output.get(0, 4, 0)); + assertSame(obstruction, fixture.output.get(0, 3, 0)); + } + @Test public void boundDecoratorHooksWinWhenBukkitClassesArePresent() { PlatformBlockState vine = mock(PlatformBlockState.class); @@ -441,7 +657,7 @@ public class DecoratorCoreTest { when(vine.isVineBlock()).thenReturn(true); when(faceFixer.fixFaces(vine, output, 0, 0, 0, 0, 0, null)).thenReturn(fixed); when(decorator.canPlaceOnto(surface)).thenReturn(true); - when(sturdiness.canGoOn(surface)).thenReturn(true); + when(sturdiness.canGoOn(surface, true)).thenReturn(true); DecoratorPlatformHooks.Bindings previous = DecoratorPlatformHooks.bind(faceFixer, sturdiness); try { assertSame(fixed, DecoratorCore.fixFacesForHunk(vine, output, 0, 0, 0, 0, 0, null)); @@ -457,6 +673,58 @@ public class DecoratorCoreTest { return air; } + private Hunk placeSpikeColumn(String material, int height, boolean upward, boolean waterlogged) { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState spike = spikeState(material, Map.of( + "thickness", "tip_merge", "vertical_direction", upward ? "down" : "up", + "waterlogged", Boolean.toString(waterlogged))); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(height); + when(decorator.getTopThreshold()).thenReturn(0.75); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(spike); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(spike); + + Hunk output = Hunk.newArrayHunk(1, height + 2, 1); + for (int y = 1; y <= height; y++) { + output.set(0, y, 0, airState()); + } + PlatformBlockState support = sturdyState(); + output.set(0, upward ? 0 : height + 1, 0, support); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + if (upward) { + DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 0, height, output, new RNG(1L), data, opts); + } else { + DecoratorCore.placeStackDown(decorator, 0, 0, 0, 0, height, 1, output, new RNG(1L), data, height, opts, null); + } + assertSame(support, output.get(0, upward ? 0 : height + 1, 0)); + return output; + } + + private void assertSpikeColumn(Hunk output, String material, + boolean upward, boolean waterlogged, String... thicknesses) { + for (int index = 0; index < thicknesses.length; index++) { + int y = upward ? index + 1 : thicknesses.length - index; + assertEquals(material + "[thickness=" + thicknesses[index] + + ",vertical_direction=" + (upward ? "up" : "down") + + ",waterlogged=" + waterlogged + "]", + output.get(0, y, 0).key()); + } + } + + private PlatformBlockState spikeState(String material, Map properties) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(material + "[thickness=" + properties.get("thickness") + + ",vertical_direction=" + properties.get("vertical_direction") + + ",waterlogged=" + properties.get("waterlogged") + "]"); + when(state.canPlaceOnto(any(PlatformBlockState.class))).thenReturn(true); + when(state.withProperty(anyString(), anyString())).thenAnswer(invocation -> { + Map updated = new LinkedHashMap<>(properties); + updated.put(invocation.getArgument(0), invocation.getArgument(1)); + return spikeState(material, updated); + }); + return state; + } + private PlatformBlockState tallPlantState() { return tallPlantState(mock(PlatformBlockState.class), mock(PlatformBlockState.class)); } @@ -476,4 +744,41 @@ public class DecoratorCoreTest { when(blockData.isFaceSturdy(any(), eq(BlockSupport.FULL))).thenReturn(true); return support; } + private class SpikeFixture { + private final IrisDecorator decorator = mock(IrisDecorator.class); + private final IrisData data = mock(IrisData.class); + private final DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + private final Hunk output; + private final int height; + private final boolean upward; + + private SpikeFixture(int height, boolean upward) { + this.height = height; + this.upward = upward; + output = Hunk.newArrayHunk(1, height + 2, 1); + for (int y = 0; y < output.getHeight(); y++) { + output.set(0, y, 0, airState()); + } + output.set(0, upward ? 0 : height + 1, 0, sturdyState()); + PlatformBlockState spike = spikeState("minecraft:sulfur_spike", Map.of( + "thickness", "tip_merge", "vertical_direction", upward ? "down" : "up", "waterlogged", "false")); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(height); + when(decorator.getTopThreshold()).thenReturn(0.75); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(spike); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(spike); + } + + private void place() { + if (upward) { + DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 0, height, output, new RNG(1L), data, opts); + } else { + DecoratorCore.placeStackDown(decorator, 0, 0, 0, 0, height, 1, output, new RNG(1L), data, height, opts, null); + } + } + + private String property(int y, String name) { + return IrisProceduralBlocks.propertyValue(output.get(0, y, 0), name); + } + } + } diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisPerfectionModifierSpeleothemTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisPerfectionModifierSpeleothemTest.java new file mode 100644 index 000000000..d89c527c4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisPerfectionModifierSpeleothemTest.java @@ -0,0 +1,175 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.decorator.IrisSpeleothems; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import org.bukkit.block.BlockSupport; +import org.bukkit.block.data.BlockData; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisPerfectionModifierSpeleothemTest { + @Test + public void poolReplacementRemovesPreviouslySupportedSpikesInBothDirections() { + for (String material : new String[]{"minecraft:sulfur_spike", "minecraft:pointed_dripstone"}) { + for (boolean upward : new boolean[]{true, false}) { + for (String fluid : new String[]{"minecraft:water", "minecraft:lava"}) { + Fixture fixture = new Fixture(); + fixture.column(material, upward, false); + fixture.normalize(); + for (int y = 2; y <= 4; y++) { + assertTrue(IrisSpeleothems.isSpike(fixture.output.get(0, y, 0))); + } + + int supportY = upward ? 1 : 5; + PlatformBlockState pool = fluid.equals("minecraft:water") ? fixture.water : fluid(fluid); + fixture.output.set(0, supportY, 0, pool); + fixture.normalize(); + + assertSame(pool, fixture.output.get(0, supportY, 0)); + for (int y = 2; y <= 4; y++) { + assertSame(fixture.air, fixture.output.get(0, y, 0)); + } + } + } + } + } + + @Test + public void removedWaterloggedSpikesRestoreWater() { + for (boolean upward : new boolean[]{true, false}) { + Fixture fixture = new Fixture(); + fixture.column("minecraft:sulfur_spike", upward, true); + fixture.output.set(0, upward ? 1 : 5, 0, fixture.water); + fixture.normalize(); + + for (int y = 2; y <= 4; y++) { + assertSame(fixture.water, fixture.output.get(0, y, 0)); + } + } + } + + @Test + public void removingOneMergedColumnRepairsTheSupportedOppositeTip() { + for (boolean removeUpward : new boolean[]{true, false}) { + Fixture fixture = new Fixture(); + fixture.output.set(0, 0, 0, sturdyState()); + fixture.output.set(0, 7, 0, sturdyState()); + fixture.output.set(0, 1, 0, spike("minecraft:sulfur_spike", true, "base", false)); + fixture.output.set(0, 2, 0, spike("minecraft:sulfur_spike", true, "frustum", false)); + fixture.output.set(0, 3, 0, spike("minecraft:sulfur_spike", true, "tip_merge", false)); + fixture.output.set(0, 4, 0, spike("minecraft:sulfur_spike", false, "tip_merge", false)); + fixture.output.set(0, 5, 0, spike("minecraft:sulfur_spike", false, "frustum", false)); + fixture.output.set(0, 6, 0, spike("minecraft:sulfur_spike", false, "base", false)); + fixture.output.set(0, removeUpward ? 0 : 7, 0, fixture.water); + fixture.normalize(); + + for (int y = removeUpward ? 1 : 4; y <= (removeUpward ? 3 : 6); y++) { + assertSame(fixture.air, fixture.output.get(0, y, 0)); + } + assertEquals("tip", fixture.property(removeUpward ? 4 : 3, "thickness")); + assertEquals("frustum", fixture.property(removeUpward ? 5 : 2, "thickness")); + assertEquals("base", fixture.property(removeUpward ? 6 : 1, "thickness")); + assertEquals(removeUpward ? "down" : "up", + fixture.property(removeUpward ? 4 : 3, "vertical_direction")); + } + } + + @Test + public void objectRemovingTheFreeEndRetapersTheSupportedRemainder() { + for (boolean upward : new boolean[]{true, false}) { + Fixture fixture = new Fixture(); + fixture.column("minecraft:sulfur_spike", upward, false); + fixture.output.set(0, upward ? 4 : 2, 0, fixture.air); + fixture.normalize(); + + assertEquals("tip", fixture.property(3, "thickness")); + assertEquals("frustum", fixture.property(upward ? 2 : 4, "thickness")); + assertSame(fixture.air, fixture.output.get(0, upward ? 4 : 2, 0)); + } + } + + private static PlatformBlockState spike(String material, boolean upward, String thickness, boolean waterlogged) { + return spike(material, Map.of( + "vertical_direction", upward ? "up" : "down", + "thickness", thickness, + "waterlogged", Boolean.toString(waterlogged))); + } + + private static PlatformBlockState spike(String material, Map properties) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(material + "[thickness=" + properties.get("thickness") + + ",vertical_direction=" + properties.get("vertical_direction") + + ",waterlogged=" + properties.get("waterlogged") + "]"); + when(state.isWaterLogged()).thenReturn(Boolean.parseBoolean(properties.get("waterlogged"))); + when(state.withProperty(anyString(), anyString())).thenAnswer(invocation -> { + Map updated = new LinkedHashMap<>(properties); + updated.put(invocation.getArgument(0), invocation.getArgument(1)); + return spike(material, updated); + }); + return state; + } + + private static PlatformBlockState sturdyState() { + PlatformBlockState state = mock(PlatformBlockState.class); + BlockData nativeState = mock(BlockData.class); + when(state.key()).thenReturn("minecraft:sulfur"); + when(state.nativeHandle()).thenReturn(nativeState); + when(nativeState.isFaceSturdy(any(), eq(BlockSupport.FULL))).thenReturn(true); + return state; + } + + private static PlatformBlockState fluid(String material) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(material); + when(state.isFluid()).thenReturn(true); + when(state.isWater()).thenReturn(material.equals("minecraft:water")); + return state; + } + + private static class Fixture { + private final PlatformBlockState air = mock(PlatformBlockState.class); + private final PlatformBlockState water = fluid("minecraft:water"); + private final Hunk output = Hunk.newArrayHunk(1, 9, 1); + + private Fixture() { + when(air.key()).thenReturn("minecraft:air"); + when(air.isAir()).thenReturn(true); + for (int y = 0; y < output.getHeight(); y++) { + output.set(0, y, 0, air); + } + } + + private void column(String material, boolean upward, boolean waterlogged) { + output.set(0, upward ? 1 : 5, 0, sturdyState()); + output.set(0, upward ? 2 : 4, 0, spike(material, upward, "base", waterlogged)); + output.set(0, 3, 0, spike(material, upward, "frustum", waterlogged)); + output.set(0, upward ? 4 : 2, 0, spike(material, upward, "tip", waterlogged)); + } + + private void normalize() { + for (int y = output.getHeight() - 1; y >= 0; y--) { + PlatformBlockState state = output.get(0, y, 0); + if (IrisSpeleothems.isSpike(state)) { + IrisPerfectionModifier.normalizeSpike(state, output, 0, 0, y, air, water); + } + } + } + + private String property(int y, String property) { + return IrisProceduralBlocks.propertyValue(output.get(0, y, 0), property); + } + } +}