diff --git a/README.md b/README.md index 3888df2b8..1e137b1f2 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,19 @@ returns `---`. A real zero returns `0`. | `%iris_world.available%` | `true` when the reading player is in an Iris world and a reading exists | | `%iris_world.biome%` | Surface biome display name at the player, e.g. `Hot Desert Dunes` | | `%iris_world.biome-key%` | Surface biome load key, e.g. `desert/hot-dunes` | +| `%iris_world.biome-custom%` | `true` when the surface biome defines custom derivatives, otherwise `false` | +| `%iris_world.biome-custom-id%` | Authored `customDerivitives[].id` when exactly one derivative is defined, for example `golden-dunes`; `---` for none or multiple | +| `%iris_world.biome-custom-ids%` | All authored custom derivative IDs, comma-and-space separated in definition order; `---` for none | +| `%iris_world.biome-custom-key%` | Namespaced Minecraft registry key for the sole custom derivative, such as `iris:biomes/`; `---` for none, multiple, or unavailable mapping | +| `%iris_world.biome-custom-keys%` | Registry keys for all custom derivatives in definition order, comma-and-space separated; `---` for none or any unavailable mapping | +| `%iris_world.biome-custom-count%` | Number of authored custom derivatives; `0` for a vanilla-only biome | +| `%iris_world.biome-derivative%` | Configured base derivative registry key, for example `minecraft:desert` | +| `%iris_world.biome-vanilla-derivative%` | Effective vanilla derivative registry key used when a vanilla biome is required | +| `%iris_world.biome-type%` | Inferred biome category: `land`, `sea`, `shore`, or `cave`; `---` when unavailable | +| `%iris_world.min-height%` | World minimum absolute Y, inclusive | +| `%iris_world.max-height%` | World maximum absolute Y, exclusive | +| `%iris_world.height%` | World vertical span, equal to maximum minus minimum height | +| `%iris_world.fluid-height%` | Dimension fluid level in absolute world Y; this is configuration, not a measurement of water at the player | | `%iris_world.region%` | Region display name at the player | | `%iris_world.region-key%` | Region load key | | `%iris_world.dimension%` | Dimension (pack) load key of the player's world | @@ -197,7 +210,9 @@ returns `---`. A real zero returns `0`. | `%iris_pregen.chunks-per-second%` | Current rate | | `%iris_pregen.paused%` | `true` while the job is paused | -The world values are the surface reading at the player's block column. Walking refreshes them at most +Custom derivative IDs are authored metadata. A singular ID or registry key is available only when the biome defines one derivative; plural keys list all definitions. Saved columns retain their historical biome definitions. + +Biome and region values describe the surface at the player's block column. Height values describe world configuration. Walking refreshes them at most once per second per player, so a whole board of `world.*` keys costs one refresh per player per second no matter how many of them are on it, and a value may lag a sprinting player by up to a second. A jump that is not walking - joining, respawning, changing worlds, stepping through a portal, diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisBiomeInfo.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisBiomeInfo.java new file mode 100644 index 000000000..c18ff25d0 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisBiomeInfo.java @@ -0,0 +1,24 @@ +package art.arcane.iris.api.terrain; + +import java.util.List; + +public record IrisBiomeInfo( + String key, + String name, + String regionKey, + String regionName, + String derivativeKey, + String vanillaDerivativeKey, + String type, + List customDerivatives) { + public IrisBiomeInfo { + key = key == null ? "" : key; + name = name == null ? "" : name; + regionKey = regionKey == null ? "" : regionKey; + regionName = regionName == null ? "" : regionName; + derivativeKey = derivativeKey == null ? "" : derivativeKey; + vanillaDerivativeKey = vanillaDerivativeKey == null ? "" : vanillaDerivativeKey; + type = type == null ? "" : type; + customDerivatives = List.copyOf(customDerivatives); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisCustomBiomeInfo.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisCustomBiomeInfo.java new file mode 100644 index 000000000..edc85452b --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisCustomBiomeInfo.java @@ -0,0 +1,8 @@ +package art.arcane.iris.api.terrain; + +public record IrisCustomBiomeInfo(String id, String registryKey) { + public IrisCustomBiomeInfo { + id = id == null ? "" : id; + registryKey = registryKey == null ? "" : registryKey; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java index dc4dd7666..e2474fd16 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java @@ -18,6 +18,8 @@ public interface IrisTerrainService { Optional surfaceBiomeName(World world, int blockX, int blockZ); + Optional surfaceBiomeInfo(World world, int blockX, int blockZ); + Optional biomeKey(World world, int blockX, int blockY, int blockZ); Optional regionKey(World world, int blockX, int blockZ); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiExpansion.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiExpansion.java index 18ba1bf12..b71151824 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiExpansion.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiExpansion.java @@ -42,6 +42,19 @@ public final class IrisPapiExpansion extends VolmitPlaceholderExpansion { .key("world.available", state::worldAvailable) .key("world.biome", state::biome) .key("world.biome-key", state::biomeKey) + .key("world.biome-custom", state::biomeCustom) + .key("world.biome-custom-id", state::biomeCustomId) + .key("world.biome-custom-ids", state::biomeCustomIds) + .key("world.biome-custom-key", state::biomeCustomKey) + .key("world.biome-custom-keys", state::biomeCustomKeys) + .key("world.biome-custom-count", state::biomeCustomCount) + .key("world.biome-derivative", state::biomeDerivative) + .key("world.biome-vanilla-derivative", state::biomeVanillaDerivative) + .key("world.biome-type", state::biomeType) + .key("world.min-height", state::minHeight) + .key("world.max-height", state::maxHeight) + .key("world.height", state::height) + .key("world.fluid-height", state::fluidHeight) .key("world.region", state::region) .key("world.region-key", state::regionKey) .key("world.dimension", state::dimension) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiState.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiState.java index 29c4d6c4a..b8df9fad7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiState.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiState.java @@ -3,7 +3,6 @@ package art.arcane.iris.integration; import art.arcane.iris.api.pregen.IrisPregenPhase; import art.arcane.iris.api.pregen.IrisPregenProgress; import art.arcane.iris.api.terrain.IrisTerrainService; -import art.arcane.iris.api.terrain.IrisWorldInfo; import art.arcane.volmlib.util.bukkit.papi.PlaceholderSnapshot; import art.arcane.volmlib.util.bukkit.papi.PlaceholderValues; import art.arcane.volmlib.util.bukkit.papi.PlayerSnapshotStore; @@ -103,6 +102,71 @@ public final class IrisPapiState { return view == null ? PlaceholderValues.UNAVAILABLE : view.biomeKey(); } + public String biomeCustom(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().custom(); + } + + public String biomeCustomId(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().customId(); + } + + public String biomeCustomIds(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().customIds(); + } + + public String biomeCustomKey(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().customKey(); + } + + public String biomeCustomKeys(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().customKeys(); + } + + public String biomeCustomCount(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().customCount(); + } + + public String biomeDerivative(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().derivative(); + } + + public String biomeVanillaDerivative(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().vanillaDerivative(); + } + + public String biomeType(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.details().type(); + } + + public String minHeight(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.heights().minimum(); + } + + public String maxHeight(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.heights().maximum(); + } + + public String height(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.heights().height(); + } + + public String fluidHeight(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.heights().fluid(); + } + public String region(UUID playerId) { IrisPapiWorldView view = viewOf(playerId); return view == null ? PlaceholderValues.UNAVAILABLE : view.region(); @@ -195,10 +259,7 @@ public final class IrisPapiState { return IrisPapiWorldView.present( position, now, - service.surfaceBiomeName(world, blockX, blockZ), - service.surfaceBiomeKey(world, blockX, blockZ), - service.regionName(world, blockX, blockZ), - service.regionKey(world, blockX, blockZ), - service.worldInfo(world).map(IrisWorldInfo::dimensionKey)); + service.surfaceBiomeInfo(world, blockX, blockZ), + service.worldInfo(world)); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiWorldView.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiWorldView.java index cba8dc410..ffd4b1552 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiWorldView.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/integration/IrisPapiWorldView.java @@ -1,8 +1,13 @@ package art.arcane.iris.integration; +import art.arcane.iris.api.terrain.IrisBiomeInfo; +import art.arcane.iris.api.terrain.IrisCustomBiomeInfo; +import art.arcane.iris.api.terrain.IrisWorldInfo; import art.arcane.volmlib.util.bukkit.papi.PlaceholderValues; +import java.util.List; import java.util.Optional; +import java.util.StringJoiner; public record IrisPapiWorldView( IrisPapiPosition position, @@ -12,7 +17,9 @@ public record IrisPapiWorldView( String biomeKey, String region, String regionKey, - String dimension) { + String dimension, + BiomeDetails details, + Heights heights) { public static IrisPapiWorldView absent(IrisPapiPosition position, long builtAtMs) { return new IrisPapiWorldView( position, @@ -22,29 +29,82 @@ public record IrisPapiWorldView( PlaceholderValues.UNAVAILABLE, PlaceholderValues.UNAVAILABLE, PlaceholderValues.UNAVAILABLE, - PlaceholderValues.UNAVAILABLE); + PlaceholderValues.UNAVAILABLE, + BiomeDetails.absent(), + Heights.absent()); } public static IrisPapiWorldView present( IrisPapiPosition position, long builtAtMs, - Optional biome, - Optional biomeKey, - Optional region, - Optional regionKey, - Optional dimension) { + Optional biome, + Optional world) { return new IrisPapiWorldView( position, builtAtMs, PlaceholderValues.TRUE, - text(biome), - text(biomeKey), - text(region), - text(regionKey), - text(dimension)); + text(biome.map(IrisBiomeInfo::name)), + text(biome.map(IrisBiomeInfo::key)), + text(biome.map(IrisBiomeInfo::regionName)), + text(biome.map(IrisBiomeInfo::regionKey)), + text(world.map(IrisWorldInfo::dimensionKey)), + biome.map(BiomeDetails::of).orElseGet(BiomeDetails::absent), + world.map(Heights::of).orElseGet(Heights::absent)); } static String text(Optional value) { return value == null || value.isEmpty() ? PlaceholderValues.UNAVAILABLE : PlaceholderValues.text(value.get()); } + + public record BiomeDetails( + String custom, + String customId, + String customIds, + String customKey, + String customKeys, + String customCount, + String derivative, + String vanillaDerivative, + String type) { + static BiomeDetails absent() { + String unavailable = PlaceholderValues.UNAVAILABLE; + return new BiomeDetails(unavailable, unavailable, unavailable, unavailable, unavailable, + unavailable, unavailable, unavailable, unavailable); + } + + static BiomeDetails of(IrisBiomeInfo biome) { + List derivatives = biome.customDerivatives(); + StringJoiner ids = new StringJoiner(", "); + StringJoiner keys = new StringJoiner(", "); + boolean keysComplete = true; + for (IrisCustomBiomeInfo derivative : derivatives) { + ids.add(derivative.id()); + keys.add(derivative.registryKey()); + keysComplete &= !derivative.registryKey().isBlank(); + } + IrisCustomBiomeInfo single = derivatives.size() == 1 ? derivatives.getFirst() : null; + return new BiomeDetails( + PlaceholderValues.bool(!derivatives.isEmpty()), + single == null ? PlaceholderValues.UNAVAILABLE : PlaceholderValues.text(single.id()), + PlaceholderValues.text(ids.toString()), + single == null ? PlaceholderValues.UNAVAILABLE : PlaceholderValues.text(single.registryKey()), + keysComplete ? PlaceholderValues.text(keys.toString()) : PlaceholderValues.UNAVAILABLE, + Integer.toString(derivatives.size()), + PlaceholderValues.text(biome.derivativeKey()), + PlaceholderValues.text(biome.vanillaDerivativeKey()), + PlaceholderValues.text(biome.type())); + } + } + + public record Heights(String minimum, String maximum, String height, String fluid) { + static Heights absent() { + String unavailable = PlaceholderValues.UNAVAILABLE; + return new Heights(unavailable, unavailable, unavailable, unavailable); + } + + static Heights of(IrisWorldInfo world) { + return new Heights(Integer.toString(world.minHeight()), Integer.toString(world.maxHeight()), + Integer.toString(world.height()), Integer.toString(world.fluidHeight())); + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVC.java index 368d575e0..2106bb380 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVC.java @@ -1,5 +1,6 @@ package art.arcane.iris.platform.bukkit.api; +import art.arcane.iris.api.terrain.IrisBiomeInfo; import art.arcane.iris.api.terrain.IrisColumnField; import art.arcane.iris.api.terrain.IrisColumnQuery; import art.arcane.iris.api.terrain.IrisColumnSample; @@ -10,6 +11,7 @@ import art.arcane.iris.api.terrain.IrisTerrainService; import art.arcane.iris.api.terrain.IrisWorldInfo; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.platform.bukkit.terrain.IrisApiFaultGuard; +import art.arcane.iris.platform.bukkit.terrain.IrisBiomeInfoFactory; import art.arcane.iris.platform.bukkit.terrain.IrisColumnWalk; import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits; import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier; @@ -25,6 +27,7 @@ import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.platform.bukkit.plugin.IrisService; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.generator.ChunkGenerator; @@ -150,6 +153,22 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } } + @Override + public Optional surfaceBiomeInfo(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + return Optional.of(IrisBiomeInfoFactory.surface(engine, blockX, blockZ, + error -> reportQueryFault("surfaceBiomeInfo", world, error))); + } catch (Throwable error) { + reportQueryFault("surfaceBiomeInfo", world, error); + return Optional.empty(); + } + } + @Override public Optional biomeKey(World world, int blockX, int blockY, int blockZ) { Engine engine = liveEngineOf(world); @@ -371,6 +390,10 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } private void reportQueryFault(String operation, World world, Throwable error) { + if (error instanceof SavedBiomeUnavailableException unavailable + && unavailable.isLoading() && unavailable.getSuppressed().length == 0) { + return; + } if (queryFaults.record(System.currentTimeMillis())) { IrisLogging.reportError("Iris terrain API query \"" + operation + "\" failed for world \"" + (world == null ? "null" : world.getName()) + "\" (" + queryFaults.faults() @@ -379,6 +402,10 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } private void reportSinkFault(World world, Throwable error) { + if (error instanceof SavedBiomeUnavailableException unavailable + && unavailable.isLoading() && unavailable.getSuppressed().length == 0) { + return; + } if (sinkFaults.record(System.currentTimeMillis())) { IrisLogging.reportError("Iris terrain API column sample failed for world \"" + (world == null ? "null" : world.getName()) + "\" (" + sinkFaults.faults() diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactory.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactory.java new file mode 100644 index 000000000..5fe7a68bf --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactory.java @@ -0,0 +1,59 @@ +package art.arcane.iris.platform.bukkit.terrain; + +import art.arcane.iris.api.terrain.IrisBiomeInfo; +import art.arcane.iris.api.terrain.IrisCustomBiomeInfo; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.volmlib.util.collection.KList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; + +public final class IrisBiomeInfoFactory { + private IrisBiomeInfoFactory() { + } + + public static IrisBiomeInfo surface(Engine engine, int blockX, int blockZ, Consumer reportFailure) { + Objects.requireNonNull(reportFailure, "reportFailure"); + BiomeEnvironment environment = engine.getSurfaceBiomeEnvironment(blockX, blockZ); + IrisBiome biome = environment.biome(); + InferredType inferredType = biome.getInferredType(); + return new IrisBiomeInfo( + biome.getLoadKey(), + biome.getName(), + environment.region().getLoadKey(), + environment.region().getName(), + biome.getDerivativeKey(), + biome.getVanillaDerivativeKey(), + inferredType == null ? "" : inferredType.name().toLowerCase(Locale.ROOT), + customDerivatives(environment, reportFailure)); + } + + private static List customDerivatives( + BiomeEnvironment environment, + Consumer reportFailure) { + KList customBiomes = environment.biome().getCustomDerivitives(); + if (customBiomes == null || customBiomes.isEmpty()) { + return List.of(); + } + + List derivatives = new ArrayList<>(customBiomes.size()); + for (IrisBiomeCustom customBiome : customBiomes) { + String registryKey = ""; + try { + registryKey = environment.data().customBiomeResourceKey(environment.dimension(), customBiome); + } catch (RuntimeException failure) { + reportFailure.accept(new IllegalStateException( + "Unable to resolve the registry key for Iris custom biome '" + customBiome.getId() + "'.", failure)); + } + derivatives.add(new IrisCustomBiomeInfo(customBiome.getId(), registryKey)); + } + return derivatives; + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisBiomeInfoTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisBiomeInfoTest.java new file mode 100644 index 000000000..b0b3c18a9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisBiomeInfoTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.api.terrain; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class IrisBiomeInfoTest { + @Test + public void snapshotsDoNotExposeMutableDerivativeLists() { + List derivatives = new ArrayList<>(); + derivatives.add(new IrisCustomBiomeInfo("amber", "iris:biomes/amber")); + IrisBiomeInfo info = new IrisBiomeInfo( + "forests/amber", "Amber Forest", "temperate", "Temperate", + "minecraft:forest", "minecraft:forest", "land", derivatives); + + derivatives.clear(); + + assertEquals(1, info.customDerivatives().size()); + assertThrows(UnsupportedOperationException.class, () -> info.customDerivatives().clear()); + } + + @Test + public void unavailableStringsAreEmpty() { + IrisBiomeInfo info = new IrisBiomeInfo(null, null, null, null, null, null, null, List.of()); + IrisCustomBiomeInfo custom = new IrisCustomBiomeInfo(null, null); + + assertEquals("", info.key()); + assertEquals("", info.name()); + assertEquals("", info.regionKey()); + assertEquals("", info.regionName()); + assertEquals("", info.derivativeKey()); + assertEquals("", info.vanillaDerivativeKey()); + assertEquals("", info.type()); + assertEquals("", custom.id()); + assertEquals("", custom.registryKey()); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/FakeIrisTerrainService.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/FakeIrisTerrainService.java index 52eda8005..a0100896a 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/FakeIrisTerrainService.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/FakeIrisTerrainService.java @@ -1,5 +1,7 @@ package art.arcane.iris.integration; +import art.arcane.iris.api.terrain.IrisBiomeInfo; +import art.arcane.iris.api.terrain.IrisCustomBiomeInfo; import art.arcane.iris.api.terrain.IrisColumnQuery; import art.arcane.iris.api.terrain.IrisColumnSink; import art.arcane.iris.api.terrain.IrisSurfaceKind; @@ -8,6 +10,7 @@ import art.arcane.iris.api.terrain.IrisWorldInfo; import org.bukkit.World; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.Set; @@ -20,6 +23,8 @@ final class FakeIrisTerrainService implements IrisTerrainService { private String biomeKey = "desert/hot-dunes"; private String regionName = "Scorched Expanse"; private String regionKey = "scorched"; + private List derivatives = List.of(new IrisCustomBiomeInfo("golden-dunes", "iris:biomes/" + "a".repeat(64))); + private boolean biomeAvailable = true; private int builds; private int lastBlockX; private int lastBlockZ; @@ -36,6 +41,26 @@ final class FakeIrisTerrainService implements IrisTerrainService { this.regionKey = regionKey; } + void derivatives(List derivatives) { + this.derivatives = derivatives; + } + + void biomeAvailable(boolean available) { + biomeAvailable = available; + } + + @Override + public Optional surfaceBiomeInfo(World world, int blockX, int blockZ) { + if (!isIrisWorld(world) || !biomeAvailable) { + return Optional.empty(); + } + builds++; + lastBlockX = blockX; + lastBlockZ = blockZ; + return Optional.of(new IrisBiomeInfo(biomeKey, biomeName, regionKey, regionName, + "minecraft:desert", "minecraft:desert", "land", derivatives)); + } + int builds() { return builds; } @@ -70,19 +95,12 @@ final class FakeIrisTerrainService implements IrisTerrainService { @Override public Optional surfaceBiomeKey(World world, int blockX, int blockZ) { - return isIrisWorld(world) ? Optional.ofNullable(biomeKey) : Optional.empty(); + throw new AssertionError("placeholders must use a single biome metadata snapshot"); } @Override public Optional surfaceBiomeName(World world, int blockX, int blockZ) { - if (!isIrisWorld(world)) { - return Optional.empty(); - } - - builds++; - lastBlockX = blockX; - lastBlockZ = blockZ; - return Optional.ofNullable(biomeName); + throw new AssertionError("placeholders must use a single biome metadata snapshot"); } @Override @@ -92,12 +110,12 @@ final class FakeIrisTerrainService implements IrisTerrainService { @Override public Optional regionKey(World world, int blockX, int blockZ) { - return isIrisWorld(world) ? Optional.ofNullable(regionKey) : Optional.empty(); + throw new AssertionError("placeholders must use a single biome metadata snapshot"); } @Override public Optional regionName(World world, int blockX, int blockZ) { - return isIrisWorld(world) ? Optional.ofNullable(regionName) : Optional.empty(); + throw new AssertionError("placeholders must use a single biome metadata snapshot"); } @Override diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiExpansionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiExpansionTest.java index 746eaa1c8..987469afe 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiExpansionTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiExpansionTest.java @@ -36,8 +36,21 @@ public class IrisPapiExpansionTest { "pregen.world", "world.available", "world.biome", + "world.biome-custom", + "world.biome-custom-count", + "world.biome-custom-id", + "world.biome-custom-ids", + "world.biome-custom-key", + "world.biome-custom-keys", + "world.biome-derivative", "world.biome-key", + "world.biome-type", + "world.biome-vanilla-derivative", "world.dimension", + "world.fluid-height", + "world.height", + "world.max-height", + "world.min-height", "world.region", "world.region-key"); @@ -102,6 +115,19 @@ public class IrisPapiExpansionTest { assertEquals("true", expansion.onRequest(player, "world.available")); assertEquals("Hot Desert Dunes", expansion.onRequest(player, "world.biome")); assertEquals("desert/hot-dunes", expansion.onRequest(player, "world.biome-key")); + assertEquals("true", expansion.onRequest(player, "world.biome-custom")); + assertEquals("golden-dunes", expansion.onRequest(player, "world.biome-custom-id")); + assertEquals("golden-dunes", expansion.onRequest(player, "world.biome-custom-ids")); + assertEquals("iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expansion.onRequest(player, "world.biome-custom-key")); + assertEquals("iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expansion.onRequest(player, "world.biome-custom-keys")); + assertEquals("1", expansion.onRequest(player, "world.biome-custom-count")); + assertEquals("minecraft:desert", expansion.onRequest(player, "world.biome-derivative")); + assertEquals("minecraft:desert", expansion.onRequest(player, "world.biome-vanilla-derivative")); + assertEquals("land", expansion.onRequest(player, "world.biome-type")); + assertEquals("-64", expansion.onRequest(player, "world.min-height")); + assertEquals("320", expansion.onRequest(player, "world.max-height")); + assertEquals("384", expansion.onRequest(player, "world.height")); + assertEquals("63", expansion.onRequest(player, "world.fluid-height")); assertEquals("Scorched Expanse", expansion.onRequest(player, "world.region")); assertEquals("scorched", expansion.onRequest(player, "world.region-key")); assertEquals("overworld", expansion.onRequest(player, "world.dimension")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiStateTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiStateTest.java index 66051ebb6..2cd4fce85 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiStateTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiStateTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.integration; +import art.arcane.iris.api.terrain.IrisCustomBiomeInfo; import art.arcane.iris.api.pregen.IrisPregenPhase; import art.arcane.iris.api.pregen.IrisPregenProgress; import art.arcane.iris.api.terrain.IrisTerrainService; @@ -110,6 +111,19 @@ public class IrisPapiStateTest { state.region(PLAYER); state.regionKey(PLAYER); state.dimension(PLAYER); + state.biomeCustom(PLAYER); + state.biomeCustomId(PLAYER); + state.biomeCustomIds(PLAYER); + state.biomeCustomKey(PLAYER); + state.biomeCustomKeys(PLAYER); + state.biomeCustomCount(PLAYER); + state.biomeDerivative(PLAYER); + state.biomeVanillaDerivative(PLAYER); + state.biomeType(PLAYER); + state.minHeight(PLAYER); + state.maxHeight(PLAYER); + state.height(PLAYER); + state.fluidHeight(PLAYER); } assertEquals(1, terrain.builds()); @@ -391,4 +405,117 @@ public class IrisPapiStateTest { assertEquals("false", state.worldAvailable(PLAYER)); assertEquals(DASH, state.biome(PLAYER)); } + + @Test + public void multipleCustomDerivativesExposeAllDefinitionsWithoutGuessingTheSelectedOne() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.derivatives(List.of( + new IrisCustomBiomeInfo("golden-dunes", "iris:biomes/first"), + new IrisCustomBiomeInfo("red-dunes", "iris:biomes/second"))); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + state.trackPosition(PLAYER, world, 10, 10); + + assertEquals("true", state.biomeCustom(PLAYER)); + assertEquals("2", state.biomeCustomCount(PLAYER)); + assertEquals("golden-dunes, red-dunes", state.biomeCustomIds(PLAYER)); + assertEquals("iris:biomes/first, iris:biomes/second", state.biomeCustomKeys(PLAYER)); + assertEquals(DASH, state.biomeCustomId(PLAYER)); + assertEquals(DASH, state.biomeCustomKey(PLAYER)); + } + + @Test + public void vanillaBiomesExposeFalseAndARealZeroCustomCount() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.derivatives(List.of()); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + state.trackPosition(PLAYER, world, 10, 10); + + assertEquals("false", state.biomeCustom(PLAYER)); + assertEquals("0", state.biomeCustomCount(PLAYER)); + assertEquals(DASH, state.biomeCustomId(PLAYER)); + assertEquals(DASH, state.biomeCustomIds(PLAYER)); + assertEquals(DASH, state.biomeCustomKeys(PLAYER)); + assertEquals("minecraft:desert", state.biomeVanillaDerivative(PLAYER)); + } + + @Test + public void anUnavailableRegistryKeyPreservesAuthoredIdsWithoutPublishingPartialKeyLists() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.derivatives(List.of(new IrisCustomBiomeInfo("golden-dunes", ""))); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + state.trackPosition(PLAYER, world, 10, 10); + + assertEquals("golden-dunes", state.biomeCustomId(PLAYER)); + assertEquals(DASH, state.biomeCustomKey(PLAYER)); + terrain.derivatives(List.of(new IrisCustomBiomeInfo("golden-dunes", "iris:biomes/first"), + new IrisCustomBiomeInfo("red-dunes", ""))); + clock.advance(IrisPapiState.VIEW_TTL_MS); + assertEquals("golden-dunes, red-dunes", state.biomeCustomIds(PLAYER)); + assertEquals(DASH, state.biomeCustomKeys(PLAYER)); + } + + @Test + public void missingBiomeMetadataDoesNotHideKnownDimensionHeights() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.biomeAvailable(false); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + state.trackPosition(PLAYER, world, 10, 10); + + assertEquals("true", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biomeCustom(PLAYER)); + assertEquals(DASH, state.biomeCustomCount(PLAYER)); + assertEquals(DASH, state.biomeDerivative(PLAYER)); + assertEquals("overworld", state.dimension(PLAYER)); + assertEquals("-64", state.minHeight(PLAYER)); + assertEquals("320", state.maxHeight(PLAYER)); + assertEquals("384", state.height(PLAYER)); + assertEquals("63", state.fluidHeight(PLAYER)); + } + + @Test + public void pendingBiomeMetadataIsRetriedAfterTheViewExpiresWithoutPlayerMovement() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.biomeAvailable(false); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + state.trackPosition(PLAYER, world, 10, 10); + + assertEquals(DASH, state.biomeCustomId(PLAYER)); + assertEquals("true", state.worldAvailable(PLAYER)); + terrain.biomeAvailable(true); + clock.advance(IrisPapiState.VIEW_TTL_MS - 1L); + assertEquals(DASH, state.biomeCustomId(PLAYER)); + clock.advance(1L); + assertEquals("golden-dunes", state.biomeCustomId(PLAYER)); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + } + + @Test + public void customDerivativeIdsAreScrubbedAndRefreshWithTheCachedView() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("golden-dunes", state.biomeCustomId(PLAYER)); + + terrain.derivatives(List.of(new IrisCustomBiomeInfo("ยง6red%dunes", "iris:biomes/second"))); + assertEquals("golden-dunes", state.biomeCustomIds(PLAYER)); + clock.advance(IrisPapiState.VIEW_TTL_MS); + assertEquals("reddunes", state.biomeCustomId(PLAYER)); + assertEquals("reddunes", state.biomeCustomIds(PLAYER)); + assertEquals(2, terrain.builds()); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVCTest.java index 667454401..c9a654003 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVCTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/api/IrisTerrainSVCTest.java @@ -5,23 +5,43 @@ import art.arcane.iris.api.terrain.IrisColumnQuery; import art.arcane.iris.api.terrain.IrisRiverState; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.platform.generation.PlatformChunkGenerator; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyFeatureRef; import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.platform.bukkit.plugin.IrisService; import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; import org.junit.Test; +import org.mockito.MockedStatic; +import java.lang.reflect.Field; import java.util.EnumSet; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; public class IrisTerrainSVCTest { private static final IrisColumnQuery SMALL = IrisColumnQuery.rect( @@ -53,6 +73,7 @@ public class IrisTerrainSVCTest { assertEquals(IrisSurfaceKind.UNKNOWN, service.surfaceKind(null, 0, 0)); assertTrue(service.surfaceBiomeKey(null, 0, 0).isEmpty()); assertTrue(service.surfaceBiomeName(null, 0, 0).isEmpty()); + assertTrue(service.surfaceBiomeInfo(null, 0, 0).isEmpty()); assertTrue(service.biomeKey(null, 0, 64, 0).isEmpty()); assertTrue(service.regionKey(null, 0, 0).isEmpty()); assertTrue(service.regionName(null, 0, 0).isEmpty()); @@ -66,6 +87,9 @@ public class IrisTerrainSVCTest { assertEquals(Optional.class, IrisTerrainService.class.getMethod("regionName", World.class, int.class, int.class) .getReturnType()); + assertEquals(Optional.class, + IrisTerrainService.class.getMethod("surfaceBiomeInfo", World.class, int.class, int.class) + .getReturnType()); } @Test @@ -94,6 +118,124 @@ public class IrisTerrainSVCTest { assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false))); } + @Test + public void pendingBiomeMetadataReturnsAbsenceAndResolvesOnRetryWithoutLogging() throws Exception { + Engine engine = mock(Engine.class); + World world = world(engine); + IrisTerrainSVC service = enabledService(); + IrisBiome biome = mock(IrisBiome.class); + when(biome.getLoadKey()).thenReturn("desert/dunes"); + BiomeEnvironment environment = new BiomeEnvironment(1L, biome, mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + when(engine.getSurfaceBiomeEnvironment(-17, 32)) + .thenThrow(new SavedBiomeUnavailableException("Saved biome information is loading", true)) + .thenReturn(environment); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertTrue(service.surfaceBiomeInfo(world, -17, 32).isEmpty()); + assertEquals("desert/dunes", service.surfaceBiomeInfo(world, -17, 32).orElseThrow().key()); + logging.verifyNoInteractions(); + } + } + + @Test + public void pendingReadsDoNotConsumeTheFirstRealFailureReport() throws Exception { + Engine engine = mock(Engine.class); + World world = world(engine); + IrisTerrainSVC service = enabledService(); + SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException( + "Saved biome data could not be read", new IllegalStateException("Invalid saved data")); + when(engine.getSurfaceBiomeEnvironment(-17, 32)) + .thenThrow(new SavedBiomeUnavailableException("Saved biome information is loading", true)) + .thenThrow(failure); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertTrue(service.surfaceBiomeInfo(world, -17, 32).isEmpty()); + logging.verifyNoInteractions(); + assertTrue(service.surfaceBiomeInfo(world, -17, 32).isEmpty()); + logging.verify(() -> IrisLogging.reportError(contains("(1 terrain API query faults"), same(failure))); + logging.verifyNoMoreInteractions(); + } + } + + @Test + public void biomeAndRegionReadsTreatLoadingAsTemporaryAbsence() throws Exception { + Engine engine = mock(Engine.class); + World world = world(engine); + IrisTerrainSVC service = enabledService(); + SavedBiomeUnavailableException pending = new SavedBiomeUnavailableException("Loading saved biomes", true); + when(engine.getSurfaceBiome(0, 0)).thenThrow(pending); + when(engine.getBiome(0, 64, 0)).thenThrow(pending); + when(engine.getRegion(0, 0)).thenThrow(pending); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertTrue(service.surfaceBiomeKey(world, 0, 0).isEmpty()); + assertTrue(service.surfaceBiomeName(world, 0, 0).isEmpty()); + assertTrue(service.biomeKey(world, 0, 64, 0).isEmpty()); + assertTrue(service.regionKey(world, 0, 0).isEmpty()); + assertTrue(service.regionName(world, 0, 0).isEmpty()); + logging.verifyNoInteractions(); + } + } + + @Test + public void aLoadingReadWithASuppressedFailureIsStillReported() throws Exception { + Engine engine = mock(Engine.class); + World world = world(engine); + IrisTerrainSVC service = enabledService(); + SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Loading saved biomes", true); + failure.addSuppressed(new IllegalStateException("Failed to release saved definitions")); + when(engine.getSurfaceBiomeEnvironment(0, 0)).thenThrow(failure); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertTrue(service.surfaceBiomeInfo(world, 0, 0).isEmpty()); + logging.verify(() -> IrisLogging.reportError(contains("surfaceBiomeInfo"), same(failure))); + logging.verifyNoMoreInteractions(); + } + } + + @Test + public void pendingBatchReadDoesNotHideSubsequentSinkFailures() throws Exception { + Engine engine = mock(Engine.class); + World world = world(engine); + IrisTerrainSVC service = enabledService(); + IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 0, 0, 1, EnumSet.of(IrisColumnField.BIOME_KEY)); + when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); + when(engine.getSurfaceBiome(0, 0)) + .thenThrow(new SavedBiomeUnavailableException("Loading saved biomes", true)) + .thenReturn(mock(IrisBiome.class)); + AtomicInteger sinkCalls = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("Consumer failed"); + + try (MockedStatic logging = mockStatic(IrisLogging.class); + MockedStatic settings = mockStatic(IrisSettings.class)) { + settings.when(IrisSettings::get).thenReturn(new IrisSettings()); + assertFalse(service.sampleColumns(world, query, sample -> sinkCalls.incrementAndGet())); + assertEquals(0, sinkCalls.get()); + logging.verifyNoInteractions(); + assertFalse(service.sampleColumns(world, query, sample -> { throw failure; })); + logging.verify(() -> IrisLogging.reportError(contains("(1 terrain API sample faults"), same(failure))); + logging.verifyNoMoreInteractions(); + } + } + + private static World world(Engine engine) { + World world = mock(World.class); + ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class)); + when(world.getGenerator()).thenReturn(generator); + when(world.getName()).thenReturn("sandbox"); + when(((PlatformChunkGenerator) generator).getEngine()).thenReturn(engine); + return world; + } + + private static IrisTerrainSVC enabledService() throws ReflectiveOperationException { + IrisTerrainSVC service = new IrisTerrainSVC(); + Field enabled = IrisTerrainSVC.class.getDeclaredField("serviceEnabled"); + enabled.setAccessible(true); + ((AtomicBoolean) enabled.get(service)).set(true); + return service; + } + private static HydrologyColumnSample river(boolean connectedFluid) { HydrologyFeatureRef feature = new HydrologyFeatureRef( 1L, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactoryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactoryTest.java new file mode 100644 index 000000000..c2b2a2eaf --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/platform/bukkit/terrain/IrisBiomeInfoFactoryTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.platform.bukkit.terrain; + +import art.arcane.iris.api.terrain.IrisBiomeInfo; +import art.arcane.iris.api.terrain.IrisCustomBiomeInfo; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class IrisBiomeInfoFactoryTest { + @Test + public void historicalMetadataUsesTheRetainedEnvironmentInsteadOfTheActivePack() { + IrisBiomeCustom custom = new IrisBiomeCustom().setId("amber-forest"); + Fixture fixture = fixture(List.of(custom)); + IrisData activeData = mock(IrisData.class); + when(fixture.engine().getData()).thenReturn(activeData); + when(fixture.data().customBiomeResourceKey(fixture.dimension(), custom)).thenReturn("iris:biomes/retained"); + List failures = new ArrayList<>(); + + IrisBiomeInfo info = IrisBiomeInfoFactory.surface(fixture.engine(), -17, 32, failures::add); + + assertEquals("forests/amber", info.key()); + assertEquals("Amber Forest", info.name()); + assertEquals("temperate", info.regionKey()); + assertEquals("Temperate Region", info.regionName()); + assertEquals("minecraft:forest", info.derivativeKey()); + assertEquals("minecraft:birch_forest", info.vanillaDerivativeKey()); + assertEquals("land", info.type()); + assertEquals(List.of(new IrisCustomBiomeInfo("amber-forest", "iris:biomes/retained")), info.customDerivatives()); + assertTrue(failures.isEmpty()); + verify(fixture.engine()).getSurfaceBiomeEnvironment(-17, 32); + verifyNoMoreInteractions(fixture.engine()); + verify(fixture.data()).customBiomeResourceKey(fixture.dimension(), custom); + verifyNoInteractions(activeData); + } + + @Test + public void allCustomDerivativesRemainInAuthoredOrderWithoutSelectingAVariant() { + IrisBiomeCustom first = new IrisBiomeCustom().setId("amber"); + IrisBiomeCustom second = new IrisBiomeCustom().setId("copper"); + Fixture fixture = fixture(List.of(first, second)); + when(fixture.data().customBiomeResourceKey(fixture.dimension(), first)).thenReturn("iris:biomes/first"); + when(fixture.data().customBiomeResourceKey(fixture.dimension(), second)).thenReturn("iris:biomes/second"); + + IrisBiomeInfo info = IrisBiomeInfoFactory.surface(fixture.engine(), -17, 32, failure -> { + throw new AssertionError(failure); + }); + + assertEquals(List.of( + new IrisCustomBiomeInfo("amber", "iris:biomes/first"), + new IrisCustomBiomeInfo("copper", "iris:biomes/second")), info.customDerivatives()); + verify(fixture.engine()).getSurfaceBiomeEnvironment(-17, 32); + verifyNoMoreInteractions(fixture.engine()); + } + + @Test + public void aVanillaBiomeDoesNotResolveAnyCustomRegistryKeys() { + Fixture fixture = fixture(List.of()); + + IrisBiomeInfo info = IrisBiomeInfoFactory.surface(fixture.engine(), -17, 32, failure -> { + throw new AssertionError(failure); + }); + + assertEquals("Amber Forest", info.name()); + assertTrue(info.customDerivatives().isEmpty()); + verifyNoInteractions(fixture.data()); + } + + @Test + public void aMissingMappingPreservesTheAuthoredIdAndOtherMetadata() { + IrisBiomeCustom first = new IrisBiomeCustom().setId("amber"); + IrisBiomeCustom second = new IrisBiomeCustom().setId("copper"); + Fixture fixture = fixture(List.of(first, second)); + IllegalStateException missing = new IllegalStateException("Missing historical registry mapping"); + when(fixture.data().customBiomeResourceKey(fixture.dimension(), first)).thenThrow(missing); + when(fixture.data().customBiomeResourceKey(fixture.dimension(), second)).thenReturn("iris:biomes/second"); + List failures = new ArrayList<>(); + + IrisBiomeInfo info = IrisBiomeInfoFactory.surface(fixture.engine(), -17, 32, failures::add); + + assertEquals("Amber Forest", info.name()); + assertEquals(List.of( + new IrisCustomBiomeInfo("amber", ""), + new IrisCustomBiomeInfo("copper", "iris:biomes/second")), info.customDerivatives()); + assertEquals(1, failures.size()); + assertSame(missing, failures.getFirst().getCause()); + assertTrue(failures.getFirst().getMessage().contains("amber")); + } + + @Test + public void anUnclassifiedBiomeReportsAnEmptyType() { + Fixture fixture = fixture(List.of()); + when(fixture.biome().getInferredType()).thenReturn(null); + + IrisBiomeInfo info = IrisBiomeInfoFactory.surface(fixture.engine(), -17, 32, failure -> { + throw new AssertionError(failure); + }); + + assertEquals("", info.type()); + } + + private static Fixture fixture(List customBiomes) { + Engine engine = mock(Engine.class); + IrisBiome biome = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisData data = mock(IrisData.class); + KList derivatives = new KList<>(); + derivatives.addAll(customBiomes); + when(biome.getLoadKey()).thenReturn("forests/amber"); + when(biome.getName()).thenReturn("Amber Forest"); + when(biome.getDerivativeKey()).thenReturn("minecraft:forest"); + when(biome.getVanillaDerivativeKey()).thenReturn("minecraft:birch_forest"); + when(biome.getInferredType()).thenReturn(InferredType.LAND); + when(biome.getCustomDerivitives()).thenReturn(derivatives); + when(region.getLoadKey()).thenReturn("temperate"); + when(region.getName()).thenReturn("Temperate Region"); + when(engine.getSurfaceBiomeEnvironment(-17, 32)).thenReturn( + new BiomeEnvironment(4L, biome, region, dimension, data)); + return new Fixture(engine, biome, dimension, data); + } + + private record Fixture(Engine engine, IrisBiome biome, IrisDimension dimension, IrisData data) { + } +}