d
This commit is contained in:
Brian Neumann-Fopiano
2026-08-23 13:56:23 -04:00
parent dce7af1955
commit fe5651f854
216 changed files with 21919 additions and 1865 deletions
@@ -184,6 +184,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
@@ -588,6 +589,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
serverStopTeardownDeferred.set(false);
servicesDisabled.set(false);
sharedRuntimeClosed.set(false);
startupBoundaryRestart.set(false);
terminalCleanupCompleted.set(false);
deferredShutdownGenerators.clear();
MultiBurst.burst.reopen();
@@ -827,6 +829,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
BukkitGuiHost.install();
// super.onEnable() already registers this instance as a listener.
super.onEnable();
if (IrisStartupValidation.isRestartRequired()) {
String restartReason = IrisStartupValidation.denialReason()
.orElse("Iris startup validation requires a restart.");
startupBoundaryRestart.set(true);
ServerConfigurator.restartAtStartupBoundary(restartReason);
}
}
/**
@@ -863,7 +871,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onDisable() {
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
if (serverStopping) {
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
} else if (serverStopping) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
@@ -881,7 +892,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
if (!serverStopping) {
if (!serverStopping || restartingAtStartupBoundary) {
finishTerminalCleanup();
}
}
@@ -1017,6 +1028,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
private void runShutdownHook() {
if (startupBoundaryRestart.get()) {
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
return;
}
if (!awaitServerShutdownBoundary()) {
Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary.");
return;
@@ -2,6 +2,11 @@ package art.arcane.iris.api.terrain;
public enum IrisColumnField {
SURFACE_HEIGHT,
NATURAL_HEIGHT,
SURFACE_KIND,
BIOME_KEY
BIOME_KEY,
RIVER_STATE,
RIVER_DISTANCE,
RIVER_FLOW,
RIVER_WATER_SURFACE_Y
}
@@ -0,0 +1,64 @@
package art.arcane.iris.api.terrain;
import java.util.Objects;
public record IrisColumnSample(
int blockX,
int blockZ,
int surfaceHeight,
int naturalHeight,
IrisSurfaceKind surfaceKind,
String biomeKey,
IrisRiverState riverState,
double riverDistance,
int riverFlow,
int riverWaterSurfaceY
) {
public static final int UNAVAILABLE_HEIGHT = Integer.MIN_VALUE;
public static final double UNAVAILABLE_RIVER_DISTANCE = Double.NaN;
public static final int UNAVAILABLE_RIVER_FLOW = -1;
public IrisColumnSample {
Objects.requireNonNull(surfaceKind, "surfaceKind");
Objects.requireNonNull(riverState, "riverState");
biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey;
if (!Double.isNaN(riverDistance) && (!Double.isFinite(riverDistance) || riverDistance < 0D)) {
throw new IllegalArgumentException("riverDistance must be non-negative, finite, or unavailable");
}
if (riverFlow < UNAVAILABLE_RIVER_FLOW) {
throw new IllegalArgumentException("riverFlow must be non-negative or unavailable");
}
}
public boolean hasSurfaceHeight() {
return surfaceHeight != UNAVAILABLE_HEIGHT;
}
public boolean hasNaturalHeight() {
return naturalHeight != UNAVAILABLE_HEIGHT;
}
public boolean hasSurfaceKind() {
return surfaceKind != IrisSurfaceKind.UNKNOWN;
}
public boolean hasBiomeKey() {
return biomeKey != null;
}
public boolean hasRiverState() {
return riverState != IrisRiverState.NONE;
}
public boolean hasRiverDistance() {
return !Double.isNaN(riverDistance);
}
public boolean hasRiverFlow() {
return riverFlow != UNAVAILABLE_RIVER_FLOW;
}
public boolean hasRiverWaterSurfaceY() {
return riverWaterSurfaceY != UNAVAILABLE_HEIGHT;
}
}
@@ -2,5 +2,5 @@ package art.arcane.iris.api.terrain;
@FunctionalInterface
public interface IrisColumnSink {
void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey);
void accept(IrisColumnSample sample);
}
@@ -0,0 +1,7 @@
package art.arcane.iris.api.terrain;
public enum IrisRiverState {
NONE,
WET,
DRY
}
@@ -4,6 +4,9 @@ public enum IrisSurfaceKind {
UNKNOWN,
LAND,
SHORE,
RIVER,
RIVER_SHORE,
DRY_CHANNEL,
OCEAN,
VOID
}
@@ -31,22 +31,21 @@ import java.util.List;
import java.util.Objects;
import java.util.Random;
/**
* Answer to a generator-plugin discovery probe.
* <p>
* Multiverse-Core calls {@code getDefaultWorldGenerator} with an empty dimension id and the name of
* an already loaded world purely to find out whether a plugin is a generator plugin, then throws the
* returned instance away. A non-null answer keeps Iris in {@code /mv generators} and in Multiverse's
* generator tab-completion without Multiverse logging a warning block on every boot.
* <p>
* Nothing here can build terrain: every generation entry point refuses, so an instance that escapes
* the probe fails loudly instead of silently producing vanilla chunks.
*/
final class IrisProbeChunkGenerator extends ChunkGenerator {
private final String worldName;
final class IrisFailClosedChunkGenerator extends ChunkGenerator {
private final String refusalMessage;
IrisProbeChunkGenerator(String worldName) {
this.worldName = Objects.requireNonNull(worldName, "worldName");
private IrisFailClosedChunkGenerator(String refusalMessage) {
this.refusalMessage = Objects.requireNonNull(refusalMessage, "refusalMessage");
}
static IrisFailClosedChunkGenerator discoveryProbe(String worldName) {
return new IrisFailClosedChunkGenerator("Iris generator-discovery probe for '" + worldName
+ "' was asked to generate terrain. Iris worlds are created with /iris create.");
}
static IrisFailClosedChunkGenerator startupLock(String worldName, String denialReason) {
return new IrisFailClosedChunkGenerator("Iris generation for '" + worldName
+ "' remains locked: " + denialReason);
}
@Override
@@ -84,6 +83,11 @@ final class IrisProbeChunkGenerator extends ChunkGenerator {
throw refusal();
}
@Override
public boolean canSpawn(@NotNull World world, int x, int z) {
throw refusal();
}
@Override
public List<BlockPopulator> getDefaultPopulators(@NotNull World world) {
throw refusal();
@@ -94,8 +98,72 @@ final class IrisProbeChunkGenerator extends ChunkGenerator {
throw refusal();
}
@Override
public boolean shouldGenerateNoise() {
return false;
}
@Override
public boolean shouldGenerateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
@Override
public boolean shouldGenerateSurface() {
return false;
}
@Override
public boolean shouldGenerateSurface(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
@Override
public boolean shouldGenerateBedrock() {
return false;
}
@Override
public boolean shouldGenerateCaves() {
return false;
}
@Override
public boolean shouldGenerateCaves(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
@Override
public boolean shouldGenerateDecorations() {
return false;
}
@Override
public boolean shouldGenerateDecorations(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
@Override
public boolean shouldGenerateMobs() {
return false;
}
@Override
public boolean shouldGenerateMobs(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
@Override
public boolean shouldGenerateStructures() {
return false;
}
@Override
public boolean shouldGenerateStructures(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) {
return false;
}
private IllegalStateException refusal() {
return new IllegalStateException("Iris generator-discovery probe for '" + worldName
+ "' was asked to generate terrain. Iris worlds are created with /iris create.");
return new IllegalStateException(refusalMessage);
}
}
@@ -349,9 +349,14 @@ public final class IrisWorldGeneratorResolver {
}
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return new IrisProbeChunkGenerator(worldName);
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
}
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
Iris.warn("Keeping configured Iris world '" + worldName
+ "' generation-locked: " + startupDenial.get());
return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get());
}
IrisStartupValidation.requireWorldCreationReady();
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
if (stagedGenerator != null) {
Iris.debug("Using staged runtime generator for " + worldName);
@@ -250,7 +250,9 @@ public class CommandObject implements DirectorExecutor {
@Override
public int getFluidHeight() {
return 63;
return targetEngine == null
? 63
: targetEngine.getMinHeight() + targetEngine.getDimension().getFluidHeight();
}
@Override
@@ -18,7 +18,6 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
@@ -47,7 +46,6 @@ import art.arcane.iris.engine.object.IrisNoiseGenerator;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.object.NoiseStyle;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
@@ -62,7 +60,6 @@ import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.function.Function2;
import art.arcane.volmlib.util.function.NoiseProvider;
import art.arcane.iris.util.project.interpolation.InterpolationMethod;
import art.arcane.volmlib.util.io.IO;
@@ -102,7 +99,6 @@ import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.BukkitCommandMessages;
@@ -264,8 +260,8 @@ public class CommandStudio implements DirectorExecutor {
return;
}
Supplier<Function2<Double, Double, Double>> supplier = () -> (x, z) -> generator.getHeight(x, z, new RNG(seed).nextParallelRNG(3245).lmax());
NoiseExplorerGUI.launch(supplier, "Custom Generator");
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
}
@Director(description = "Show loot if a chest were right here", descriptionKey = "iris.director.commandstudio.director.show_loot_if_chest_were_right_here", origin = DirectorOrigin.PLAYER, sync = true)
@@ -648,7 +644,8 @@ public class CommandStudio implements DirectorExecutor {
@Director(description = "Teleport to the active studio world", descriptionKey = "iris.director.commandstudio.director.teleport_active_studio_world", aliases = "stp", origin = DirectorOrigin.PLAYER, sync = true)
public void tpstudio() {
if (!Iris.service(StudioSVC.class).isProjectOpen()) {
StudioSVC studioService = Iris.service(StudioSVC.class);
if (!studioService.isProjectOpen()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN));
return;
}
@@ -660,13 +657,16 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_SENDING_YOU_STUDIO_WORLD));
Player player = player();
IrisWorld studioWorld = Iris.service(StudioSVC.class)
.getActiveProject()
.getActiveProvider()
.getTarget()
.getWorld();
BukkitPlatform.teleportAsync(player, BukkitWorldBinding.spawnLocation(studioWorld))
.thenRun(() -> player.setGameMode(GameMode.CREATIVE));
studioService.teleportToActiveProject(player)
.whenComplete((teleported, failure) -> {
if (failure != null) {
Iris.reportError("Studio teleport failed for player \"" + player.getName() + "\".", failure);
return;
}
if (Boolean.TRUE.equals(teleported)) {
J.runEntity(player, () -> player.setGameMode(GameMode.CREATIVE));
}
});
}
@Director(description = "Update your dimension projects VSCode workspace", descriptionKey = "iris.director.commandstudio.director.update_your_dimension_projects_vscode_workspace")
@@ -169,7 +169,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT ->
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
@@ -2,7 +2,9 @@ package art.arcane.iris.core.service;
import art.arcane.iris.api.terrain.IrisColumnField;
import art.arcane.iris.api.terrain.IrisColumnQuery;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.api.terrain.IrisColumnSink;
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.api.terrain.IrisWorldInfo;
@@ -17,6 +19,8 @@ import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
@@ -101,13 +105,14 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
int fluid = engine.getDimension().getFluidHeight();
IrisRiverSurfaceSample riverSurface = engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ);
int fluid = (int) Math.round(riverSurface.waterSurfaceY());
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, riverSurface);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -224,26 +229,72 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
EnumSet<IrisColumnField> fields = query.fields();
boolean wantHeight = fields.contains(IrisColumnField.SURFACE_HEIGHT);
boolean wantNaturalHeight = fields.contains(IrisColumnField.NATURAL_HEIGHT);
boolean wantKind = fields.contains(IrisColumnField.SURFACE_KIND);
boolean wantBiome = fields.contains(IrisColumnField.BIOME_KEY);
boolean wantRiverState = fields.contains(IrisColumnField.RIVER_STATE);
boolean wantRiverDistance = fields.contains(IrisColumnField.RIVER_DISTANCE);
boolean wantRiverFlow = fields.contains(IrisColumnField.RIVER_FLOW);
boolean wantRiverWaterSurface = fields.contains(IrisColumnField.RIVER_WATER_SURFACE_Y);
boolean wantRiver = wantKind || wantRiverState || wantRiverDistance || wantRiverFlow
|| wantRiverWaterSurface;
try {
int minHeight = engine.getMinHeight();
int fluid = engine.getDimension().getFluidHeight();
long visited = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> {
if (engine.isClosed()) {
return false;
}
IrisRiverSurfaceSample riverSurface = wantRiver
? engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ)
: null;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = riverSurface == null
? engine.getDimension().getFluidHeight()
: (int) Math.round(riverSurface.waterSurfaceY());
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null;
IrisSurfaceKind kind = wantKind
? IrisSurfaceClassifier.classify(surface, fluid, biome == null ? null : biome.getInferredType())
? IrisSurfaceClassifier.classify(
surface,
fluid,
biome == null ? null : biome.getInferredType(),
riverSurface
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
sink.accept(blockX, blockZ, wantHeight ? surface + minHeight : -1, kind, biomeKey);
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
boolean riverPresent = riverSurface != null && riverSurface.river().present();
IrisRiverState riverState = wantRiverState && riverSurface != null
? riverState(riverSurface)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? riverSurface.river().distance()
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? riverSurface.river().flow()
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& riverPresent
&& riverSurface.river().state() == RiverRouteState.WET
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
blockX,
blockZ,
wantHeight ? surface + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT,
natural,
kind,
biomeKey,
riverState,
riverDistance,
riverFlow,
riverWaterSurfaceY
));
return true;
});
return visited == query.columnCount();
@@ -253,6 +304,17 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(IrisRiverSurfaceSample surface) {
if (!surface.river().present()) {
return IrisRiverState.NONE;
}
return switch (surface.river().state()) {
case WET -> IrisRiverState.WET;
case DRY -> IrisRiverState.DRY;
case SUPPRESSED -> IrisRiverState.NONE;
};
}
private static Optional<String> key(IrisBiome biome) {
String loadKey = biome == null ? null : biome.getLoadKey();
return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey);
@@ -2,6 +2,9 @@ package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
public final class IrisSurfaceClassifier {
private IrisSurfaceClassifier() {
@@ -22,4 +25,32 @@ public final class IrisSurfaceClassifier {
return inferredType == InferredType.SHORE ? IrisSurfaceKind.SHORE : IrisSurfaceKind.LAND;
}
public static IrisSurfaceKind classify(
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
IrisRiverSurfaceSample riverSurface
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
if (riverSurface != null && riverSurface.river().present() && !riverSurface.subterranean()) {
if (riverSurface.river().state() == RiverRouteState.DRY) {
return riverSurface.river().section() == RiverSection.DRY_CHANNEL
? IrisSurfaceKind.DRY_CHANNEL
: IrisSurfaceKind.LAND;
}
if (riverSurface.river().state() == RiverRouteState.WET) {
RiverSection section = riverSurface.river().section();
if (section == RiverSection.BANK) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (section == RiverSection.CHANNEL || section == RiverSection.MOUTH) {
return IrisSurfaceKind.RIVER;
}
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
}
}
@@ -31,12 +31,17 @@ public class IrisShutdownOrderingTest {
"public void quiesceForServerShutdown()", "public boolean isStudio()");
assertOrdered(onDisable,
"if (serverStopping)",
"startupBoundaryRestart.get()",
"teardownRuntime(\"startup-boundary-restart\", 30L)",
"else if (serverStopping)",
"quiesceRuntimeForServerShutdown(\"onDisable\")",
"startPostStopFinisher()",
"else",
"} else {",
"teardownRuntime(\"onDisable\", 30L)");
assertOrdered(shutdownHook,
"startupBoundaryRestart.get()",
"finishDeferredRuntimeTeardown(\"startup-boundary-restart-hook\", 30L)",
"return;",
"awaitServerShutdownBoundary()",
"finishDeferredRuntimeTeardown(\"shutdown-hook\", 30L)");
assertOrdered(finisher,
@@ -0,0 +1,145 @@
package art.arcane.iris.api.terrain;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class IrisColumnSampleTest {
@Test
public void unavailableFieldsHaveUnambiguousSentinels() {
IrisColumnSample sample = sample(
IrisColumnSample.UNAVAILABLE_HEIGHT,
IrisColumnSample.UNAVAILABLE_HEIGHT,
IrisSurfaceKind.UNKNOWN,
null,
IrisRiverState.NONE,
IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE,
IrisColumnSample.UNAVAILABLE_RIVER_FLOW,
IrisColumnSample.UNAVAILABLE_HEIGHT
);
assertFalse(sample.hasSurfaceHeight());
assertFalse(sample.hasNaturalHeight());
assertFalse(sample.hasSurfaceKind());
assertFalse(sample.hasBiomeKey());
assertFalse(sample.hasRiverState());
assertFalse(sample.hasRiverDistance());
assertFalse(sample.hasRiverFlow());
assertFalse(sample.hasRiverWaterSurfaceY());
}
@Test
public void negativeWorldHeightsRemainAvailableValues() {
IrisColumnSample sample = sample(
-1,
-64,
IrisSurfaceKind.DRY_CHANNEL,
"test:river",
IrisRiverState.DRY,
0D,
0,
-1
);
assertTrue(sample.hasSurfaceHeight());
assertTrue(sample.hasNaturalHeight());
assertTrue(sample.hasSurfaceKind());
assertTrue(sample.hasBiomeKey());
assertTrue(sample.hasRiverState());
assertTrue(sample.hasRiverDistance());
assertTrue(sample.hasRiverFlow());
assertTrue(sample.hasRiverWaterSurfaceY());
}
@Test
public void blankBiomeKeysNormalizeToUnavailable() {
IrisColumnSample sample = sample(
64,
65,
IrisSurfaceKind.LAND,
" ",
IrisRiverState.NONE,
IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE,
IrisColumnSample.UNAVAILABLE_RIVER_FLOW,
IrisColumnSample.UNAVAILABLE_HEIGHT
);
assertNull(sample.biomeKey());
assertFalse(sample.hasBiomeKey());
}
@Test
public void theSinkReceivesTheTypedSample() {
IrisColumnSample sample = sample(
64,
65,
IrisSurfaceKind.RIVER,
"test:river",
IrisRiverState.WET,
0.5D,
3,
67
);
AtomicReference<IrisColumnSample> received = new AtomicReference<>();
IrisColumnSink sink = received::set;
sink.accept(sample);
assertSame(sample, received.get());
}
@Test
public void invalidHydrologyValuesAreRejected() {
assertInvalid(Double.POSITIVE_INFINITY, 1);
assertInvalid(-0.1D, 1);
assertInvalid(0D, -2);
}
private static void assertInvalid(double distance, int flow) {
try {
sample(
64,
65,
IrisSurfaceKind.RIVER,
"test:river",
IrisRiverState.WET,
distance,
flow,
67
);
fail("Expected invalid hydrology values to be rejected");
} catch (IllegalArgumentException expected) {
assertTrue(expected.getMessage().startsWith("river"));
}
}
private static IrisColumnSample sample(
int surfaceHeight,
int naturalHeight,
IrisSurfaceKind surfaceKind,
String biomeKey,
IrisRiverState riverState,
double riverDistance,
int riverFlow,
int riverWaterSurfaceY
) {
return new IrisColumnSample(
12,
-7,
surfaceHeight,
naturalHeight,
surfaceKind,
biomeKey,
riverState,
riverDistance,
riverFlow,
riverWaterSurfaceY
);
}
}
@@ -29,6 +29,18 @@ public class IrisStartupOrderingTest {
"generatorResolver.validateAllPacks();");
}
@Test
public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
assertOrdered(onEnable,
"BukkitGuiHost.install();",
"super.onEnable();",
"IrisStartupValidation.isRestartRequired()",
"ServerConfigurator.restartAtStartupBoundary(restartReason);");
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
@@ -1,6 +1,7 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
@@ -22,11 +23,14 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Random;
import java.util.function.Supplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
@@ -39,8 +43,10 @@ public class IrisWorldGeneratorResolverTest {
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidationRegistry() {
public void clearValidationState() {
PackValidationRegistry.clear();
IrisStartupValidation.disable();
WorldLifecycleStaging.clearAll("world_nether");
}
@Test
@@ -234,7 +240,9 @@ public class IrisWorldGeneratorResolverTest {
int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)");
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe);
int readiness = resolver.indexOf("IrisStartupValidation.requireWorldCreationReady()");
int denial = resolver.indexOf("IrisStartupValidation.denialReason()", probe);
int failClosed = resolver.indexOf("IrisFailClosedChunkGenerator.startupLock(", denial);
int staged = resolver.indexOf("WorldLifecycleStaging.consumeGenerator(worldName)", failClosed);
int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)");
int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)");
int frozen = resolver.indexOf("return resolveFrozenWorldGenerator(", ownership);
@@ -245,8 +253,10 @@ public class IrisWorldGeneratorResolverTest {
assertTrue(plotSquaredProbe >= 0);
assertTrue(probe > plotSquaredProbe);
assertTrue(readiness > probe);
assertTrue(duplicateGuard > readiness);
assertTrue(denial > probe);
assertTrue(failClosed > denial);
assertTrue(staged > failClosed);
assertTrue(duplicateGuard > staged);
assertTrue(ownership > duplicateGuard);
assertTrue(frozen > ownership);
assertTrue(failureCapture > frozen);
@@ -302,6 +312,8 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mock(World.class));
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart boundary");
ChunkGenerator probe = new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "");
@@ -312,6 +324,95 @@ public class IrisWorldGeneratorResolverTest {
IllegalStateException.class,
() -> probe.generateNoise(mock(WorldInfo.class), new Random(), 0, 0, null));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("discovery probe"));
}
}
@Test
public void restartRequiredDefaultWorldCannotFallBackToVanilla() {
ChunkGenerator stagedGenerator = mock(ChunkGenerator.class);
ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
WorldLifecycleStaging.stageGenerator("world_nether", stagedGenerator, null);
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("updated external datapacks require restart");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
bukkit.when(() -> Bukkit.getWorld("world_nether")).thenReturn(null);
ChunkGenerator selected = craftBukkitGeneratorOrFallback(
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_nether", "underworld"),
vanillaFallback
);
assertNotSame(vanillaFallback, selected);
assertNotSame(stagedGenerator, selected);
assertSame(stagedGenerator, WorldLifecycleStaging.consumeGenerator("world_nether"));
WorldInfo worldInfo = mock(WorldInfo.class);
Random random = new Random();
assertFalse(selected.shouldGenerateNoise());
assertFalse(selected.shouldGenerateNoise(worldInfo, random, 0, 0));
assertFalse(selected.shouldGenerateSurface());
assertFalse(selected.shouldGenerateSurface(worldInfo, random, 0, 0));
assertFalse(selected.shouldGenerateBedrock());
assertFalse(selected.shouldGenerateCaves());
assertFalse(selected.shouldGenerateCaves(worldInfo, random, 0, 0));
assertFalse(selected.shouldGenerateDecorations());
assertFalse(selected.shouldGenerateDecorations(worldInfo, random, 0, 0));
assertFalse(selected.shouldGenerateMobs());
assertFalse(selected.shouldGenerateMobs(worldInfo, random, 0, 0));
assertFalse(selected.shouldGenerateStructures());
assertFalse(selected.shouldGenerateStructures(worldInfo, random, 0, 0));
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> selected.generateNoise(
worldInfo,
random,
0,
0,
mock(ChunkGenerator.ChunkData.class)
)
);
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_nether"));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("updated external datapacks require restart"));
}
}
@Test
public void everyBlockingStartupStateReturnsFailClosedGenerator() {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IrisStartupValidation.begin();
assertStartupStateFailsClosed("world_pending", "external datapacks");
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksInvalid("invalid external datapack state");
assertStartupStateFailsClosed("world_datapack_invalid", "invalid external datapack state");
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksInvalid(List.of("invalid dimension pack state"));
assertStartupStateFailsClosed("world_pack_invalid", "invalid dimension pack state");
}
}
@Test
public void readyStartupStillConsumesStagedGenerator() {
ChunkGenerator stagedGenerator = mock(ChunkGenerator.class);
WorldLifecycleStaging.stageGenerator("world_nether", stagedGenerator, null);
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
bukkit.when(() -> Bukkit.getWorld("world_nether")).thenReturn(null);
ChunkGenerator selected = new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_nether", "underworld");
assertSame(stagedGenerator, selected);
}
}
@@ -436,4 +537,38 @@ public class IrisWorldGeneratorResolverTest {
"{\"name\":\"Biome\"}",
StandardCharsets.UTF_8);
}
private static void assertStartupStateFailsClosed(String worldName, String expectedReason) {
ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
ChunkGenerator selected = craftBukkitGeneratorOrFallback(
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator(worldName, "overworld"),
vanillaFallback
);
assertNotSame(vanillaFallback, selected);
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> selected.generateNoise(
mock(WorldInfo.class),
new Random(),
0,
0,
mock(ChunkGenerator.ChunkData.class)
)
);
assertTrue(refusal.getMessage(), refusal.getMessage().contains(worldName));
assertTrue(refusal.getMessage(), refusal.getMessage().contains(expectedReason));
}
private static ChunkGenerator craftBukkitGeneratorOrFallback(
Supplier<ChunkGenerator> resolution,
ChunkGenerator fallback
) {
try {
ChunkGenerator selected = resolution.get();
return selected == null ? fallback : selected;
} catch (Throwable ignoredGeneratorFailure) {
return fallback;
}
}
}
@@ -0,0 +1,45 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IObjectPlacer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.project.stream.ProceduralStream;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7);
}
}
@@ -21,4 +21,19 @@ public class StudioPlayerModeContractTest {
assertTrue(plugin.contains("GameMode.CREATIVE"));
assertTrue(commands.contains("GameMode.CREATIVE"));
}
@Test
public void tpStudioUsesThePreparedCoordinatorEntry() throws IOException {
String commands = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n");
int methodStart = commands.indexOf("public void tpstudio()");
int methodEnd = commands.indexOf("\n @Director", methodStart);
String method = commands.substring(methodStart, methodEnd);
assertTrue(method.contains("StudioSVC studioService = Iris.service(StudioSVC.class)"));
assertTrue(method.contains("studioService.teleportToActiveProject(player)"));
assertFalse(method.contains("getActiveProject()"));
assertFalse(method.contains("BukkitPlatform.teleportAsync"));
assertFalse(method.contains("BukkitWorldBinding.spawnLocation"));
}
}
@@ -2,8 +2,15 @@ package art.arcane.iris.core.service;
import art.arcane.iris.api.terrain.IrisColumnField;
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.engine.river.RiverEdgeId;
import art.arcane.iris.engine.river.RiverNodeId;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSample;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.World;
import org.junit.Test;
@@ -82,9 +89,7 @@ public class IrisTerrainSVCTest {
IrisTerrainSVC service = new IrisTerrainSVC();
AtomicInteger sinkCalls = new AtomicInteger();
boolean answered = service.sampleColumns(null, SMALL,
(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey)
-> sinkCalls.incrementAndGet());
boolean answered = service.sampleColumns(null, SMALL, sample -> sinkCalls.incrementAndGet());
assertFalse(answered);
assertEquals(0, sinkCalls.get());
@@ -97,4 +102,38 @@ public class IrisTerrainSVCTest {
assertFalse(service.sampleColumns(null, null, null));
assertFalse(service.sampleColumns(null, SMALL, null));
}
@Test
public void riverRouteStatesMapToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(
new IrisRiverSurfaceSample(RiverSample.none(), 70D, 70D, 70D, false, false)
));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(RiverRouteState.WET)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(RiverRouteState.DRY)));
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(river(RiverRouteState.SUPPRESSED)));
}
private static IrisRiverSurfaceSample river(RiverRouteState state) {
RiverSection section = switch (state) {
case WET -> RiverSection.CHANNEL;
case DRY -> RiverSection.DRY_CHANNEL;
case SUPPRESSED -> RiverSection.NONE;
};
RiverSample sample = new RiverSample(
true,
state,
section,
0D,
0.5D,
1D,
1,
1,
8D,
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
);
return new IrisRiverSurfaceSample(sample, 70D, 60D, 63D, false, state == RiverRouteState.WET);
}
}
@@ -2,6 +2,12 @@ package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.river.RiverEdgeId;
import art.arcane.iris.engine.river.RiverNodeId;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSample;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
@@ -58,4 +64,86 @@ public class IrisSurfaceClassifierTest {
}
}
}
@Test
public void activeRiverGeometryOverridesGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
river(RiverRouteState.WET, RiverSection.CHANNEL)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
river(RiverRouteState.WET, RiverSection.MOUTH)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
river(RiverRouteState.WET, RiverSection.BANK)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_BANK)
));
}
@Test
public void voidClassificationWinsOverRiverGeometry() {
for (RiverSection section : RiverSection.values()) {
if (section == RiverSection.NONE) {
continue;
}
RiverRouteState state = section == RiverSection.DRY_CHANNEL || section == RiverSection.DRY_BANK
? RiverRouteState.DRY
: RiverRouteState.WET;
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
river(state, section)
));
}
}
@Test
public void suppressedRoutesDoNotCreatePublicRiverSurfaceKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
river(RiverRouteState.SUPPRESSED, RiverSection.CHANNEL)
));
}
private static IrisRiverSurfaceSample river(RiverRouteState state, RiverSection section) {
RiverSample sample = new RiverSample(
true,
state,
section,
0D,
0.5D,
1D,
1,
1,
8D,
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
);
double waterSurface = state == RiverRouteState.WET ? 63D : 60D;
return new IrisRiverSurfaceSample(sample, 70D, 60D, waterSurface, false, state == RiverRouteState.WET);
}
}