This commit is contained in:
Brian Neumann-Fopiano
2026-08-19 19:36:03 -04:00
parent 4adef4576f
commit 41b2b53ac8
25 changed files with 966 additions and 43 deletions
@@ -0,0 +1,101 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.generator.BiomeProvider;
import org.bukkit.generator.BlockPopulator;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo;
import org.jetbrains.annotations.NotNull;
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;
IrisProbeChunkGenerator(String worldName) {
this.worldName = Objects.requireNonNull(worldName, "worldName");
}
@Override
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
}
@Override
public void generateSurface(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
}
@Override
public void generateBedrock(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
}
@Override
public void generateCaves(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
}
@Override
public ChunkData generateChunkData(@NotNull World world, @NotNull Random random, int x, int z, @NotNull BiomeGrid biome) {
throw refusal();
}
@Override
public BiomeProvider getDefaultBiomeProvider(@NotNull WorldInfo worldInfo) {
throw refusal();
}
@Override
public int getBaseHeight(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull HeightMap heightMap) {
throw refusal();
}
@Override
public List<BlockPopulator> getDefaultPopulators(@NotNull World world) {
throw refusal();
}
@Override
public Location getFixedSpawnLocation(@NotNull World world, @NotNull Random random) {
throw refusal();
}
private IllegalStateException refusal() {
return new IllegalStateException("Iris generator-discovery probe for '" + worldName
+ "' was asked to generate terrain. Iris worlds are created with /iris create.");
}
}
@@ -33,15 +33,19 @@ import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import lombok.NonNull;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.generator.BiomeProvider;
import org.bukkit.generator.ChunkGenerator;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -56,6 +60,7 @@ import java.util.function.Supplier;
public final class IrisWorldGeneratorResolver {
private static final int VALIDATION_STABILITY_ATTEMPTS = 2;
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private static final String IRIS_DIMENSION_NAMESPACE = "iris";
private final VolmitPlugin plugin;
@@ -278,6 +283,10 @@ public final class IrisWorldGeneratorResolver {
}
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, String id) {
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return new IrisProbeChunkGenerator(worldName);
}
IrisStartupValidation.requireWorldCreationReady();
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
if (stagedGenerator != null) {
@@ -288,6 +297,11 @@ public final class IrisWorldGeneratorResolver {
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
try {
return resolveFrozenWorldGenerator(worldName, id);
} catch (RuntimeException failure) {
@@ -298,6 +312,66 @@ public final class IrisWorldGeneratorResolver {
}
}
/**
* Multiverse-Core probes every enabled plugin by asking for a generator with an empty dimension
* id and the name of a world that is already loaded. Bukkit never creates a world that is
* already loaded, so that pair only ever means discovery, never generation.
*/
private static boolean isGeneratorDiscoveryProbe(String worldName, String id) {
return id != null && id.isEmpty() && Bukkit.getWorld(worldName) != null;
}
/**
* Refuses a second generator for a world key that is already live. Multiverse imports accept a
* world name that maps onto a loaded Iris key, which would otherwise start a second engine on
* the same storage with a different seed.
*/
private static void requireWorldKeyAvailable(String worldName, NamespacedKey worldKey) {
World loaded = WorldIdentity.resolve(worldKey).orElse(null);
if (loaded == null) {
return;
}
throw new IllegalStateException("Refusing to generate '" + worldName + "': " + worldKey
+ " is already loaded as '" + loaded.getName() + "'.");
}
/**
* Refuses worlds Iris does not own before the fail-fast path can see them. A world without Iris
* storage is somebody else's create or import, and returning null there would silently hand the
* caller a vanilla world.
* <p>
* The {@code iris} dimension namespace is Iris-exclusive, so a directory in it is ownership on
* its own. Every server already has {@code dimensions/minecraft/*}, so a vanilla slot counts as
* Iris' only once it carries a frozen pack snapshot.
*/
private static void requireOwnedWorld(String worldName, File levelRoot, NamespacedKey worldKey) {
File dimensionRoot;
try {
dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
Bukkit.getWorldContainer(),
levelRoot,
worldName,
worldKey
).orElse(null);
} catch (RuntimeException unusableStorage) {
// Storage exists but cannot be resolved: an owned world, left to the fail-fast path.
return;
}
if (dimensionRoot != null
&& (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) {
return;
}
throw new IllegalStateException("'" + worldName + "' (" + worldKey
+ ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + worldName + " type=<pack>;"
+ " Iris registers them with Multiverse itself.");
}
private static boolean hasFrozenPack(File dimensionRoot) {
Path packRoot = dimensionRoot.toPath().toAbsolutePath().normalize().resolve("iris").resolve("pack");
return Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS);
}
private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) {
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
@@ -234,7 +234,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
private boolean sendHelpIfRequested(CommandSender sender, String[] args) {
Optional<DirectorHelpPage> request = DirectorMiniMenu.resolveHelp(getDirector(), Arrays.asList(args), 17);
Optional<DirectorHelpPage> request = DirectorMiniMenu.resolveHelp(getDirector(), Arrays.asList(args));
if (request.isEmpty()) {
return false;
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
@@ -7,19 +8,30 @@ import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Random;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
public class IrisWorldGeneratorResolverTest {
@Rule
@@ -130,22 +142,158 @@ public class IrisWorldGeneratorResolverTest {
}
@Test
public void configuredWorldSnapshotFailureStopsStartupAndRethrows() throws Exception {
public void ownedWorldSnapshotFailureStopsStartupAndRethrows() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java"));
int resolverStart = source.indexOf("public ChunkGenerator resolveDefaultWorldGenerator(");
int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart);
String resolver = source.substring(resolverStart, resolverEnd);
int failureCapture = resolver.indexOf("catch (RuntimeException failure)");
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)");
int readiness = resolver.indexOf("IrisStartupValidation.requireWorldCreationReady()");
int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)");
int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)");
int frozen = resolver.indexOf("return resolveFrozenWorldGenerator(", ownership);
int failureCapture = resolver.indexOf("catch (RuntimeException failure)", frozen);
int report = resolver.indexOf("Iris.reportError(", failureCapture);
int shutdown = resolver.indexOf("Bukkit.shutdown()", report);
int rethrow = resolver.indexOf("throw failure", shutdown);
assertTrue(failureCapture >= 0);
assertTrue(probe >= 0);
assertTrue(readiness > probe);
assertTrue(duplicateGuard > readiness);
assertTrue(ownership > duplicateGuard);
assertTrue(frozen > ownership);
assertTrue(failureCapture > frozen);
assertTrue(report > failureCapture);
assertTrue(shutdown > report);
assertTrue(rethrow > shutdown);
String shutdownScope = "the fail-fast shutdown must stay scoped to the frozen snapshot of an owned world";
assertEquals(shutdownScope, resolverStart + shutdown, source.indexOf("Bukkit.shutdown()"));
assertEquals(shutdownScope, resolverStart + shutdown, source.lastIndexOf("Bukkit.shutdown()"));
}
@Test
public void discoveryProbeOfLoadedWorldReturnsInertGeneratorQuietly() {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mock(World.class));
ChunkGenerator probe = new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "");
assertNotNull("Multiverse drops Iris from /mv generators when the probe returns null", probe);
bukkit.verify(Bukkit::shutdown, never());
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> probe.generateNoise(mock(WorldInfo.class), new Random(), 0, 0, null));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
}
}
@Test
public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("external-create");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("mvtest", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
File worldContainer = temporaryFolder.newFolder("owned-broken");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
assertTrue(new File(worldContainer, "world_iris_moon/dimensions/iris/moon").mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
bukkit.verify(Bukkit::shutdown);
}
}
@Test
public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception {
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
File levelRoot = new File(worldContainer, "world");
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_nether", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
File worldContainer = temporaryFolder.newFolder("duplicate-key");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
writeValidPack(new File(levelRoot, "dimensions/iris/irisworld/iris/pack").toPath());
World loaded = mock(World.class);
when(loaded.getKey()).thenReturn(new NamespacedKey("iris", "irisworld"));
when(loaded.getName()).thenReturn("world_iris_irisworld");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded));
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("irisworld", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
bukkit.verify(Bukkit::shutdown, never());
}
}
private static void writeValidPack(Path packRoot) throws Exception {