mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Fail-closed review fixes
- Modded logins are refused only when this save's dimension registry lists the primary world - Otherwise Iris warns once and leaves routing idle - An unreadable dimension registry counts as expecting the primary world - A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace - The refusal consequence comes from whether startup worlds exist instead of the first tick - The first WorldLoadEvent or a hotload marks startup worlds as existing - A failed enable on a hotload says the server keeps running - Reconciler resolution failures report the refusal banner - Operators get a join notice for such failures until the world loads - The fail-closed generator is built even when the cause message cannot be read - Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow - The failed-enable teardown decision lives in FailedEnableLock with a test
This commit is contained in:
@@ -26,12 +26,12 @@ final class FailedEnableLock {
|
||||
/**
|
||||
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
|
||||
*/
|
||||
static boolean engage(Throwable failure, boolean irisWorldsPresent) {
|
||||
static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
|
||||
if (!irisWorldsPresent) {
|
||||
return false;
|
||||
}
|
||||
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
|
||||
List<String> notice = notice(failure);
|
||||
List<String> notice = notice(failure, startupWorldsCreated);
|
||||
try {
|
||||
for (String line : notice) {
|
||||
Iris.error(line);
|
||||
@@ -45,14 +45,29 @@ final class FailedEnableLock {
|
||||
return true;
|
||||
}
|
||||
|
||||
static List<String> notice(Throwable failure) {
|
||||
/**
|
||||
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
|
||||
* A failed enable may have neither, so it tears down at once even while the server stops.
|
||||
*/
|
||||
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
|
||||
return !enableFailed && serverStopping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
|
||||
* already exist, so nothing stops
|
||||
*/
|
||||
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris failed to enable",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
|
||||
"Every Iris world refuses to generate: server startup stops before the first one loads,"
|
||||
+ " and no chunks are written.",
|
||||
startupWorldsCreated
|
||||
? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
|
||||
+ " and no chunks are written for them."
|
||||
: "Every Iris world refuses to generate: server startup stops before the first one"
|
||||
+ " loads, and no chunks are written.",
|
||||
"Player logins are refused until this is fixed and the server restarts."
|
||||
));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import art.arcane.iris.world.BukkitWorldReconciler;
|
||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.world.WorldRefusalReporter;
|
||||
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
@@ -754,7 +755,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
J.s(() -> {
|
||||
generatorResolver.markServerRunning();
|
||||
pendingWorldReplacements.captureVanillaLevelContext();
|
||||
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
||||
J.a(this::bstats);
|
||||
@@ -900,6 +900,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisStartupValidation.begin();
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
try {
|
||||
generatorResolver.refusals().attach(this);
|
||||
IrisPlatforms.bind(new BukkitPlatform());
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
pendingWorldReplacements.registerPlatformEntryListener();
|
||||
@@ -909,7 +910,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
enable();
|
||||
} catch (Throwable failure) {
|
||||
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
|
||||
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||
generatorResolver.refusals().startupWorldsCreated())) {
|
||||
throw failure;
|
||||
}
|
||||
enableFailed = true;
|
||||
@@ -933,7 +935,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
|
||||
? "no NMS binding is available for this server version."
|
||||
: bindFailure.getMessage()), bindFailure);
|
||||
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
|
||||
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||
generatorResolver.refusals().startupWorldsCreated())) {
|
||||
enableFailed = true;
|
||||
return;
|
||||
}
|
||||
@@ -980,13 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
IrisLanguage.shutdown();
|
||||
teardownPapi();
|
||||
// A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed
|
||||
// enable always did instead of deferring to the post-stop finisher.
|
||||
boolean serverStopping = !enableFailed && IrisToolbelt.isServerStopping();
|
||||
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
|
||||
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
||||
if (restartingAtStartupBoundary) {
|
||||
teardownRuntime("startup-boundary-restart", 30L);
|
||||
} else if (serverStopping) {
|
||||
} else if (deferToServerStop) {
|
||||
quiesceRuntimeForServerShutdown("onDisable");
|
||||
startPostStopFinisher();
|
||||
} else {
|
||||
@@ -1004,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
// super.onDisable() cancels plugin tasks and unregisters every listener.
|
||||
super.onDisable();
|
||||
if (!serverStopping || restartingAtStartupBoundary) {
|
||||
if (!deferToServerStop || restartingAtStartupBoundary) {
|
||||
finishTerminalCleanup();
|
||||
}
|
||||
}
|
||||
@@ -1429,6 +1430,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return generatorResolver.requireWorldGenerator(worldName, id);
|
||||
}
|
||||
|
||||
public WorldRefusalReporter worldRefusals() {
|
||||
return generatorResolver.refusals();
|
||||
}
|
||||
|
||||
public void splash() {
|
||||
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
||||
printPacks();
|
||||
|
||||
+18
-7
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
|
||||
return WorldIdentity.resolve(worldKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
|
||||
* then fails this load only.
|
||||
*/
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||
String configuredWorldName = worldKey.toString();
|
||||
ChunkGenerator generator;
|
||||
IrisDimension irisDimension;
|
||||
try {
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||
String configuredWorldName = configuredWorldName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
||||
ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
configuredWorldName = configuredWorldName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
|
||||
generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
|
||||
irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
if (generator == null || irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||
}
|
||||
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
|
||||
} catch (Throwable refusal) {
|
||||
plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
|
||||
return CompletableFuture.failedFuture(refusal);
|
||||
}
|
||||
try {
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
|
||||
+ " using Iris:" + dimension + "...");
|
||||
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||
.generator(generator)
|
||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||
|
||||
+11
-2
@@ -49,9 +49,18 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
|
||||
+ "' remains locked: " + denialReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
|
||||
* be summarized is named by its type instead.
|
||||
*/
|
||||
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
|
||||
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': "
|
||||
+ GenerationRefusalNotice.summary(failure));
|
||||
String reason;
|
||||
try {
|
||||
reason = GenerationRefusalNotice.summary(failure);
|
||||
} catch (Throwable unreadable) {
|
||||
reason = failure.getClass().getName();
|
||||
}
|
||||
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+19
-30
@@ -47,7 +47,6 @@ import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.world.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
|
||||
import art.arcane.iris.world.task.J;
|
||||
@@ -91,7 +90,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
||||
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
||||
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
||||
private volatile boolean serverRunning;
|
||||
private final WorldRefusalReporter refusals = new WorldRefusalReporter();
|
||||
|
||||
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -533,48 +532,30 @@ public final class IrisWorldGeneratorResolver {
|
||||
return resolveFrozenWorldGenerator(worldName, dimension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup worlds are created before the first server tick; anything asked for after it is a runtime load.
|
||||
*/
|
||||
public void markServerRunning() {
|
||||
serverRunning = true;
|
||||
public WorldRefusalReporter refusals() {
|
||||
return refusals;
|
||||
}
|
||||
|
||||
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
|
||||
ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure);
|
||||
refusals.report(worldName, refusalKey(worldName), failure);
|
||||
try {
|
||||
for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) {
|
||||
Iris.error(line);
|
||||
}
|
||||
Iris.reportError(failure);
|
||||
} catch (Throwable loggingFailure) {
|
||||
failure.addSuppressed(loggingFailure);
|
||||
failure.printStackTrace(System.err);
|
||||
System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
|
||||
+ loggingFailure.getClass().getName());
|
||||
}
|
||||
return refused;
|
||||
return IrisFailClosedChunkGenerator.refused(worldName, failure);
|
||||
}
|
||||
|
||||
private static String refusalKey(String worldName) {
|
||||
@Nullable
|
||||
private static NamespacedKey refusalKey(String worldName) {
|
||||
try {
|
||||
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString();
|
||||
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
||||
} catch (Throwable unresolved) {
|
||||
return "unresolved key";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris does not generate this world and does not let vanilla or any other generator"
|
||||
+ " write it; no chunks are written.",
|
||||
serverRunning
|
||||
? "The world does not load; the server keeps running. Fix the cause, then load it again."
|
||||
: "Server startup stops before this world loads. Fix the cause, then start the server again."
|
||||
));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ChunkGenerator startupLockedGenerator(String worldName) {
|
||||
Optional<String> startupDenial = IrisStartupValidation.denialReason();
|
||||
@@ -640,6 +621,14 @@ public final class IrisWorldGeneratorResolver {
|
||||
return;
|
||||
}
|
||||
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
|
||||
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
|
||||
// until that binding is gone.
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
|
||||
+ " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run"
|
||||
+ " /iris replace " + messageKey + " type=<pack> to replace it with Iris on the next restart.");
|
||||
}
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") has no Iris world storage, so Iris cannot generate it."
|
||||
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft 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.world;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.localization.C;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentSkipListMap;
|
||||
|
||||
/**
|
||||
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
|
||||
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
|
||||
*/
|
||||
public final class WorldRefusalReporter implements Listener {
|
||||
private static final String UNRESOLVED_KEY = "unresolved key";
|
||||
|
||||
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
|
||||
private volatile boolean startupWorldsCreated;
|
||||
|
||||
/**
|
||||
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
|
||||
*/
|
||||
public void attach(Plugin plugin) {
|
||||
if (!Bukkit.getWorlds().isEmpty()) {
|
||||
startupWorldsCreated = true;
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
|
||||
* A generator asked for after that belongs to a runtime create, which fails only its caller.
|
||||
*/
|
||||
public boolean startupWorldsCreated() {
|
||||
return startupWorldsCreated;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
startupWorldsCreated = true;
|
||||
try {
|
||||
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
|
||||
} catch (RuntimeException unkeyed) {
|
||||
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
if (operatorNotices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
if (!player.isOp() && !player.hasPermission("iris.all")) {
|
||||
return;
|
||||
}
|
||||
for (String notice : operatorNotices.values()) {
|
||||
player.sendMessage(C.RED + notice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
|
||||
* failure must not turn either into something else.
|
||||
*/
|
||||
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
|
||||
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
|
||||
try {
|
||||
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
|
||||
Iris.error(line);
|
||||
}
|
||||
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
|
||||
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
|
||||
} catch (Throwable reportingFailure) {
|
||||
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
|
||||
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Iris does not generate this world and does not let vanilla or any other generator"
|
||||
+ " write it; no chunks are written.",
|
||||
startupWorldsCreated
|
||||
? "The world does not load; the server keeps running. Fix the cause, then load it again."
|
||||
: "Server startup stops before this world loads. Fix the cause, then start the server again."
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.world.IrisStartupValidation;
|
||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.generator.WorldInfo;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -16,7 +22,9 @@ import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -24,6 +32,9 @@ 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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -40,6 +51,7 @@ public class FailedEnableLockTest {
|
||||
@After
|
||||
public void disableValidation() {
|
||||
IrisStartupValidation.disable();
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -49,7 +61,7 @@ public class FailedEnableLockTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertTrue(FailedEnableLock.engage(failure, true));
|
||||
assertTrue(FailedEnableLock.engage(failure, true, false));
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("service graph exploded"));
|
||||
@@ -74,7 +86,7 @@ public class FailedEnableLockTest {
|
||||
IrisStartupValidation.begin();
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false));
|
||||
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
|
||||
}
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
@@ -84,7 +96,7 @@ public class FailedEnableLockTest {
|
||||
@Test
|
||||
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
|
||||
String notice = String.join("\n", FailedEnableLock.notice(
|
||||
new IllegalStateException("outer", new IllegalArgumentException("inner"))));
|
||||
new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
|
||||
|
||||
assertTrue(notice, notice.contains("Iris failed to enable"));
|
||||
assertTrue(notice, notice.contains("outer"));
|
||||
@@ -94,6 +106,82 @@ public class FailedEnableLockTest {
|
||||
assertTrue(notice, notice.contains("logins"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
|
||||
*/
|
||||
@Test
|
||||
public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
|
||||
String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
|
||||
|
||||
assertFalse(notice, notice.contains("startup stops"));
|
||||
assertTrue(notice, notice.contains("keeps running"));
|
||||
}
|
||||
|
||||
/**
|
||||
* IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
|
||||
* the guarded region, and the throw must not reach Paper.
|
||||
*/
|
||||
@Test
|
||||
public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
|
||||
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||
Iris plugin = plugin();
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(true)) {
|
||||
plugin.onEnable();
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("already bound"));
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld");
|
||||
assertNotNull("a null generator is the vanilla generator", generator);
|
||||
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound"));
|
||||
fixture.bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
|
||||
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||
Iris plugin = plugin();
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(false)) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
|
||||
Iris plugin = plugin();
|
||||
set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
|
||||
|
||||
try (EnableFixture fixture = new EnableFixture(true);
|
||||
MockedStatic<INMS> nms = mockStatic(INMS.class);
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
nms.when(INMS::isBound).thenReturn(false);
|
||||
nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
|
||||
|
||||
plugin.onEnable();
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("no NMS binding for 26.9"));
|
||||
scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
|
||||
* made, so a failed enable tears down at once even while the server stops.
|
||||
*/
|
||||
@Test
|
||||
public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
|
||||
assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
|
||||
assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("level", "world");
|
||||
@@ -110,4 +198,43 @@ public class FailedEnableLockTest {
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
|
||||
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
|
||||
* set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
|
||||
*/
|
||||
private static Iris plugin() throws Exception {
|
||||
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||
set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void set(Iris plugin, String name, Object value) throws Exception {
|
||||
Field field = Iris.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
|
||||
*/
|
||||
private static final class EnableFixture implements AutoCloseable {
|
||||
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
private final MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
private final MockedStatic<FailedEnableLock> lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
|
||||
|
||||
private EnableFixture(boolean irisWorlds) {
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lock.close();
|
||||
iris.close();
|
||||
bukkit.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory;
|
||||
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.world.history.GenerationManifest;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class BukkitWorldReconcilerRuntimeTest {
|
||||
@@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs
|
||||
* the same banner and operator notice as one CraftServer asked for.
|
||||
*/
|
||||
@Test
|
||||
public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception {
|
||||
NamespacedKey key = new NamespacedKey("iris", "probe");
|
||||
IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe.");
|
||||
Iris plugin = mock(Iris.class);
|
||||
WorldRefusalReporter refusals = mock(WorldRefusalReporter.class);
|
||||
when(plugin.worldRefusals()).thenReturn(refusals);
|
||||
when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal);
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorldStorage> storage = mockStatic(IrisWorldStorage.class)) {
|
||||
storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world"));
|
||||
storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe");
|
||||
storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe");
|
||||
|
||||
CompletableFuture<World> created = backend(plugin).createWorld(key, "overworld", 1337L);
|
||||
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, created::get);
|
||||
assertSame(refusal, failure.getCause());
|
||||
verify(refusals).report("world_iris_probe", key, refusal);
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeFixture fixture() throws Exception {
|
||||
Path container = temporaryFolder.newFolder().toPath();
|
||||
Path root = container.resolve("world/dimensions/iris/probe");
|
||||
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
|
||||
when(dimension.getLoadKey()).thenReturn("overworld");
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(data.getDataFolder()).thenReturn(pack.toFile());
|
||||
return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack);
|
||||
}
|
||||
|
||||
private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception {
|
||||
Class<?> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend");
|
||||
Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class);
|
||||
constructor.setAccessible(true);
|
||||
BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class));
|
||||
return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
|
||||
return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
|
||||
}
|
||||
|
||||
private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key,
|
||||
|
||||
+21
@@ -53,4 +53,25 @@ public class IrisFailClosedChunkGeneratorTest {
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an
|
||||
* unreadable cause still has to produce a generator.
|
||||
*/
|
||||
@Test
|
||||
public void aCauseWhoseMessageCannotBeReadStillRefuses() {
|
||||
IllegalStateException unreadable = new IllegalStateException() {
|
||||
@Override
|
||||
public String getMessage() {
|
||||
throw new UnsupportedOperationException("message unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable);
|
||||
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-23
@@ -464,28 +464,10 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
|
||||
String startup = String.join("\n", IrisWorldGeneratorResolver.refusalNotice(
|
||||
"world_iris_moon", "iris:moon", failure, false));
|
||||
String runtime = String.join("\n", IrisWorldGeneratorResolver.refusalNotice(
|
||||
"world_iris_moon", "iris:moon", failure, true));
|
||||
|
||||
for (String notice : List.of(startup, runtime)) {
|
||||
assertTrue(notice, notice.contains("world_iris_moon"));
|
||||
assertTrue(notice, notice.contains("iris:moon"));
|
||||
assertTrue(notice, notice.contains("Iris generation history is unusable at /srv/moon."));
|
||||
assertTrue(notice, notice.contains("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
assertTrue(notice, notice.contains("no chunks"));
|
||||
}
|
||||
assertTrue(startup, startup.contains("startup stops"));
|
||||
assertTrue(runtime, runtime.contains("does not load"));
|
||||
assertFalse(runtime, runtime.contains("startup stops"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
|
||||
* binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
|
||||
@@ -500,7 +482,10 @@ public class IrisWorldGeneratorResolverTest {
|
||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("minecraft:the_nether"));
|
||||
assertTrue(refusal, refusal.contains("/iris create"));
|
||||
assertTrue(refusal, refusal.contains("worlds.world_nether.generator"));
|
||||
assertTrue(refusal, refusal.contains("bukkit.yml"));
|
||||
assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=<pack>"));
|
||||
assertFalse(refusal, refusal.contains("/iris create"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the
|
||||
* server, a runtime create only fails its caller. The banner must not claim the wrong one.
|
||||
*/
|
||||
public class WorldRefusalReporterTest {
|
||||
private static final NamespacedKey MOON = new NamespacedKey("iris", "moon");
|
||||
|
||||
@Test
|
||||
public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() {
|
||||
String banner = reportAndCapture(new WorldRefusalReporter());
|
||||
|
||||
assertTrue(banner, banner.contains("'moon'"));
|
||||
assertTrue(banner, banner.contains("iris:moon"));
|
||||
assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon."));
|
||||
assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
assertTrue(banner, banner.contains("no chunks"));
|
||||
assertTrue(banner, banner.contains("startup stops"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
|
||||
* A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps
|
||||
* running when that create fails.
|
||||
*/
|
||||
@Test
|
||||
public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld"))));
|
||||
|
||||
String banner = reportAndCapture(reporter);
|
||||
|
||||
assertTrue(banner, banner.contains("keeps running"));
|
||||
assertFalse(banner, banner.contains("startup stops"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
World overworld = world(NamespacedKey.minecraft("overworld"));
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld));
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||
|
||||
reporter.attach(plugin);
|
||||
}
|
||||
|
||||
assertTrue(reporter.startupWorldsCreated());
|
||||
verify(plugins).registerEvents(reporter, plugin);
|
||||
String banner = reportAndCapture(reporter);
|
||||
assertTrue(banner, banner.contains("keeps running"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() {
|
||||
WorldRefusalReporter reporter = new WorldRefusalReporter();
|
||||
reportAndCapture(reporter);
|
||||
Player operator = player(true);
|
||||
Player member = player(false);
|
||||
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty()));
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty()));
|
||||
|
||||
verify(operator).sendMessage(contains("iris:moon"));
|
||||
verify(member, never()).sendMessage(anyString());
|
||||
|
||||
reporter.onWorldLoad(new WorldLoadEvent(world(MOON)));
|
||||
Player later = player(true);
|
||||
reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty()));
|
||||
verify(later, never()).sendMessage(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFailureWhileReportingNeverEscapes() {
|
||||
Throwable unreadable = new IllegalStateException() {
|
||||
@Override
|
||||
public String getMessage() {
|
||||
throw new UnsupportedOperationException("message unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
new WorldRefusalReporter().report("moon", MOON, unreadable);
|
||||
}
|
||||
}
|
||||
|
||||
private static String reportAndCapture(WorldRefusalReporter reporter) {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
List<String> lines = new ArrayList<>();
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> {
|
||||
lines.add(invocation.getArgument(0, String.class));
|
||||
return null;
|
||||
});
|
||||
reporter.report("moon", MOON, failure);
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
private static World world(NamespacedKey key) {
|
||||
World world = mock(World.class);
|
||||
when(world.getKey()).thenReturn(key);
|
||||
when(world.getName()).thenReturn(key.getKey());
|
||||
return world;
|
||||
}
|
||||
|
||||
private static Player player(boolean operator) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.isOp()).thenReturn(operator);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
+33
-3
@@ -40,6 +40,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
|
||||
private static int tickCounter = 0;
|
||||
private static volatile String loginRefusal;
|
||||
private static volatile String absentPrimary;
|
||||
|
||||
private ModdedPrimaryWorldRouter() {
|
||||
}
|
||||
@@ -48,6 +49,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
routed.clear();
|
||||
inFlight.clear();
|
||||
loginRefusal = null;
|
||||
absentPrimary = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +101,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
NativeWorld target = ModdedDimensionManager.level(server, primary);
|
||||
if (target == null) {
|
||||
if (ModdedStartup.dimensionsRestored()) {
|
||||
refuseLogins(server, primary);
|
||||
judgeMissingPrimary(server, primary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -141,9 +143,37 @@ public final class ModdedPrimaryWorldRouter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists
|
||||
* it expected it here, so only that save has a primary world that failed to load.
|
||||
*/
|
||||
private static void judgeMissingPrimary(NativeModdedServer server, String primary) {
|
||||
if (loginRefusal == null && primary.equals(absentPrimary)) {
|
||||
return;
|
||||
}
|
||||
if (loginRefusal == null && !expectedInThisSave(server, primary)) {
|
||||
absentPrimary = primary;
|
||||
ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not"
|
||||
+ " exist in this save, so players are not routed. Create it here with /iris world replace-overworld"
|
||||
+ " <pack> or clear primaryWorld.");
|
||||
return;
|
||||
}
|
||||
refuseLogins(server, primary);
|
||||
}
|
||||
|
||||
private static boolean expectedInThisSave(NativeModdedServer server, String primary) {
|
||||
try {
|
||||
return ModdedDimensionRegistryStore.get(server, primary) != null;
|
||||
} catch (RuntimeException unreadable) {
|
||||
ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '"
|
||||
+ primary + "'", unreadable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a
|
||||
* primary world that did not load refuses every player instead.
|
||||
* primary world this save expected and did not load refuses every player instead.
|
||||
*/
|
||||
private static void refuseLogins(NativeModdedServer server, String primary) {
|
||||
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
|
||||
@@ -152,7 +182,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
for (String line : GenerationRefusalNotice.compose(
|
||||
"Iris refused player logins: primary world '" + primary + "' is not loaded",
|
||||
List.of("routePlayersToPrimaryWorld sends players to '" + primary
|
||||
+ "', which did not load at startup."),
|
||||
+ "', which this save's dimension registry expects but did not load at startup."),
|
||||
List.of(
|
||||
"Players are disconnected instead of playing in the overworld, so no chunks are"
|
||||
+ " written there.",
|
||||
|
||||
+68
-1
@@ -37,7 +37,7 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() {
|
||||
public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
@@ -45,10 +45,12 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
@@ -63,6 +65,37 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another
|
||||
* save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused.
|
||||
*/
|
||||
@Test
|
||||
public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null);
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
evaluate(server);
|
||||
|
||||
verify(online, never()).disconnect(anyString());
|
||||
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1));
|
||||
log.verify(() -> ModdedIrisLog.error(anyString()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
|
||||
* missing then is still loading, not broken.
|
||||
@@ -100,8 +133,10 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
evaluate(server);
|
||||
@@ -119,6 +154,38 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry that cannot be read cannot prove the primary world was never here, so it counts as expected.
|
||||
*/
|
||||
@Test
|
||||
public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||
onlinePlayers(server, online);
|
||||
ModdedModConfig routed = config(true, PRIMARY);
|
||||
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
|
||||
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY))
|
||||
.thenThrow(new IllegalStateException("registry could not be read"));
|
||||
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||
|
||||
evaluate(server);
|
||||
|
||||
verify(online).disconnect(contains(PRIMARY));
|
||||
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||
}
|
||||
}
|
||||
|
||||
private static ModdedDimensionRegistryStore.PersistentDimension persisted() {
|
||||
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
|
||||
}
|
||||
|
||||
private static void evaluate(NativeModdedServer server) {
|
||||
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
|
||||
ModdedPrimaryWorldRouter.tick(server);
|
||||
|
||||
Reference in New Issue
Block a user