diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index 2015cadf0..3648e6bcd 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -42,13 +42,10 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public final class WorldRuntimeControlService { - private static final int STUDIO_ENTRY_VIEW_DISTANCE = 2; private static final int MAX_SAFE_ENTRY_HORIZONTAL_RADIUS = 15; private static final int SAFE_ENTRY_UPWARD_ALLOWANCE = 32; private static final double BLOCK_CENTER = 0.5D; private static final double COLLISION_EPSILON = 0.000001D; - private static final Method PLAYER_GET_VIEW_DISTANCE_METHOD = findPlayerMethod("getViewDistance"); - private static final Method PLAYER_SET_VIEW_DISTANCE_METHOD = findPlayerMethod("setViewDistance", int.class); private static final Set UNSAFE_ENTRY_MATERIALS = Set.of( Material.CACTUS, Material.CAMPFIRE, @@ -370,7 +367,6 @@ public final class WorldRuntimeControlService { CompletableFuture future = new CompletableFuture<>(); GameModeRestore modeRestore = new GameModeRestore(player); - PlayerViewDistanceRestore viewDistanceRestore = new PlayerViewDistanceRestore(player); AtomicReference> activeTeleport = new AtomicReference<>(); future.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success)) { @@ -380,7 +376,6 @@ public final class WorldRuntimeControlService { if (teleport != null && !teleport.isDone()) { teleport.cancel(false); } - viewDistanceRestore.restore(); modeRestore.restore(); }); boolean scheduled = J.runEntity(player, () -> { @@ -390,9 +385,7 @@ public final class WorldRuntimeControlService { } if (gameMode != null) { modeRestore.apply(gameMode); - viewDistanceRestore.apply(); if (future.isDone()) { - viewDistanceRestore.restore(); modeRestore.restore(); return; } @@ -417,7 +410,6 @@ public final class WorldRuntimeControlService { if (Boolean.TRUE.equals(success)) { if (future.complete(true)) { - viewDistanceRestore.restore(); J.runEntity(player, () -> IrisServices.get(BoardSVC.class).updatePlayer(player)); } return; @@ -804,14 +796,6 @@ public final class WorldRuntimeControlService { return method.invoke(instance); } - private static Method findPlayerMethod(String methodName, Class... parameterTypes) { - try { - return Player.class.getMethod(methodName, parameterTypes); - } catch (NoSuchMethodException ignored) { - return null; - } - } - @FunctionalInterface interface TeleportExecutor { CompletableFuture teleport(Player player, Location location); @@ -857,56 +841,4 @@ public final class WorldRuntimeControlService { } } } - - private static final class PlayerViewDistanceRestore { - private final Player player; - private final AtomicBoolean changed; - private final AtomicBoolean restored; - private int previousViewDistance; - - private PlayerViewDistanceRestore(Player player) { - this.player = player; - changed = new AtomicBoolean(false); - restored = new AtomicBoolean(false); - } - - private void apply() { - if (PLAYER_GET_VIEW_DISTANCE_METHOD == null || PLAYER_SET_VIEW_DISTANCE_METHOD == null) { - return; - } - try { - Object currentValue = PLAYER_GET_VIEW_DISTANCE_METHOD.invoke(player); - if (!(currentValue instanceof Number)) { - return; - } - int currentViewDistance = ((Number) currentValue).intValue(); - if (currentViewDistance <= STUDIO_ENTRY_VIEW_DISTANCE) { - return; - } - PLAYER_SET_VIEW_DISTANCE_METHOD.invoke(player, STUDIO_ENTRY_VIEW_DISTANCE); - previousViewDistance = currentViewDistance; - changed.set(true); - } catch (ReflectiveOperationException failure) { - IrisLogging.reportError("Failed to apply a temporary player view distance for a studio teleport.", failure); - } - } - - private void restore() { - if (!changed.get() || !restored.compareAndSet(false, true)) { - return; - } - Runnable restoration = () -> { - try { - PLAYER_SET_VIEW_DISTANCE_METHOD.invoke(player, previousViewDistance); - } catch (ReflectiveOperationException failure) { - IrisLogging.reportError("Failed to restore a player's view distance after a studio teleport.", failure); - } - }; - if (!J.runEntity(player, restoration)) { - IrisLogging.reportError( - "Failed to restore a player's view distance after a studio teleport.", - new IllegalStateException("The player entity scheduler rejected the view-distance restoration.")); - } - } - } } diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index fe177d1aa..b67b4e8a3 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -518,7 +518,7 @@ public class BoardSVC implements IrisService, BoardProvider { lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_REGION, MessageArgument.untrusted("region", engine.getRegion(x, z).getName()))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BIOME, MessageArgument.untrusted("biome", engine.getBiomeOrMantle(x, y, z).getName()))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_HEIGHT, MessageArgument.trusted("height", Math.round(engine.getHeight(x, z))))); - lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_SLOPE, MessageArgument.trusted("slope", Form.f(engine.getComplex().getSlopeStream().get(x, z), 2)))); + lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_SLOPE, MessageArgument.trusted("slope", Form.f(engine.getSlope(x, z), 2)))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BLOCK_UPDATES, MessageArgument.trusted("updates", Form.f(engine.getBlockUpdatesPerSecond())))); lines.add("&7&m "); this.lines = lines; diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index f6ec2dbcf..df2a4a424 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -137,6 +137,7 @@ public class IrisComplex implements DataProvider { private ProceduralStream overlayStream; private ProceduralStream heightFluidStream; private ProceduralStream slopeStream; + private ProceduralStream naturalSlopeStream; private ProceduralStream riverDistanceStream; private ProceduralStream riverFlowStream; private ProceduralStream riverCarveWeightStream; @@ -316,7 +317,8 @@ public class IrisComplex implements DataProvider { (x, z) -> naturalHeightStream.getDouble(x, z), (x, z) -> describeNaturalHeight(engine, x, z), this::sampleNaturalOcean, - footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint) + footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint), + () -> engine.getPlatformHooks().isMainThread() )); } heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) @@ -325,6 +327,7 @@ public class IrisComplex implements DataProvider { .round(); slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .slope(3).cache2DDouble("slopeStream", engine, cacheSize); + naturalSlopeStream = naturalHeightStream.slope(3).cache2DDouble("naturalSlopeStream", engine, cacheSize); trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream @@ -704,6 +707,11 @@ public class IrisComplex implements DataProvider { return (int) Math.round(naturalHeightStream.getDouble(x, z)); } + /** The terrain slope of a column from the natural height stream only: never touches hydrology or its caches. */ + public double naturalSlope(int x, int z) { + return naturalSlopeStream.getDouble(x, z); + } + /** The surface biome a column would have without any river content, from natural streams only. */ public IrisBiome naturalSurfaceBiome(int x, int z) { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 0737ac89d..3c25925c5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -348,6 +348,15 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return getPlatformHooks().isMainThread() && !getComplex().isHydrologyPlanned(x, z); } + /** The terrain slope at a column, answered from natural terrain when the server thread may not wait. */ + @BlockCoordinates + default double getSlope(int x, int z) { + if (answersFromNaturalTerrain(x, z)) { + return getComplex().naturalSlope(x, z); + } + return getComplex().getSlopeStream().getDouble(x, z); + } + @BlockCoordinates default int getHeight(int x, int z) { return getHeight(x, z, true); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index 2db9e4868..7aef40bcb 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -16,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; public final class HydrologyTileCache implements AutoCloseable { private static final int DEFAULT_MAXIMUM_ENTRIES = 64; @@ -31,6 +32,7 @@ public final class HydrologyTileCache implements AutoCloseable { private final ThreadLocal localChunkColumns; private final Executor prefetchExecutor; private final ConcurrentHashMap> planning; + private final BooleanSupplier waitingForbidden; public HydrologyTileCache(HydrologyPlanner planner) { this(planner, DEFAULT_MAXIMUM_ENTRIES); @@ -40,13 +42,26 @@ public final class HydrologyTileCache implements AutoCloseable { this(planner, maximumEntries, null); } + public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) { + this(planner, maximumEntries, prefetchExecutor, null); + } + /** * @param prefetchExecutor runs neighbour tile planning ahead of generation, or null to plan * tiles only when a chunk needs them + * @param waitingForbidden true on a thread that must never wait for a cold plan, so a sample + * from it is answered as "no hydrology here" while the tiles it needs + * are handed to the prefetch executor; null lets every caller wait */ - public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) { + public HydrologyTileCache( + HydrologyPlanner planner, + int maximumEntries, + Executor prefetchExecutor, + BooleanSupplier waitingForbidden + ) { this.planner = Objects.requireNonNull(planner, "planner"); this.prefetchExecutor = prefetchExecutor; + this.waitingForbidden = waitingForbidden; this.planning = new ConcurrentHashMap<>(); if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive."); @@ -149,17 +164,31 @@ public final class HydrologyTileCache implements AutoCloseable { if (composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))) != null) { return true; } - boolean planned = true; + return !requestUnplanned(chunkX, chunkZ); + } + + /** + * Hands every tile the chunk needs and does not have to the prefetch executor and says whether + * any was missing. Nothing is planned on the caller, so this is safe from a thread that must + * never wait for a cold plan. + */ + private boolean requestUnplanned(int chunkX, int chunkZ) { + boolean missing = false; for (HydrologyTileKey key : relevantKeys(chunkX, chunkZ)) { if (tiles.getIfPresent(key) != null) { continue; } - planned = false; + missing = true; if (prefetchExecutor != null) { prefetch(key); } } - return planned; + return missing; + } + + /** Whether the calling thread must never wait for a cold hydrology plan. */ + private boolean waitsForbidden() { + return waitingForbidden != null && waitingForbidden.getAsBoolean(); } public HydrologyRenderSample renderAt(int blockX, int blockZ) { @@ -206,6 +235,14 @@ public final class HydrologyTileCache implements AutoCloseable { return local.columns(); } long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)); + if (composedChunks.getIfPresent(packedChunk) == null + && waitsForbidden() + && requestUnplanned(chunkX, chunkZ)) { + // A thread that must never wait (the server thread answering a query) gets a riverless + // answer while the tiles it would have waited for are planned on the prefetch executor. + // The empty result is never cached, so the next query composes the real columns. + return ChunkColumns.empty(chunkX, chunkZ); + } ChunkColumns columns = composedChunks.get( packedChunk, ignored -> composeChunkColumns(chunkX, chunkZ) @@ -467,6 +504,11 @@ public final class HydrologyTileCache implements AutoCloseable { } private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { + /** A chunk with no hydrology at all, the answer for a caller that may not wait for its plan. */ + private static ChunkColumns empty(int chunkX, int chunkZ) { + return new ChunkColumns(chunkX, chunkZ, new HydrologyColumnSample[CHUNK_COLUMN_COUNT]); + } + private Optional columnAt(int blockX, int blockZ) { int localX = Math.floorMod(blockX, CHUNK_SIZE); int localZ = Math.floorMod(blockZ, CHUNK_SIZE); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index 764f4ad41..530d73548 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -125,7 +125,12 @@ public final class IrisHydrologyRuntime implements AutoCloseable { 0, context.caveViewFactory() ); - this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES, IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + this.cache = new HydrologyTileCache( + planner, + MAXIMUM_CACHE_TILES, + IrisPlatforms.isBound() ? MultiBurst.hydrology : null, + context.waitingForbidden() + ); IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d", settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism()); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java index c193b85f7..b50cd7d5b 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; import art.arcane.iris.engine.object.IrisDimension; import java.util.Objects; +import java.util.function.BooleanSupplier; public record IrisHydrologyRuntimeContext( long seed, @@ -15,8 +16,14 @@ public record IrisHydrologyRuntimeContext( IrisHydrologyNaturalHeightProvider naturalHeightProvider, IrisHydrologyNaturalHeightDescriber naturalHeightDescriber, IrisHydrologyNaturalOceanClassifier naturalOceanClassifier, - HydrologyCaveVoxelViewFactory caveViewFactory + HydrologyCaveVoxelViewFactory caveViewFactory, + BooleanSupplier waitingForbidden ) { + /** + * {@code waitingForbidden} is true on a thread that must never wait for a cold hydrology plan, + * the server thread above all: a sample taken there is answered as "no hydrology here" while the + * tiles it needs are planned on the prefetch pool. + */ public IrisHydrologyRuntimeContext { if (worldHeight < 3) { throw new IllegalArgumentException("worldHeight must be at least three"); @@ -28,5 +35,6 @@ public record IrisHydrologyRuntimeContext( Objects.requireNonNull(naturalHeightDescriber); Objects.requireNonNull(naturalOceanClassifier); Objects.requireNonNull(caveViewFactory); + Objects.requireNonNull(waitingForbidden); } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index 936a7569c..b21a3176d 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -99,6 +99,8 @@ public class IrisDepositModifier extends EngineAssignedModifier 0D + && isAdjacentToAir(data, nx, ny, nz); + if (shouldDiscardExposed(k.getDiscardChanceOnAirExposure(), rng.d(), adjacentToAir)) { continue; } @@ -247,6 +257,15 @@ public class IrisDepositModifier extends EngineAssignedModifier 0D && sample < Math.min(1D, chance); } + static boolean isTerrainSurface( + Hunk data, int x, int y, int z, int columnSurface) { + return y == columnSurface + || isExteriorAirAt(data, x - 1, y, z) + || isExteriorAirAt(data, x + 1, y, z) + || isExteriorAirAt(data, x, y - 1, z) + || isExteriorAirAt(data, x, y + 1, z) + || isExteriorAirAt(data, x, y, z - 1) + || isExteriorAirAt(data, x, y, z + 1); + } + static boolean isAdjacentToAir(Hunk data, int x, int y, int z) { return isAirAt(data, x - 1, y, z) || isAirAt(data, x + 1, y, z) @@ -295,6 +325,18 @@ public class IrisDepositModifier extends EngineAssignedModifier data, int x, int y, int z) { + if (x < 0 || x >= data.getWidth() + || y < 0 || y >= data.getHeight() + || z < 0 || z >= data.getDepth()) { + return false; + } + PlatformBlockState state = data.getRaw(x, y, z); + return state == null + || (state.isAir() + && !"minecraft:cave_air".equals(IrisProceduralBlocks.materialKey(state))); + } + static boolean passesOreFrequency(double multiplier, double sample) { return multiplier >= 1D || sample < Math.max(0D, multiplier); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 2db10c7da..51c813473 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -96,6 +96,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { private final transient AtomicCache> undergroundOreCache = new AtomicCache<>(); private final transient AtomicCache surfaceOreBoundsCache = new AtomicCache<>(); private final transient AtomicCache undergroundOreBoundsCache = new AtomicCache<>(); + private final transient AtomicCache> surfaceOreReplaceableBlockData = new AtomicCache<>(); private final transient AtomicCache> decoratorBuckets = new AtomicCache<>(); private final transient AtomicCache derivativeResolved = new AtomicCache<>(); private final transient AtomicCache vanillaDerivativeResolved = new AtomicCache<>(); @@ -207,6 +208,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisDepositGenerator.class) @Desc("Define biome deposit generators that add onto the existing regional and global deposit generators") private KList deposits = new KList<>(); + @ArrayType(type = String.class) + @Desc("Optional exact host block ids that replace each ore deposit's surfaceReplaceableBlocks on exterior terrain inside this biome. Omit to inherit the deposit setting; an empty list forbids surface ore.") + private KList surfaceOreReplaceableBlocks = null; @MinNumber(0) @MaxNumber(1) @Desc("Multiplier applied to the frequency of every ore deposit whose center is inside this biome. A value of 0.4 keeps 40% of ore veins while leaving non-ore deposits unchanged.") @@ -226,6 +230,14 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(type = IrisOreGenerator.class, min = 1) private KList ores = new KList<>(); + public boolean hasSurfaceOreReplaceableBlocks() { + return IrisBiomeOres.hasSurfaceOreReplaceableBlocks(this); + } + + public boolean canReplaceSurfaceOre(PlatformBlockState state) { + return IrisBiomeOres.canReplaceSurfaceOre(this, state); + } + public PlatformBlockState generateOres(int x, int y, int z, RNG rng, IrisData data, boolean surface) { return IrisBiomeOres.generateOres(this, x, y, z, rng, data, surface); } @@ -299,6 +311,12 @@ public class IrisBiome extends IrisRegistrant implements IRare { undergroundOreBoundsCache.reset(); } + public IrisBiome setSurfaceOreReplaceableBlocks(KList surfaceOreReplaceableBlocks) { + this.surfaceOreReplaceableBlocks = surfaceOreReplaceableBlocks; + surfaceOreReplaceableBlockData.reset(); + return this; + } + public KList getSurfaceOreGenerators() { return IrisBiomeOres.getSurfaceOreGenerators(this); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java index ea8c71769..0551d3d88 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java @@ -21,7 +21,9 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.math.RNG; /** @@ -54,6 +56,19 @@ final class IrisBiomeOres { return !getUndergroundOres(biome).isEmpty(); } + static boolean hasSurfaceOreReplaceableBlocks(IrisBiome biome) { + return biome.getSurfaceOreReplaceableBlocks() != null; + } + + static boolean canReplaceSurfaceOre(IrisBiome biome, PlatformBlockState state) { + if (!hasSurfaceOreReplaceableBlocks(biome)) { + return true; + } + return biome.getSurfaceOreReplaceableBlockData() + .aquire(() -> resolveSurfaceOreReplaceableBlocks(biome)) + .contains(IrisProceduralBlocks.materialKey(state)); + } + static KList getSurfaceOreGenerators(IrisBiome biome) { return getOres(biome, true); } @@ -126,4 +141,15 @@ final class IrisBiomeOres { } return null; } + + private static KSet resolveSurfaceOreReplaceableBlocks(IrisBiome biome) { + KSet resolved = new KSet<>(); + for (String key : biome.getSurfaceOreReplaceableBlocks()) { + PlatformBlockState state = B.getStateOrNull(key, false); + if (state != null) { + resolved.add(IrisProceduralBlocks.materialKey(state)); + } + } + return resolved; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java index eef20e1a7..14a2a47f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java @@ -56,6 +56,7 @@ public class IrisDepositGenerator { private final transient AtomicCache> blockData = new AtomicCache<>(); private final transient AtomicCache ore = new AtomicCache<>(); private final transient AtomicCache> replaceableBlockData = new AtomicCache<>(); + private final transient AtomicCache> surfaceReplaceableBlockData = new AtomicCache<>(); private final transient ConcurrentMap> scaledObjects = new ConcurrentHashMap<>(); @Required @MinNumber(-8192) @@ -120,6 +121,9 @@ public class IrisDepositGenerator { @ArrayType(min = 1, type = String.class) @Desc("Optional block ids this deposit may replace. An empty list retains Iris's any-solid behavior.") private KList replaceableBlocks = new KList<>(); + @ArrayType(min = 1, type = String.class) + @Desc("Optional exact host block ids required when an ore candidate occupies an exterior terrain surface. Cave-air walls are unaffected. An empty list retains the normal replaceableBlocks behavior.") + private KList surfaceReplaceableBlocks = new KList<>(); @Desc("Chooses whether includedBiomes and excludedBiomes inspect the surface biome or the cave biome at the deposit origin.") private IrisDepositBiomeScope biomeScope = IrisDepositBiomeScope.CAVE; @ArrayType(min = 1, type = String.class) @@ -476,6 +480,36 @@ public class IrisDepositGenerator { .contains(IrisProceduralBlocks.materialKey(state)); } + public IrisDepositGenerator setSurfaceReplaceableBlocks(KList surfaceReplaceableBlocks) { + this.surfaceReplaceableBlocks = surfaceReplaceableBlocks == null + ? new KList<>() + : surfaceReplaceableBlocks; + surfaceReplaceableBlockData.reset(); + return this; + } + + public boolean canReplaceSurface(PlatformBlockState state) { + if (surfaceReplaceableBlocks == null || surfaceReplaceableBlocks.isEmpty()) { + return true; + } + return surfaceReplaceableBlockData.aquire(this::resolveSurfaceReplaceableBlocks) + .contains(IrisProceduralBlocks.materialKey(state)); + } + + public boolean hasSurfaceReplaceableBlocks(IrisBiome surfaceBiome) { + if (surfaceBiome != null && surfaceBiome.hasSurfaceOreReplaceableBlocks()) { + return true; + } + return surfaceReplaceableBlocks != null && !surfaceReplaceableBlocks.isEmpty(); + } + + public boolean canReplaceSurface(PlatformBlockState state, IrisBiome surfaceBiome) { + if (surfaceBiome != null && surfaceBiome.hasSurfaceOreReplaceableBlocks()) { + return surfaceBiome.canReplaceSurfaceOre(state); + } + return canReplaceSurface(state); + } + public boolean matchesBiome(IrisBiome surfaceBiome, IrisBiome caveBiome) { IrisBiome selected = biomeScope == IrisDepositBiomeScope.SURFACE ? surfaceBiome : caveBiome; if (includedBiomes != null && !includedBiomes.isEmpty() && !matchesAnyBiome(selected, includedBiomes)) { @@ -491,8 +525,16 @@ public class IrisDepositGenerator { } private KSet resolveReplaceableBlocks() { + return resolveBlockKeys(replaceableBlocks); + } + + private KSet resolveSurfaceReplaceableBlocks() { + return resolveBlockKeys(surfaceReplaceableBlocks); + } + + private KSet resolveBlockKeys(KList keys) { KSet resolved = new KSet<>(); - for (String key : replaceableBlocks) { + for (String key : keys) { PlatformBlockState state = B.getStateOrNull(key, false); if (state != null) { resolved.add(IrisProceduralBlocks.materialKey(state)); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java index 3cf2cb34a..ca679bf8b 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java @@ -16,9 +16,11 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -91,12 +93,11 @@ public class WorldRuntimeControlServiceTeleportTest { } @Test - public void successfulModeTeleportTemporarilyReducesAndRestoresViewDistance() { + public void teleportNeverTouchesThePlayersViewDistance() { Player player = mock(Player.class); Location destination = mock(Location.class); CompletableFuture nativeTeleport = new CompletableFuture<>(); when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); - when(player.getViewDistance()).thenReturn(12); try (MockedStatic scheduling = immediateEntityScheduling()) { CompletableFuture result = WorldRuntimeControlService.scheduleTeleport( @@ -107,10 +108,9 @@ public class WorldRuntimeControlServiceTeleportTest { nativeTeleport.complete(true); assertTrue(result.join()); - InOrder viewDistances = inOrder(player); - viewDistances.verify(player).setViewDistance(2); - viewDistances.verify(player).setViewDistance(12); verify(player).setGameMode(GameMode.SPECTATOR); + verify(player, never()).setViewDistance(anyInt()); + verify(player, never()).getViewDistance(); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index ed9800ede..94b540160 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; @@ -661,4 +662,44 @@ public class HydrologyTileCacheTest { cache.columnAt(40, 40); assertTrue(cache.isPlanned(40, 40)); } + + @Test + public void aThreadThatMayNotWaitSamplesRiverlesslyAndAsksThePrefetchExecutorInstead() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + List queued = new ArrayList<>(); + AtomicBoolean forbidden = new AtomicBoolean(true); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add, forbidden::get); + + assertTrue(cache.columnAt(40, 40).isEmpty()); + verify(planner, org.mockito.Mockito.never()).plan(any(HydrologyTileKey.class)); + assertFalse(queued.isEmpty()); + + for (Runnable task : List.copyOf(queued)) { + task.run(); + } + + // Once the plan lands the same thread composes the real columns instead of the empty answer. + assertTrue(cache.isPlanned(40, 40)); + assertTrue(cache.columnAt(40, 40).isEmpty()); + verify(tile, org.mockito.Mockito.atLeastOnce()).columnAt(anyInt(), anyInt()); + } + + @Test + public void aThreadThatMayWaitStillPlansTheTilesItNeeds() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + AtomicBoolean forbidden = new AtomicBoolean(false); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, null, forbidden::get); + + assertTrue(cache.columnAt(40, 40).isEmpty()); + verify(planner, org.mockito.Mockito.atLeastOnce()).plan(any(HydrologyTileKey.class)); + assertTrue(cache.isPlanned(40, 40)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java index 09ced48c1..6b0bc4dba 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.modifier; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDepositGenerator; import art.arcane.iris.engine.object.IrisDepositHeightDistribution; import art.arcane.iris.engine.object.IrisDepositPlacementScope; import art.arcane.iris.spi.PlatformBlockState; @@ -75,6 +77,24 @@ public class IrisDepositModifierContainmentTest { assertFalse(IrisDepositModifier.canReplaceDepositTarget(null)); } + @Test + public void surfacePolicyReplacesBuriedHostPolicyWithoutAffectingCaves() { + IrisDepositGenerator generator = mock(IrisDepositGenerator.class); + IrisBiome biome = mock(IrisBiome.class); + PlatformBlockState sand = mock(PlatformBlockState.class); + PlatformBlockState basalt = mock(PlatformBlockState.class); + when(generator.hasSurfaceReplaceableBlocks(biome)).thenReturn(true); + when(generator.canReplaceSurface(sand, biome)).thenReturn(true); + when(generator.canReplace(sand)).thenReturn(false); + when(generator.canReplaceSurface(basalt, biome)).thenReturn(false); + when(generator.canReplace(basalt)).thenReturn(true); + + assertTrue(IrisDepositModifier.canReplaceDepositHost(generator, sand, biome, true)); + assertFalse(IrisDepositModifier.canReplaceDepositHost(generator, sand, biome, false)); + assertFalse(IrisDepositModifier.canReplaceDepositHost(generator, basalt, biome, true)); + assertTrue(IrisDepositModifier.canReplaceDepositHost(generator, basalt, biome, false)); + } + @Test public void oreFrequencyMultiplierKeepsConfiguredShareOfVeins() { assertTrue(IrisDepositModifier.passesOreFrequency(0.4D, 0.399D)); @@ -153,6 +173,33 @@ public class IrisDepositModifierContainmentTest { assertFalse(IrisDepositModifier.shouldDiscardExposed(0D, 0D, true)); } + @Test + public void terrainSurfaceIncludesExteriorAirButExcludesCaveAir() { + Hunk data = Hunk.newHunk(3, 3, 3); + PlatformBlockState solid = mock(PlatformBlockState.class); + PlatformBlockState exteriorAir = mock(PlatformBlockState.class); + PlatformBlockState caveAir = mock(PlatformBlockState.class); + when(exteriorAir.isAir()).thenReturn(true); + when(exteriorAir.key()).thenReturn("minecraft:air"); + when(caveAir.isAir()).thenReturn(true); + when(caveAir.key()).thenReturn("minecraft:cave_air"); + for (int x = 0; x < 3; x++) { + for (int y = 0; y < 3; y++) { + for (int z = 0; z < 3; z++) { + data.setRaw(x, y, z, solid); + } + } + } + + assertTrue(IrisDepositModifier.isTerrainSurface(data, 1, 1, 1, 1)); + assertFalse(IrisDepositModifier.isTerrainSurface(data, 1, 1, 1, 2)); + data.setRaw(2, 1, 1, exteriorAir); + assertTrue(IrisDepositModifier.isTerrainSurface(data, 1, 1, 1, 2)); + data.setRaw(2, 1, 1, caveAir); + assertFalse(IrisDepositModifier.isTerrainSurface(data, 1, 1, 1, 2)); + assertTrue(IrisDepositModifier.isAdjacentToAir(data, 1, 1, 1)); + } + @Test public void exposureProbeChecksOnlyInBoundsOrthogonalNeighbors() { Hunk data = Hunk.newHunk(3, 3, 3); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java index 7c48a8290..8f5292c6d 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java @@ -3,8 +3,12 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import com.google.gson.Gson; import org.junit.Test; +import org.mockito.MockedStatic; import java.lang.reflect.Field; @@ -12,8 +16,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class IrisDepositTuningTest { @@ -53,6 +59,100 @@ public class IrisDepositTuningTest { assertEquals(1D, biome.getOreDepositFrequencyMultiplier(), 0D); assertEquals(1D, biome.getOreDepositSizeMultiplier(), 0D); + assertNull(biome.getSurfaceOreReplaceableBlocks()); + } + + @Test + public void surfaceHostFilterDefaultsToUnrestricted() { + IrisDepositGenerator generator = new IrisDepositGenerator(); + + assertTrue(generator.getSurfaceReplaceableBlocks().isEmpty()); + assertTrue(generator.canReplaceSurface(mock(PlatformBlockState.class))); + } + + @Test + public void surfaceHostFilterAllowsOnlyConfiguredMaterials() { + IrisDepositGenerator generator = new IrisDepositGenerator(); + generator.setSurfaceReplaceableBlocks(new KList().qadd("minecraft:stone")); + PlatformBlockState stone = mock(PlatformBlockState.class); + PlatformBlockState dirt = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + when(dirt.key()).thenReturn("minecraft:dirt"); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(stone); + blocks.when(() -> B.getStateOrNull("minecraft:dirt", false)).thenReturn(dirt); + + assertTrue(generator.canReplaceSurface(stone)); + assertFalse(generator.canReplaceSurface(dirt)); + + generator.setSurfaceReplaceableBlocks(new KList().qadd("minecraft:dirt")); + assertFalse(generator.canReplaceSurface(stone)); + assertTrue(generator.canReplaceSurface(dirt)); + } + } + + @Test + public void biomeSurfaceHostPolicyOverridesDepositPolicy() { + IrisDepositGenerator generator = new IrisDepositGenerator(); + generator.setSurfaceReplaceableBlocks(new KList().qadd("minecraft:stone")); + IrisBiome inherited = new IrisBiome(); + IrisBiome desert = new IrisBiome(); + desert.setSurfaceOreReplaceableBlocks( + new KList().qadd("minecraft:stone").qadd("minecraft:sand")); + IrisBiome disabled = new IrisBiome(); + disabled.setSurfaceOreReplaceableBlocks(new KList<>()); + PlatformBlockState stone = mock(PlatformBlockState.class); + PlatformBlockState sand = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + when(sand.key()).thenReturn("minecraft:sand"); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(stone); + blocks.when(() -> B.getStateOrNull("minecraft:sand", false)).thenReturn(sand); + + assertTrue(generator.canReplaceSurface(stone, inherited)); + assertFalse(generator.canReplaceSurface(sand, inherited)); + assertTrue(generator.canReplaceSurface(sand, desert)); + assertTrue(generator.hasSurfaceReplaceableBlocks(disabled)); + assertFalse(generator.canReplaceSurface(stone, disabled)); + } + } + + @Test + public void biomeSurfaceHostPolicyDistinguishesOmittedAndEmptyJson() { + Gson gson = new Gson(); + IrisBiome inherited = gson.fromJson("{\"name\":\"Inherited\"}", IrisBiome.class); + IrisBiome disabled = gson.fromJson( + "{\"name\":\"Disabled\",\"surfaceOreReplaceableBlocks\":[]}", IrisBiome.class); + + assertNull(inherited.getSurfaceOreReplaceableBlocks()); + assertNotNull(disabled.getSurfaceOreReplaceableBlocks()); + assertTrue(disabled.getSurfaceOreReplaceableBlocks().isEmpty()); + assertFalse(inherited.hasSurfaceOreReplaceableBlocks()); + assertTrue(disabled.hasSurfaceOreReplaceableBlocks()); + } + + @Test + public void biomeSurfaceHostPolicySetterInvalidatesResolvedHosts() { + IrisBiome biome = new IrisBiome(); + PlatformBlockState stone = mock(PlatformBlockState.class); + PlatformBlockState sand = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + when(sand.key()).thenReturn("minecraft:sand"); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(stone); + blocks.when(() -> B.getStateOrNull("minecraft:sand", false)).thenReturn(sand); + + biome.setSurfaceOreReplaceableBlocks(new KList().qadd("minecraft:stone")); + assertTrue(biome.canReplaceSurfaceOre(stone)); + assertFalse(biome.canReplaceSurfaceOre(sand)); + + biome.setSurfaceOreReplaceableBlocks(new KList().qadd("minecraft:sand")); + assertFalse(biome.canReplaceSurfaceOre(stone)); + assertTrue(biome.canReplaceSurfaceOre(sand)); + } } @Test