mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
dwa
This commit is contained in:
+101
@@ -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.");
|
||||
}
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
|
||||
+151
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user