mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
MV
This commit is contained in:
@@ -31,12 +31,15 @@ import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.core.BukkitWorldReconciler;
|
||||
import art.arcane.iris.core.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.PendingWorldDeleteQueue;
|
||||
import art.arcane.iris.core.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.core.SettingsHotloadWatch;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
|
||||
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
|
||||
@@ -65,6 +68,7 @@ import art.arcane.iris.core.service.IrisProtocolService;
|
||||
import art.arcane.iris.core.service.IrisTerrainSVC;
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.core.service.LogFilterSVC;
|
||||
import art.arcane.iris.core.service.MultiverseSVC;
|
||||
import art.arcane.iris.core.service.ObjectSVC;
|
||||
import art.arcane.iris.core.service.ObjectStudioSaveService;
|
||||
import art.arcane.iris.core.service.PreservationSVC;
|
||||
@@ -100,6 +104,7 @@ import art.arcane.volmlib.util.io.InstanceState;
|
||||
import art.arcane.volmlib.util.math.M;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.iris.util.common.misc.Bindings;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.iris.util.common.misc.SlimJar;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import art.arcane.iris.util.common.plugin.IrisService;
|
||||
@@ -142,6 +147,7 @@ import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -261,12 +267,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning raised here is the same kind of thing as one raised in core, so it takes the same route: the
|
||||
* plugin logger at WARNING, where a log scan of logs/latest.log finds it.
|
||||
*/
|
||||
public static void warn(String format, Object... objs) {
|
||||
msg(C.YELLOW + safeFormat(format, objs));
|
||||
diagnostic(Level.WARNING, safeFormat(format, objs));
|
||||
}
|
||||
|
||||
public static void error(String format, Object... objs) {
|
||||
msg(C.RED + safeFormat(format, objs));
|
||||
diagnostic(Level.SEVERE, safeFormat(format, objs));
|
||||
}
|
||||
|
||||
public static void debug(String string) {
|
||||
@@ -512,12 +522,52 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
private static void bridgeLog(LogLevel level, String message) {
|
||||
LogLevel target = level == null ? LogLevel.INFO : level;
|
||||
switch (target) {
|
||||
case DEBUG -> Iris.debug(message);
|
||||
case INFO -> Iris.info(message);
|
||||
case WARN -> Iris.warn(message);
|
||||
case ERROR -> Iris.error(message);
|
||||
Level diagnostic = diagnosticLevel(target);
|
||||
if (diagnostic != null) {
|
||||
diagnostic(diagnostic, message);
|
||||
return;
|
||||
}
|
||||
if (target == LogLevel.DEBUG) {
|
||||
Iris.debug(message);
|
||||
return;
|
||||
}
|
||||
Iris.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The java.util.logging level a message keeps, or null when it belongs on the console sender path.
|
||||
* <p>
|
||||
* Core states a severity and every other adapter honours it; on Bukkit a coloured line through the
|
||||
* console sender reaches the terminal but not the instance's logs/latest.log, so a WARN-level scan of
|
||||
* that file never saw a single core warning. Diagnostics go to the plugin logger at their own level
|
||||
* instead, and NOTICE carries the handful of lifecycle lines that have to land there too. Player-facing
|
||||
* text still goes through {@code IrisLogging.msg}.
|
||||
*/
|
||||
static Level diagnosticLevel(LogLevel level) {
|
||||
return switch (level) {
|
||||
case NOTICE -> Level.INFO;
|
||||
case WARN -> Level.WARNING;
|
||||
case ERROR -> Level.SEVERE;
|
||||
case DEBUG, INFO -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static void diagnostic(Level level, String message) {
|
||||
String line = IrisLogging.clean(message);
|
||||
Iris plugin = instance;
|
||||
if (plugin != null) {
|
||||
try {
|
||||
plugin.getLogger().log(level, line);
|
||||
return;
|
||||
} catch (Throwable unavailable) {
|
||||
// Paper runs the bootstrap before a plugin logger exists; the streams below are all there is.
|
||||
}
|
||||
}
|
||||
if (level.intValue() >= Level.WARNING.intValue()) {
|
||||
System.err.println("[Iris] " + line);
|
||||
return;
|
||||
}
|
||||
System.out.println("[Iris] " + line);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,6 +623,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
new WandSVC(),
|
||||
new BoardSVC(),
|
||||
new IrisIntegrationService(),
|
||||
new MultiverseSVC(),
|
||||
new IrisProtocolService(),
|
||||
new IrisApiEventSVC(),
|
||||
new CommandSVC()
|
||||
@@ -597,6 +648,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
J.attempt(this::splash);
|
||||
IrisSafeguard.printReports();
|
||||
IrisSafeguard.printFooter();
|
||||
// Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there
|
||||
// never reach logs/latest.log. Replay them once now that the platform log is up.
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
tickets = new ChunkTickets();
|
||||
linkMultiverseCore = new MultiverseCoreLink();
|
||||
IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore);
|
||||
@@ -606,6 +660,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisServices.register(EngineWorldManagerProvider.class,
|
||||
(EngineWorldManagerProvider) IrisWorldManager::new);
|
||||
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
|
||||
IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld);
|
||||
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
|
||||
settingsHotloadWatch = watch;
|
||||
configHotloadEngine = new ConfigHotloadEngine(
|
||||
@@ -710,6 +765,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return worldReconciler;
|
||||
}
|
||||
|
||||
/**
|
||||
* The load every integration goes through. {@code /iris load} and the Multiverse guard both land here,
|
||||
* so a world loaded from outside Iris still gets the keyed creator, the pack's environment and the
|
||||
* bukkit.yml reconciliation that make it an Iris world rather than a vanilla one under the same name.
|
||||
*/
|
||||
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
|
||||
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
|
||||
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
|
||||
}
|
||||
|
||||
public PendingWorldReplacementManager pendingWorldReplacements() {
|
||||
return pendingWorldReplacements;
|
||||
}
|
||||
@@ -757,8 +822,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
pendingWorldReplacements.registerPlatformEntryListener();
|
||||
boolean enabled = enable();
|
||||
boolean enabled;
|
||||
try {
|
||||
enabled = enable();
|
||||
} catch (Throwable failure) {
|
||||
refuseVanillaFallback(failure);
|
||||
throw failure;
|
||||
}
|
||||
if (!enabled) {
|
||||
refuseVanillaFallback(null);
|
||||
return;
|
||||
}
|
||||
BukkitGuiHost.install();
|
||||
@@ -766,6 +838,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
super.onEnable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
|
||||
* <p>
|
||||
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
|
||||
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
|
||||
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
|
||||
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
|
||||
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
|
||||
* lives in IrisBootstrap, which refuses startup before any level is created.
|
||||
*/
|
||||
private static void refuseVanillaFallback(Throwable failure) {
|
||||
File levelRoot;
|
||||
try {
|
||||
levelRoot = IrisWorldStorage.levelRoot();
|
||||
} catch (Throwable unavailable) {
|
||||
return;
|
||||
}
|
||||
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
|
||||
return;
|
||||
}
|
||||
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
|
||||
if (failure != null) {
|
||||
Iris.reportError("Iris enable failed", failure);
|
||||
}
|
||||
try {
|
||||
Bukkit.shutdown();
|
||||
} catch (Throwable unavailable) {
|
||||
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
teardownPapi();
|
||||
boolean serverStopping = IrisToolbelt.isServerStopping();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisWorldStorageEntry;
|
||||
import art.arcane.iris.core.lifecycle.HuskWorldQuarantine;
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
@@ -13,6 +17,8 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public final class IrisBootstrap implements PluginBootstrap {
|
||||
@@ -22,6 +28,8 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
try {
|
||||
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
|
||||
reconcilePendingWorldReplacements(context, startupPaths);
|
||||
quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message));
|
||||
requireUsableWorldStorage(startupPaths);
|
||||
ProvisionResult provisioned = provision(context, startupPaths);
|
||||
Path datapackRoot = provisioned.datapackRoot();
|
||||
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
|
||||
@@ -58,6 +66,76 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a hot-deleted world's husk out of {@code <levelRoot>/dimensions/iris} before anything enumerates
|
||||
* it.
|
||||
* <p>
|
||||
* Deleting a loaded world's folder and letting the server save the level back leaves a directory holding
|
||||
* only the server's own {@code data/} skeleton. Left there it stops the boot whichever way it is
|
||||
* classified: as unusable storage it trips the guard below, and as an empty folder it is excluded from
|
||||
* the dimension registry, which trips Paper's interactive world-migration gate - a prompt that consumes
|
||||
* no console input and hangs startup forever. The folder is moved rather than deleted, so it stays
|
||||
* recoverable; {@link HuskWorldQuarantine} refuses anything that is not provably worthless.
|
||||
*/
|
||||
static void quarantineWorthlessHusks(BukkitStartupPaths startupPaths, Consumer<String> warn) {
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(startupPaths.levelRoot(), warn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
|
||||
* <p>
|
||||
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
|
||||
* whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
|
||||
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
|
||||
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
|
||||
* to be found here, before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
|
||||
* <p>
|
||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
||||
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
|
||||
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
|
||||
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
|
||||
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
|
||||
*/
|
||||
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
|
||||
Path levelRoot = startupPaths.levelRoot();
|
||||
Path levelName = levelRoot.getFileName();
|
||||
if (levelName == null || levelName.toString().isBlank()) {
|
||||
throw new IOException("Configured level root has no startup level id: " + levelRoot);
|
||||
}
|
||||
List<IrisWorldStorageEntry> entries = BukkitWorldConfiguration.auditIrisWorldStorage(
|
||||
startupPaths.bukkitConfiguration().toFile(),
|
||||
levelName.toString(),
|
||||
levelRoot
|
||||
);
|
||||
StringBuilder unusable = new StringBuilder();
|
||||
for (IrisWorldStorageEntry entry : entries) {
|
||||
switch (entry.state()) {
|
||||
case PRESENT -> {
|
||||
}
|
||||
case MISSING -> MissingWorldStorageLog.warnOnce(
|
||||
entry.configuredWorldName(),
|
||||
entry.storagePath());
|
||||
case EMPTY -> MissingWorldStorageLog.warnEmptyOnce(
|
||||
entry.configuredWorldName(),
|
||||
entry.storagePath());
|
||||
case UNUSABLE -> unusable.append(unusable.isEmpty() ? "" : "; ")
|
||||
.append(entry.configuredWorldName())
|
||||
.append(" (")
|
||||
.append(entry.storagePath())
|
||||
.append(": ")
|
||||
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
||||
.append(')');
|
||||
}
|
||||
}
|
||||
if (unusable.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
|
||||
+ " terrain over it: " + unusable
|
||||
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
|
||||
+ " reported and skipped, and the server starts.");
|
||||
}
|
||||
|
||||
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||
armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY);
|
||||
}
|
||||
|
||||
+67
-8
@@ -28,6 +28,7 @@ import art.arcane.iris.core.pack.PackValidationCache;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
@@ -237,7 +238,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
@Nullable
|
||||
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||
String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName());
|
||||
File pack = IrisWorldStorage.frozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
@@ -263,8 +264,65 @@ public final class IrisWorldGeneratorResolver {
|
||||
return dimension;
|
||||
}
|
||||
|
||||
static NamespacedKey configuredWorldKey(String worldName, String levelName) {
|
||||
return IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
|
||||
/**
|
||||
* Resolves the Iris key behind a Bukkit world name, accepting the runtime keyed name alongside the
|
||||
* startup name.
|
||||
* <p>
|
||||
* Paper names a world built from {@code WorldCreator.ofKey} {@code <namespace>_<key>}, and Multiverse
|
||||
* loads Iris worlds that way because its own keyed creator refuses a non-{@code minecraft} namespace.
|
||||
* That is not the startup name, so the configured-name parser reads {@code iris_moon} as
|
||||
* {@code iris:iris_moon} and loses the world.
|
||||
* <p>
|
||||
* Only a name whose decoded key already has Iris storage is remapped, and only when the literal
|
||||
* reading has none, so a world genuinely created as {@code /iris create "iris moon"} keeps its own
|
||||
* identity and a server without Multiverse resolves exactly the names it resolved before.
|
||||
*/
|
||||
static NamespacedKey configuredWorldKey(String worldName, String levelName, File levelRoot) {
|
||||
NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
|
||||
String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
|
||||
if (!worldName.startsWith(runtimePrefix)
|
||||
|| worldName.startsWith(levelName + "_" + runtimePrefix)
|
||||
|| IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, literal)) {
|
||||
return literal;
|
||||
}
|
||||
NamespacedKey runtime;
|
||||
try {
|
||||
runtime = IrisWorldStorage.managedKeyFromName(
|
||||
IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
|
||||
levelName);
|
||||
} catch (RuntimeException notAManagedKey) {
|
||||
return literal;
|
||||
}
|
||||
if (!IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, runtime)) {
|
||||
return literal;
|
||||
}
|
||||
IrisLogging.debug("Resolved runtime keyed world name " + worldName + " as " + runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a refusal message should name for a world name, without the storage-existence guard.
|
||||
* <p>
|
||||
* {@link #configuredWorldKey} only remaps the runtime keyed name when the decoded key has storage, so an
|
||||
* orphan keeps the literal reading and a message built from it names {@code iris:iris_moon} and tells
|
||||
* the admin to create a world that never existed under that name. Messaging does not have to be safe
|
||||
* against mis-mapping a live world, only correct about what the admin typed, so it drops that guard;
|
||||
* generation keeps it.
|
||||
*/
|
||||
static NamespacedKey messageWorldKey(String worldName, String levelName) {
|
||||
NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
|
||||
String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
|
||||
if (!worldName.startsWith(runtimePrefix)
|
||||
|| worldName.startsWith(levelName + "_" + runtimePrefix)) {
|
||||
return literal;
|
||||
}
|
||||
try {
|
||||
return IrisWorldStorage.managedKeyFromName(
|
||||
IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
|
||||
levelName);
|
||||
} catch (RuntimeException notAManagedKey) {
|
||||
return literal;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,7 +356,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
||||
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||
requireWorldKeyAvailable(worldName, worldKey);
|
||||
requireOwnedWorld(worldName, levelRoot, worldKey);
|
||||
|
||||
@@ -361,10 +419,11 @@ public final class IrisWorldGeneratorResolver {
|
||||
&& (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("'" + worldName + "' (" + worldKey
|
||||
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") 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.");
|
||||
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
|
||||
+ " type=<pack>; Iris registers them with Multiverse itself.");
|
||||
}
|
||||
|
||||
private static boolean hasFrozenPack(File dimensionRoot) {
|
||||
@@ -374,7 +433,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
|
||||
private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) {
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||
File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
levelRoot,
|
||||
|
||||
+41
-6
@@ -162,7 +162,37 @@ public final class IrisEngineSVC implements IrisService {
|
||||
World world = event.getWorld();
|
||||
CompletionStage<Boolean> unloadBoundary = WorldUnloadBoundaryRegistry.claim(
|
||||
WorldIdentity.serialize(world));
|
||||
remove(world, unloadBoundary);
|
||||
if (remove(world, unloadBoundary) || unloadBoundary != null) {
|
||||
return;
|
||||
}
|
||||
closeUntrackedGenerator(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the engine of a world this service never registered.
|
||||
* <p>
|
||||
* Registration is skipped while a previous generator for the same identity is still closing and while
|
||||
* the world is not yet the server's world for its UID, and an unload that lands in that window would
|
||||
* otherwise leave a live engine bound to a dead world. Only an unload Iris did not drive is covered:
|
||||
* an Iris-driven unload carries a boundary and closes the generator in its own phase. The close is
|
||||
* CAS-idempotent, so a redundant call returns the in-flight completion.
|
||||
*/
|
||||
private void closeUntrackedGenerator(World world) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator == null || generator.isClosing()) {
|
||||
return;
|
||||
}
|
||||
IrisLogging.debug("EngineSVC: closing untracked generator for unloaded world " + world.getName());
|
||||
CompletableFuture<Void> close = generator.closeAsync();
|
||||
if (close == null) {
|
||||
return;
|
||||
}
|
||||
close.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
|
||||
reportFailure("Failed to close untracked generator for " + world.getName(), cause);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
@@ -235,9 +265,12 @@ public final class IrisEngineSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
private void remove(World world, CompletionStage<Boolean> unloadBoundary) {
|
||||
/**
|
||||
* @return true when a registration was found and its close was reserved
|
||||
*/
|
||||
private boolean remove(World world, CompletionStage<Boolean> unloadBoundary) {
|
||||
if (world == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
Registered registered;
|
||||
@@ -250,10 +283,12 @@ public final class IrisEngineSVC implements IrisService {
|
||||
closing = reserveClose(registered);
|
||||
}
|
||||
}
|
||||
if (closing != null) {
|
||||
phases.closing(world);
|
||||
deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
|
||||
if (closing == null) {
|
||||
return false;
|
||||
}
|
||||
phases.closing(world);
|
||||
deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void deferCloseUntilWorldUnload(
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* The server enumerates levels from disk and CraftBukkit falls back to the vanilla generator whenever the
|
||||
* plugin cannot supply one, so a world with storage Iris cannot use has to stop startup before any level is
|
||||
* created. A world with no storage at all is only reported.
|
||||
*/
|
||||
public class IrisBootstrapWorldStorageContractTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Before
|
||||
public void clearOrphanReports() {
|
||||
MissingWorldStorageLog.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupIsRefusedWhenAConfiguredWorldLostItsPackSnapshot() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("broken-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/orphan2/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/iris/orphan2/region/r.0.0.mca"), "terrain");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_orphan2");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_orphan2"));
|
||||
assertTrue(failure.getMessage(),
|
||||
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/iris/orphan2")
|
||||
.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a live world's folder and letting the server save it back leaves a data/ skeleton with no
|
||||
* regions and no iris/ directory. It owns nothing, so refusing to boot over it only strands the server.
|
||||
*/
|
||||
@Test
|
||||
public void startupContinuesForAServerRewrittenHuskAndReportsItOnce() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("husk-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_husk");
|
||||
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Both classifications of a hot-deleted world's husk stop the boot: UNUSABLE trips Iris' fail-closed
|
||||
* guard, and EMPTY excludes it from the dimension registry, which trips Paper's interactive world
|
||||
* migration gate. The husk has to leave the dimensions tree before either can happen.
|
||||
*/
|
||||
@Test
|
||||
public void aHotDeletedWorldsHuskIsMovedOutOfTheDimensionsTree() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("husk-quarantine").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_husk");
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
|
||||
assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk")));
|
||||
assertEquals(1, warnings.size());
|
||||
assertTrue(warnings.getFirst(),
|
||||
warnings.getFirst().contains("/iris remove world=world_iris_husk delete=true"));
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper enumerates the dimensions tree from disk, so a husk whose bukkit.yml entry is already gone stops
|
||||
* the boot exactly the same way. The sweep is over the tree, not over the configuration.
|
||||
*/
|
||||
@Test
|
||||
public void aHuskWithNoBukkitConfigurationEntryIsStillMovedOut() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("husk-unconfigured").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft/raids.dat"), "x");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot);
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
|
||||
|
||||
assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk")));
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldWithRealDataIsNeverQuarantinedAndStillStopsStartup() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("husk-with-data").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/kept/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/iris/kept/region/r.0.0.mca"), "terrain");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_kept");
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
|
||||
|
||||
assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/kept")));
|
||||
assertTrue(warnings.isEmpty());
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine rebuilds iris/engine-data under a world folder the server itself recreated during save-all,
|
||||
* so an iris/ directory with no pack snapshot is the ambiguous state, not a worthless one.
|
||||
*/
|
||||
@Test
|
||||
public void anIrisMarkerWithNoPackSnapshotStillFailsClosed() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("husk-marker").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data"));
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_marked");
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
|
||||
|
||||
assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data")));
|
||||
assertTrue(warnings.isEmpty());
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupContinuesForAWorldWhoseFolderIsGoneAndReportsItOnce() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("orphan-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris"));
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_orphan1");
|
||||
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_orphan1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupContinuesForHealthyWorlds() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("healthy-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/iris/moon/iris/pack"));
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world_iris_moon");
|
||||
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
|
||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
||||
}
|
||||
|
||||
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
||||
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||
}
|
||||
|
||||
private static void writeServerProperties(Path serverRoot) throws Exception {
|
||||
Files.writeString(serverRoot.resolve("server.properties"), "level-name=world\n", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void writeBukkitWorlds(Path serverRoot, String... configuredWorldNames) throws Exception {
|
||||
File configuration = serverRoot.resolve("bukkit.yml").toFile();
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
for (String configuredWorldName : configuredWorldNames) {
|
||||
yaml.set("worlds." + configuredWorldName + ".generator", "Iris:overworld");
|
||||
}
|
||||
yaml.save(configuration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Core states a severity on every message it logs and the modded adapters honour it. On Bukkit the same
|
||||
* message used to become a coloured console line, which the server logs at INFO, so no core warning ever
|
||||
* appeared in a WARN-level scan of logs/latest.log - including the orphaned-world-storage reports the
|
||||
* bootstrap replays there specifically for operators to find.
|
||||
*/
|
||||
public class IrisDiagnosticLogLevelTest {
|
||||
@Test
|
||||
public void coreDiagnosticsKeepTheirSeverity() {
|
||||
assertEquals(Level.WARNING, Iris.diagnosticLevel(LogLevel.WARN));
|
||||
assertEquals(Level.SEVERE, Iris.diagnosticLevel(LogLevel.ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void informationalAndDebugMessagesStayOnTheConsolePath() {
|
||||
assertNull(Iris.diagnosticLevel(LogLevel.INFO));
|
||||
assertNull(Iris.diagnosticLevel(LogLevel.DEBUG));
|
||||
}
|
||||
|
||||
/**
|
||||
* Console sender output reaches the terminal but not the instance's logs/latest.log, which is the only
|
||||
* log most operators read after the fact. A handful of lifecycle lines go to the plugin logger instead.
|
||||
*/
|
||||
@Test
|
||||
public void lifecycleNoticesReachThePluginLoggerAtInfo() {
|
||||
assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE));
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning raised by the adapter is the same kind of thing as a warning raised by core. Routing one
|
||||
* through the plugin logger and the other through the console sender makes the level depend on which
|
||||
* side of the SPI the call happened to be written on.
|
||||
*/
|
||||
@Test
|
||||
public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
|
||||
|
||||
String warn = method(source, "public static void warn(String format, Object... objs)");
|
||||
assertTrue(warn, warn.contains("diagnostic(Level.WARNING"));
|
||||
assertFalse(warn, warn.contains("msg("));
|
||||
|
||||
String error = method(source, "public static void error(String format, Object... objs)");
|
||||
assertTrue(error, error.contains("diagnostic(Level.SEVERE"));
|
||||
assertFalse(error, error.contains("msg("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theCoreLogBridgeRoutesDiagnosticsToThePluginLogger() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
|
||||
String bridge = method(source, "private static void bridgeLog(LogLevel level, String message)");
|
||||
|
||||
assertTrue(bridge, bridge.contains("diagnosticLevel(target)"));
|
||||
assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)"));
|
||||
assertTrue(source.contains("plugin.getLogger().log(level, line)"));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("method not found: " + signature, start >= 0);
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int i = open; i < source.length(); i++) {
|
||||
char c = source.charAt(i);
|
||||
if (c == '{') {
|
||||
depth++;
|
||||
} else if (c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError("unterminated method: " + signature);
|
||||
}
|
||||
}
|
||||
+85
-3
@@ -94,15 +94,97 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperStartupAliasResolvesToCanonicalRuntimeKey() {
|
||||
public void paperStartupAliasResolvesToCanonicalRuntimeKey() throws Exception {
|
||||
File levelRoot = ownedLevelRoot("startup-alias", "moon");
|
||||
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world")
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world", levelRoot)
|
||||
);
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("moon", "world")
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("moon", "world", levelRoot)
|
||||
);
|
||||
assertEquals(
|
||||
NamespacedKey.minecraft("overworld"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("world", "world", levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseRuntimeKeyedNameResolvesToTheStoredIrisWorld() throws Exception {
|
||||
File levelRoot = ownedLevelRoot("runtime-keyed", "moon");
|
||||
|
||||
assertEquals(
|
||||
"mv load names an Iris world iris_<key> because its keyed creator refuses the namespace",
|
||||
new NamespacedKey("iris", "moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runtimeKeyedNameWithoutStorageKeepsItsLiteralKey() throws Exception {
|
||||
File levelRoot = ownedLevelRoot("runtime-keyed-missing", "elsewhere");
|
||||
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "iris_moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void literalWorldStorageWinsOverTheRuntimeKeyedReading() throws Exception {
|
||||
File levelRoot = ownedLevelRoot("runtime-keyed-collision", "moon");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/iris_moon").mkdirs());
|
||||
|
||||
assertEquals(
|
||||
"a world genuinely created as iris_moon keeps its own identity",
|
||||
new NamespacedKey("iris", "iris_moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelNamedIrisStillResolvesItsOwnStartupNames() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("level-named-iris", "iris");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
|
||||
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "moon"),
|
||||
IrisWorldGeneratorResolver.configuredWorldKey("iris_iris_moon", "iris", levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal has to name the world the admin typed. The generation guard keeps the storage-existence
|
||||
* check that stops a genuine iris_moon world being mis-mapped; the message must not, or an orphan is
|
||||
* described as iris:iris_orphan1 and the remedy names a world that never existed.
|
||||
*/
|
||||
@Test
|
||||
public void refusalMessagesNameTheRuntimeKeyedWorldWithoutItsStorage() {
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "orphan1"),
|
||||
IrisWorldGeneratorResolver.messageWorldKey("iris_orphan1", "world")
|
||||
);
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "moon"),
|
||||
IrisWorldGeneratorResolver.messageWorldKey("world_iris_moon", "world")
|
||||
);
|
||||
assertEquals(
|
||||
"a world genuinely created as iris_moon keeps its own identity",
|
||||
new NamespacedKey("iris", "iris_moon"),
|
||||
IrisWorldGeneratorResolver.messageWorldKey("world_iris_iris_moon", "world")
|
||||
);
|
||||
assertEquals(
|
||||
NamespacedKey.minecraft("overworld"),
|
||||
IrisWorldGeneratorResolver.messageWorldKey("world", "world")
|
||||
);
|
||||
}
|
||||
|
||||
private File ownedLevelRoot(String scope, String worldKey) throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder(scope, "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/" + worldKey).mkdirs());
|
||||
return levelRoot;
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ public class IrisApiWiringContractTest {
|
||||
assertBefore(add, "registered = true;", "phases.ready(world)");
|
||||
|
||||
String remove = method(source,
|
||||
"private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
"private boolean remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)");
|
||||
assertBefore(remove, "phases.closing(world)",
|
||||
"deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)");
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ public class IrisEngineLifecycleContractTest {
|
||||
public void registrationRetryWaitsAsynchronouslyForClose() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
|
||||
String add = method(source, "private void add(World world)");
|
||||
String remove = method(source, "private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
String remove = method(source, "private boolean remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
String completeClose = method(source, "private void completeClose(");
|
||||
String retry = method(source, "private void retryRegistrationAfterClose(");
|
||||
String invokeClose = method(source, "private CompletableFuture<Void> invokeGeneratorClose()");
|
||||
@@ -73,7 +73,7 @@ public class IrisEngineLifecycleContractTest {
|
||||
public void worldUnloadDefersGeneratorCloseUntilTheRawBoundaryCompletesTrue() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
|
||||
String handler = method(source, "public void onWorldUnload(WorldUnloadEvent event)");
|
||||
String remove = method(source, "private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
String remove = method(source, "private boolean remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
String defer = method(source, "private void deferCloseUntilWorldUnload(");
|
||||
|
||||
assertBefore(handler, "WorldUnloadBoundaryRegistry.claim(", "remove(world, unloadBoundary)");
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Multiverse unloads a world through {@code Bukkit.unloadWorld}, which carries no Iris unload boundary.
|
||||
* {@code IrisEngineSVC} closes the engine of every world it registered, but registration is skipped while
|
||||
* a previous generator for the same identity is still closing, and an unload landing in that window used
|
||||
* to leave a live engine bound to a dead world.
|
||||
*/
|
||||
public class IrisEngineSVCUntrackedUnloadTest {
|
||||
@Test
|
||||
public void externalUnloadClosesAnIrisGeneratorTheServiceNeverRegistered() {
|
||||
BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class);
|
||||
when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("mvtest", generator)));
|
||||
|
||||
verify(generator).closeAsync();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalUnloadOfANonIrisWorldClosesNothing() {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn("plots");
|
||||
when(world.getKey()).thenReturn(new NamespacedKey("minecraft", "plots"));
|
||||
|
||||
new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalUnloadDoesNotReenterAGeneratorThatIsAlreadyClosing() {
|
||||
BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class);
|
||||
when(generator.isClosing()).thenReturn(true);
|
||||
|
||||
new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("closing", generator)));
|
||||
|
||||
verify(generator, never()).closeAsync();
|
||||
}
|
||||
|
||||
private static World irisWorld(String key, BukkitChunkGenerator generator) {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn("world_iris_" + key);
|
||||
when(world.getKey()).thenReturn(new NamespacedKey("iris", key));
|
||||
when(world.getGenerator()).thenReturn(generator);
|
||||
return world;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user