mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +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,
|
||||
|
||||
+40
-5
@@ -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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,13 @@ public final class ModdedIrisLog {
|
||||
LogLevel target = level == null ? LogLevel.INFO : level;
|
||||
switch (target) {
|
||||
case DEBUG -> debug(message);
|
||||
case INFO -> info(message);
|
||||
case WARN -> warn(message);
|
||||
case ERROR -> error(message);
|
||||
// INFO, NOTICE, and any level added later. This is a switch statement, so the compiler does not
|
||||
// check it for exhaustiveness and a missing arm would drop the message silently. The modded
|
||||
// loaders have one logger, so a lifecycle notice is already in the file the server writes; the
|
||||
// level only differs on Bukkit, where INFO goes to a console sender instead.
|
||||
default -> info(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* The level router is a switch statement, which the compiler does not check for exhaustiveness. A level added
|
||||
* to {@link art.arcane.iris.spi.LogLevel} with no arm here would compile and then drop every message at that
|
||||
* level without a trace, so the fallback arm is what keeps a new level visible on the modded loaders.
|
||||
*/
|
||||
public class ModdedIrisLogLevelCoverageTest {
|
||||
@Test
|
||||
public void everyLogLevelReachesTheLoggerIncludingOnesAddedLater() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedIrisLog.java"));
|
||||
int router = source.indexOf("public static void log(LogLevel level, String message)");
|
||||
assertTrue("log(LogLevel, String) not found", router >= 0);
|
||||
|
||||
String body = source.substring(router, source.indexOf("public static void debug(", router));
|
||||
assertTrue(body, body.contains("default -> info(message);"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -117,7 +117,7 @@ dependencies {
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(libs.paper.api)
|
||||
testRuntimeOnly(libs.paper.api)
|
||||
testRuntimeOnly(libs.multiverseCore)
|
||||
testImplementation(libs.multiverseCore)
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
|
||||
@@ -24,6 +24,7 @@ art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java
|
||||
art/arcane/iris/core/lifecycle/WorldsProviderBackend.java
|
||||
art/arcane/iris/core/link/ExternalDataProvider.java
|
||||
art/arcane/iris/core/link/MultiverseCoreLink.java
|
||||
art/arcane/iris/core/link/MultiverseGuardListener.java
|
||||
art/arcane/iris/core/link/WorldEditLink.java
|
||||
art/arcane/iris/core/link/data/CraftEngineDataProvider.java
|
||||
art/arcane/iris/core/link/data/EcoItemsDataProvider.java
|
||||
@@ -66,6 +67,7 @@ art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java
|
||||
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
|
||||
art/arcane/iris/core/service/JigsawStudioService.java
|
||||
art/arcane/iris/core/service/JigsawStudioToolCodec.java
|
||||
art/arcane/iris/core/service/MultiverseSVC.java
|
||||
art/arcane/iris/core/service/ObjectSVC.java
|
||||
art/arcane/iris/core/service/ObjectStudioSaveService.java
|
||||
art/arcane/iris/core/service/PackDownloadProgressReporter.java
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisWorldStorage {
|
||||
private static final String IRIS_NAMESPACE = "iris";
|
||||
@@ -325,6 +326,29 @@ public final class IrisWorldStorage {
|
||||
return target.toFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the level root holds at least one Iris-managed world directory. Used by the failure paths
|
||||
* that have to decide whether letting the server continue would put vanilla terrain into Iris storage.
|
||||
*/
|
||||
public static boolean hasManagedWorldStorage(File levelRoot) {
|
||||
if (levelRoot == null) {
|
||||
return false;
|
||||
}
|
||||
Path namespace = levelRoot.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.resolve("dimensions")
|
||||
.resolve(IRIS_NAMESPACE);
|
||||
if (!Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(namespace)) {
|
||||
return entries.anyMatch(entry -> Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS));
|
||||
} catch (IOException unreadable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isExistingManagedDimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
try {
|
||||
Path target = requireSafeManagedDimensionRoot(levelRoot, key).toPath();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
@@ -32,12 +33,16 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class IrisWorlds {
|
||||
private static final String IRIS_GENERATOR_NAME = "iris";
|
||||
private static final String IRIS_GENERATOR_PREFIX = "iris:";
|
||||
private static final String IRIS_NAMESPACE = "iris";
|
||||
private static final AtomicCache<IrisWorlds> cache = new AtomicCache<>();
|
||||
private static final Set<String> UNUSABLE_STORAGE_REPORTED = ConcurrentHashMap.newKeySet();
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final Type TYPE = TypeToken.getParameterized(KMap.class, String.class, String.class).getType();
|
||||
private final Path levelRoot;
|
||||
@@ -49,15 +54,33 @@ public class IrisWorlds {
|
||||
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
registryFile = registryFile(this.levelRoot);
|
||||
this.worlds = new KMap<>();
|
||||
worlds.forEach((identity, type) -> this.worlds.put(WorldIdentity.parse(identity).toString(), type));
|
||||
// One unreadable entry drops itself. This runs behind a static cache whose callers all dereference
|
||||
// the result immediately, so anything thrown here stops Iris from enabling for every world.
|
||||
worlds.forEach((identity, type) -> {
|
||||
try {
|
||||
this.worlds.put(WorldIdentity.parse(identity).toString(), type);
|
||||
} catch (IllegalArgumentException unreadable) {
|
||||
IrisLogging.warn("Dropping unreadable worlds.json entry %s: %s", identity, unreadable.getMessage());
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
readBukkitWorlds(this.levelRoot).forEach((name, type) -> put0(
|
||||
IrisWorldStorage.keyFromConfiguredWorldName(name, this.levelRoot.getFileName().toString()).toString(),
|
||||
type));
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* The world registry, never null.
|
||||
* <p>
|
||||
* A failure here used to be swallowed into a null return, and every caller dereferences the result
|
||||
* immediately, so a single unusable world folder turned into an NPE that stopped Iris from enabling -
|
||||
* and a server whose Iris worlds then generated vanilla terrain over their own region files. Per-world
|
||||
* storage problems are isolated in {@link #clean()} and {@link #loadDimension(String, String)}; anything
|
||||
* that gets past those is a real failure and is raised, not hidden.
|
||||
*/
|
||||
public static IrisWorlds get() {
|
||||
return cache.aquire(() -> {
|
||||
return cache.aquireOnceOrThrow(() -> {
|
||||
Path levelRoot = IrisWorldStorage.levelRoot().toPath().toAbsolutePath().normalize();
|
||||
File file = registryFile(levelRoot).toFile();
|
||||
if (!file.exists()) {
|
||||
@@ -69,8 +92,8 @@ public class IrisWorlds {
|
||||
KMap<String, String> worlds = GSON.fromJson(json, TYPE);
|
||||
return new IrisWorlds(levelRoot, Objects.requireNonNullElseGet(worlds, KMap::new));
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("Failed to load worlds.json for level root " + levelRoot + "!");
|
||||
e.printStackTrace();
|
||||
IrisLogging.error("Failed to read worlds.json for level root " + levelRoot
|
||||
+ "; rebuilding it from bukkit.yml.");
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
|
||||
@@ -138,6 +161,14 @@ public class IrisWorlds {
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimension one registered world generates from, or null when its pack snapshot cannot supply it.
|
||||
* Same resolution {@link #getDimensions()} uses, for callers that hold a single world identity.
|
||||
*/
|
||||
public IrisDimension getDimension(String worldIdentity, String id) {
|
||||
return loadDimension(worldIdentity, id);
|
||||
}
|
||||
|
||||
public Stream<IrisDimension> getDimensions() {
|
||||
return getWorlds()
|
||||
.entrySet()
|
||||
@@ -146,6 +177,14 @@ public class IrisWorlds {
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops registry entries whose pack snapshot no longer backs them.
|
||||
* <p>
|
||||
* A world whose storage is present but unusable is kept and reported instead of dropped: the entry is
|
||||
* what lets {@code /iris remove} find the world, and dropping it would not stop the server from loading
|
||||
* the folder anyway. It is excluded from {@link #getDimensions()} so one broken world cannot fail the
|
||||
* whole registry - that failure used to propagate out of the constructor and null out {@link #get()}.
|
||||
*/
|
||||
public synchronized void clean() {
|
||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||
try {
|
||||
@@ -154,6 +193,9 @@ public class IrisWorlds {
|
||||
|| !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return true;
|
||||
} catch (IllegalStateException e) {
|
||||
warnUnusableStorage(entry.getKey(), e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
dirty = dirty || removed;
|
||||
@@ -232,27 +274,45 @@ public class IrisWorlds {
|
||||
KMap<String, String> result = new KMap<>();
|
||||
for (Map.Entry<String, String> entry : Objects.requireNonNull(configuredWorlds, "configuredWorlds").entrySet()) {
|
||||
String configuredWorldName = entry.getKey();
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromConfiguredWorldName(
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.keyFromConfiguredWorldName(
|
||||
configuredWorldName,
|
||||
levelNamePath.toString());
|
||||
if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString())
|
||||
.equals(configuredWorldName)) {
|
||||
continue;
|
||||
}
|
||||
} catch (IllegalArgumentException notAnIrisWorldName) {
|
||||
// A bukkit.yml name that has no Iris identity is somebody else's world, never this
|
||||
// registry's, and must not stop the whole registry from being built.
|
||||
continue;
|
||||
}
|
||||
Path worldContainer = root.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalArgumentException("Selected level root has no world container: " + root);
|
||||
}
|
||||
boolean storagePresent;
|
||||
try {
|
||||
if (IrisWorldStorage.frozenDimensionRoot(
|
||||
storagePresent = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
root.toFile(),
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
).isPresent()) {
|
||||
result.put(configuredWorldName, entry.getValue());
|
||||
).isPresent();
|
||||
} catch (IllegalStateException unusableStorage) {
|
||||
storagePresent = false;
|
||||
}
|
||||
} catch (IllegalStateException ignored) {
|
||||
if (storagePresent) {
|
||||
result.put(configuredWorldName, entry.getValue());
|
||||
continue;
|
||||
}
|
||||
// Vanilla slots are absent on every server that never created them; only an Iris world that
|
||||
// bukkit.yml still points at is an orphan worth reporting.
|
||||
if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
|
||||
MissingWorldStorageLog.warnOnce(
|
||||
configuredWorldName,
|
||||
root.resolve("dimensions").resolve(IRIS_NAMESPACE).resolve(worldKey.getKey()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -310,8 +370,24 @@ public class IrisWorlds {
|
||||
return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot);
|
||||
}
|
||||
|
||||
private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) {
|
||||
if (!UNUSABLE_STORAGE_REPORTED.add(worldIdentity)) {
|
||||
return;
|
||||
}
|
||||
IrisLogging.error("Iris world %s has unusable world storage and is excluded: %s",
|
||||
worldIdentity, failure.getMessage());
|
||||
IrisLogging.error("Restore its iris/pack snapshot, or drop the world with /iris remove world=%s delete=true.",
|
||||
worldIdentity);
|
||||
}
|
||||
|
||||
private IrisDimension loadDimension(String worldIdentity, String id) {
|
||||
File pack = packRoot(worldIdentity).orElse(null);
|
||||
File pack;
|
||||
try {
|
||||
pack = packRoot(worldIdentity).orElse(null);
|
||||
} catch (IllegalStateException unusableStorage) {
|
||||
warnUnusableStorage(worldIdentity, unusableStorage);
|
||||
return null;
|
||||
}
|
||||
IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id);
|
||||
if (dimension == null) {
|
||||
dimension = IrisData.loadAnyDimension(id, null);
|
||||
|
||||
@@ -986,12 +986,11 @@ public class ServerConfigurator {
|
||||
|
||||
|
||||
if (INMS.get().supportsDataPacks()) {
|
||||
IrisLogging.error("============================================================================");
|
||||
// Three sentences, no rules: a separator carries the record's severity too, so a box drawn
|
||||
// out of equals signs became three more [SEVERE] lines saying nothing.
|
||||
IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
|
||||
IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
|
||||
IrisLogging.error("----------------------------------------------------------------------------");
|
||||
IrisLogging.error(C.UNDERLINE + "IT IS HIGHLY RECOMMENDED YOU RESTART THE SERVER BEFORE GENERATING!");
|
||||
IrisLogging.error("============================================================================");
|
||||
IrisLogging.error(C.UNDERLINE + "Restart the server before generating.");
|
||||
|
||||
for (Player i : Bukkit.getOnlinePlayers()) {
|
||||
if (i.isOp() || i.hasPermission("iris.all")) {
|
||||
|
||||
@@ -99,7 +99,7 @@ public final class GuiHost {
|
||||
desktop.setQuitHandler((event, response) -> cancelDesktopQuit(response));
|
||||
} catch (Throwable error) {
|
||||
IrisLogging.reportError(error);
|
||||
IrisLogging.warn("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
|
||||
IrisLogging.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
||||
@@ -113,7 +114,221 @@ public final class BukkitWorldConfiguration {
|
||||
return List.of();
|
||||
}
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(requiredConfigurationFile);
|
||||
Map<WorldSlotKey, IrisGeneratorBinding> bindings = new LinkedHashMap<>();
|
||||
for (IrisWorldCandidate candidate : readIrisWorldCandidates(
|
||||
load(requiredConfigurationFile),
|
||||
requiredLevelName
|
||||
)) {
|
||||
String configuredName = candidate.configuredWorldName();
|
||||
Path worldContainer = requiredLevelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||
}
|
||||
boolean storagePresent;
|
||||
try {
|
||||
storagePresent = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
requiredLevelRoot.toFile(),
|
||||
configuredName,
|
||||
candidate.worldKey()
|
||||
).isPresent();
|
||||
} catch (IllegalStateException failure) {
|
||||
storagePresent = false;
|
||||
}
|
||||
if (!storagePresent) {
|
||||
MissingWorldStorageLog.warnOnce(
|
||||
configuredName,
|
||||
expectedDimensionRoot(requiredLevelRoot, candidate.worldKey()));
|
||||
continue;
|
||||
}
|
||||
String dimension = selectedIrisDimension(candidate.configuredGenerator(), configuredName);
|
||||
WorldSlotKey worldKey = new WorldSlotKey(
|
||||
candidate.worldKey().getNamespace(),
|
||||
candidate.worldKey().getKey()
|
||||
);
|
||||
IrisGeneratorBinding binding = new IrisGeneratorBinding(configuredName, worldKey, dimension);
|
||||
IrisGeneratorBinding previous = bindings.putIfAbsent(worldKey, binding);
|
||||
if (previous != null) {
|
||||
throw new IOException("bukkit.yml maps both \"" + previous.configuredWorldName()
|
||||
+ "\" and \"" + configuredName + "\" to " + worldKey + ".");
|
||||
}
|
||||
}
|
||||
return List.copyOf(bindings.values());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies the world storage behind every Iris entry in bukkit.yml.
|
||||
* <p>
|
||||
* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
|
||||
* anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose
|
||||
* frozen pack snapshot is gone still gets loaded by the server, and if Iris cannot supply a generator
|
||||
* for it the server writes vanilla terrain into Iris region files. That state has to be found before
|
||||
* any level loads, so it is reported here rather than discovered at world-generation time.
|
||||
*/
|
||||
public static List<IrisWorldStorageEntry> auditIrisWorldStorage(
|
||||
File configurationFile,
|
||||
String levelName,
|
||||
Path levelRoot
|
||||
) throws IOException {
|
||||
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredLevelName = requireName(levelName, "Level name");
|
||||
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path configurationPath = requiredConfigurationFile.toPath();
|
||||
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return List.of();
|
||||
}
|
||||
Path worldContainer = requiredLevelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||
}
|
||||
synchronized (MUTATION_LOCK) {
|
||||
List<IrisWorldStorageEntry> entries = new ArrayList<>();
|
||||
for (IrisWorldCandidate candidate : readIrisWorldCandidates(
|
||||
load(requiredConfigurationFile),
|
||||
requiredLevelName
|
||||
)) {
|
||||
entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisWorldStorageEntry classifyStorage(
|
||||
Path worldContainer,
|
||||
Path levelRoot,
|
||||
IrisWorldCandidate candidate
|
||||
) {
|
||||
String configuredName = candidate.configuredWorldName();
|
||||
NamespacedKey worldKey = candidate.worldKey();
|
||||
WorldSlotKey slotKey = new WorldSlotKey(worldKey.getNamespace(), worldKey.getKey());
|
||||
Path expectedRoot = expectedDimensionRoot(levelRoot, worldKey);
|
||||
File dimensionRoot;
|
||||
try {
|
||||
dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
configuredName,
|
||||
worldKey
|
||||
).orElse(null);
|
||||
} catch (IllegalStateException unusable) {
|
||||
// The server only loads a level it can see as a directory, so an entry with no directory at
|
||||
// either candidate path is an orphan, not a world that is about to generate.
|
||||
return hasDimensionDirectory(worldContainer, levelRoot, configuredName, worldKey)
|
||||
? new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
|
||||
IrisWorldStorageState.UNUSABLE, unusable.getMessage())
|
||||
: new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
|
||||
IrisWorldStorageState.MISSING, null);
|
||||
}
|
||||
if (dimensionRoot == null) {
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
|
||||
IrisWorldStorageState.MISSING, null);
|
||||
}
|
||||
Path resolvedRoot = dimensionRoot.toPath().toAbsolutePath().normalize();
|
||||
try {
|
||||
IrisWorldStorage.requireFrozenPackRoot(dimensionRoot);
|
||||
} catch (IllegalStateException unusablePack) {
|
||||
// Only a folder that owns something can be corrupted by a vanilla generator writing into it.
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||
hasWorldData(resolvedRoot)
|
||||
? IrisWorldStorageState.UNUSABLE
|
||||
: IrisWorldStorageState.EMPTY,
|
||||
unusablePack.getMessage());
|
||||
}
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||
IrisWorldStorageState.PRESENT, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the world folder holds something a vanilla generator could overwrite: an {@code iris/}
|
||||
* directory marking it as an Iris world whose pack snapshot broke, or stored chunk data.
|
||||
* <p>
|
||||
* Deleting a loaded world's folder and letting the server save the level back recreates a small
|
||||
* {@code data/} skeleton with no regions and no {@code iris/} directory. That husk owns nothing, so
|
||||
* refusing to boot over it only strands the server behind a manual delete.
|
||||
*/
|
||||
private static boolean hasWorldData(Path worldRoot) {
|
||||
if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
for (String directory : new String[]{"region", "entities", "poi", "mantle"}) {
|
||||
if (hasEntries(worldRoot.resolve(directory))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasEntries(Path directory) {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.findAny().isPresent();
|
||||
} catch (IOException unreadable) {
|
||||
// An unreadable directory is not proof that there is nothing to lose.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasDimensionDirectory(
|
||||
Path worldContainer,
|
||||
Path levelRoot,
|
||||
String configuredWorldName,
|
||||
NamespacedKey worldKey
|
||||
) {
|
||||
if (occupiesDimensionPath(levelRoot, expectedDimensionRoot(levelRoot, worldKey))) {
|
||||
return true;
|
||||
}
|
||||
Path configuredLevelRoot = worldContainer.resolve(configuredWorldName).normalize();
|
||||
return occupiesDimensionPath(configuredLevelRoot, expectedDimensionRoot(configuredLevelRoot, worldKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when something the server could load sits at the dimension path: a real directory, or a symbolic
|
||||
* link on any segment below the level root.
|
||||
* <p>
|
||||
* {@link IrisWorldStorage} refuses to resolve through a link, so a linked path is storage Iris cannot use,
|
||||
* not storage that is not there. Calling it missing tells the operator to restore a folder that is already
|
||||
* present and leaves whatever the link points at open to a vanilla generator.
|
||||
*/
|
||||
private static boolean occupiesDimensionPath(Path levelRoot, Path dimensionRoot) {
|
||||
Path root = levelRoot.toAbsolutePath().normalize();
|
||||
Path target = dimensionRoot.toAbsolutePath().normalize();
|
||||
if (!target.startsWith(root) || target.equals(root)) {
|
||||
return Files.isSymbolicLink(target) || Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
Path current = root;
|
||||
for (Path segment : root.relativize(target)) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
private static Path expectedDimensionRoot(Path levelRoot, NamespacedKey worldKey) {
|
||||
return levelRoot.toAbsolutePath()
|
||||
.normalize()
|
||||
.resolve("dimensions")
|
||||
.resolve(worldKey.getNamespace())
|
||||
.resolve(worldKey.getKey())
|
||||
.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every canonical Iris entry in bukkit.yml, in configured-name order, before any storage is inspected.
|
||||
* Malformed entries throw exactly as they always did; entries that are not Iris', or whose name is not
|
||||
* the canonical startup name for their key, are dropped.
|
||||
*/
|
||||
private static List<IrisWorldCandidate> readIrisWorldCandidates(
|
||||
YamlConfiguration configuration,
|
||||
String levelName
|
||||
) throws IOException {
|
||||
Object rawWorlds = configuration.get("worlds");
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (rawWorlds != null && worlds == null) {
|
||||
@@ -125,7 +340,7 @@ public final class BukkitWorldConfiguration {
|
||||
|
||||
List<String> configuredNames = new ArrayList<>(worlds.getKeys(false));
|
||||
configuredNames.sort(Comparator.naturalOrder());
|
||||
Map<WorldSlotKey, IrisGeneratorBinding> bindings = new LinkedHashMap<>();
|
||||
List<IrisWorldCandidate> candidates = new ArrayList<>(configuredNames.size());
|
||||
for (String configuredName : configuredNames) {
|
||||
Object rawWorld = worlds.get(configuredName);
|
||||
ConfigurationSection world = worlds.getConfigurationSection(configuredName);
|
||||
@@ -148,10 +363,7 @@ public final class BukkitWorldConfiguration {
|
||||
|
||||
NamespacedKey namespacedKey;
|
||||
try {
|
||||
namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(
|
||||
configuredName,
|
||||
requiredLevelName
|
||||
);
|
||||
namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(configuredName, levelName);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("bukkit.yml contains an invalid Iris world name \""
|
||||
+ configuredName + "\".", failure);
|
||||
@@ -159,44 +371,12 @@ public final class BukkitWorldConfiguration {
|
||||
if (!"iris".equals(namespacedKey.getNamespace())) {
|
||||
continue;
|
||||
}
|
||||
if (!configuredName.equals(IrisWorldStorage.configuredWorldName(
|
||||
namespacedKey,
|
||||
requiredLevelName
|
||||
))) {
|
||||
if (!configuredName.equals(IrisWorldStorage.configuredWorldName(namespacedKey, levelName))) {
|
||||
continue;
|
||||
}
|
||||
Path worldContainer = requiredLevelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||
}
|
||||
boolean storagePresent;
|
||||
try {
|
||||
storagePresent = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
requiredLevelRoot.toFile(),
|
||||
configuredName,
|
||||
namespacedKey
|
||||
).isPresent();
|
||||
} catch (IllegalStateException failure) {
|
||||
storagePresent = false;
|
||||
}
|
||||
if (!storagePresent) {
|
||||
continue;
|
||||
}
|
||||
String dimension = selectedIrisDimension(configuredGenerator, configuredName);
|
||||
WorldSlotKey worldKey = new WorldSlotKey(
|
||||
namespacedKey.getNamespace(),
|
||||
namespacedKey.getKey()
|
||||
);
|
||||
IrisGeneratorBinding binding = new IrisGeneratorBinding(configuredName, worldKey, dimension);
|
||||
IrisGeneratorBinding previous = bindings.putIfAbsent(worldKey, binding);
|
||||
if (previous != null) {
|
||||
throw new IOException("bukkit.yml maps both \"" + previous.configuredWorldName()
|
||||
+ "\" and \"" + configuredName + "\" to " + worldKey + ".");
|
||||
}
|
||||
}
|
||||
return List.copyOf(bindings.values());
|
||||
candidates.add(new IrisWorldCandidate(configuredName, namespacedKey, configuredGenerator));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
public static GeneratorReplacement replaceIfMatching(
|
||||
@@ -522,6 +702,51 @@ public final class BukkitWorldConfiguration {
|
||||
UNCHANGED
|
||||
}
|
||||
|
||||
public enum IrisWorldStorageState {
|
||||
/**
|
||||
* Storage resolves and carries a usable frozen pack snapshot.
|
||||
*/
|
||||
PRESENT,
|
||||
/**
|
||||
* No world directory at all. The server never enumerates the level, so nothing generates; the
|
||||
* bukkit.yml and Multiverse entries are left alone so putting the folder back restores the world.
|
||||
*/
|
||||
MISSING,
|
||||
/**
|
||||
* A world directory the server will load that holds no pack snapshot and no world data - the
|
||||
* skeleton the server writes back when it saves a level whose folder was deleted underneath it.
|
||||
* There is nothing to overwrite, so it is reported and the server starts.
|
||||
*/
|
||||
EMPTY,
|
||||
/**
|
||||
* A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
|
||||
* generate it and nothing else may.
|
||||
*/
|
||||
UNUSABLE
|
||||
}
|
||||
|
||||
public record IrisWorldStorageEntry(
|
||||
String configuredWorldName,
|
||||
WorldSlotKey worldKey,
|
||||
Path storagePath,
|
||||
IrisWorldStorageState state,
|
||||
String detail
|
||||
) {
|
||||
public IrisWorldStorageEntry {
|
||||
configuredWorldName = requireName(configuredWorldName, "Configured world name");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(storagePath, "storagePath");
|
||||
Objects.requireNonNull(state, "state");
|
||||
}
|
||||
}
|
||||
|
||||
private record IrisWorldCandidate(
|
||||
String configuredWorldName,
|
||||
NamespacedKey worldKey,
|
||||
String configuredGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
public record IrisGeneratorBinding(
|
||||
String configuredWorldName,
|
||||
WorldSlotKey worldKey,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Moves a hot-deleted Iris world's husk out of the dimensions tree before any level is created.
|
||||
* <p>
|
||||
* Deleting a loaded world's folder and letting the server save the level back leaves a directory holding
|
||||
* nothing but the server's own {@code data/} skeleton: no frozen pack snapshot, no regions, no mantle. The
|
||||
* server still enumerates it, and both outcomes stop startup - Iris refuses to boot over a directory it
|
||||
* cannot generate, and excluding it from the dimension registry instead trips Paper's interactive
|
||||
* world-migration gate, which consumes no console input and hangs the boot forever.
|
||||
* <p>
|
||||
* The husk is moved, never deleted: it is recoverable, and the move alone is enough to let the server start.
|
||||
* The test is deliberately narrow. Anything with chunk data, an {@code iris/} directory holding anything of
|
||||
* Iris' own, a symbolic link anywhere on the path, or a directory that cannot be read is left exactly where it
|
||||
* is, for {@link BukkitWorldConfiguration#auditIrisWorldStorage} to fail closed on.
|
||||
*/
|
||||
public final class HuskWorldQuarantine {
|
||||
private static final String IRIS_NAMESPACE = "iris";
|
||||
private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+");
|
||||
private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"};
|
||||
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
|
||||
private static final String APPLE_DOUBLE_PREFIX = "._";
|
||||
private static final DateTimeFormatter STAMP = DateTimeFormatter
|
||||
.ofPattern("yyyyMMdd'T'HHmmss")
|
||||
.withZone(ZoneOffset.UTC);
|
||||
private static final int MAX_DESTINATION_ATTEMPTS = 64;
|
||||
|
||||
private HuskWorldQuarantine() {
|
||||
}
|
||||
|
||||
public static List<Quarantine> quarantineWorthlessHusks(Path levelRoot, Consumer<String> warn) {
|
||||
return quarantineWorthlessHusks(levelRoot, warn, Instant.now());
|
||||
}
|
||||
|
||||
static List<Quarantine> quarantineWorthlessHusks(Path levelRoot, Consumer<String> warn, Instant at) {
|
||||
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
Consumer<String> log = Objects.requireNonNull(warn, "warn");
|
||||
Instant stamp = Objects.requireNonNull(at, "at");
|
||||
String levelName = root.getFileName() == null ? null : root.getFileName().toString();
|
||||
if (levelName == null || levelName.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
Path namespace = root.resolve("dimensions").resolve(IRIS_NAMESPACE);
|
||||
if (Files.isSymbolicLink(namespace) || !Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Path> candidates;
|
||||
try (Stream<Path> entries = Files.list(namespace)) {
|
||||
candidates = entries.sorted().toList();
|
||||
} catch (IOException unreadable) {
|
||||
// An unreadable namespace is not proof that there is nothing in it to lose.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Quarantine> quarantined = new ArrayList<>();
|
||||
for (Path candidate : candidates) {
|
||||
String key = candidate.getFileName().toString();
|
||||
if (!MANAGED_KEY.matcher(key).matches() || !isWorthlessHusk(candidate)) {
|
||||
continue;
|
||||
}
|
||||
String worldName;
|
||||
try {
|
||||
worldName = IrisWorldStorage.configuredWorldName(
|
||||
new WorldSlotKey(IRIS_NAMESPACE, key),
|
||||
levelName);
|
||||
} catch (RuntimeException notAManagedWorld) {
|
||||
continue;
|
||||
}
|
||||
Path destination;
|
||||
try {
|
||||
destination = move(root, key, candidate, stamp);
|
||||
} catch (IOException failure) {
|
||||
log.accept("Iris husk world " + worldName + " at " + candidate
|
||||
+ " could not be moved aside (" + describe(failure)
|
||||
+ "); startup will stop until it is moved or deleted by hand.");
|
||||
continue;
|
||||
}
|
||||
quarantined.add(new Quarantine(key, candidate, destination));
|
||||
log.accept("Iris husk world " + worldName + ": no pack snapshot and no world data; moved "
|
||||
+ candidate + " -> " + destination
|
||||
+ ". Drop the entry with /iris remove world=" + worldName + " delete=true.");
|
||||
}
|
||||
return List.copyOf(quarantined);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when nothing in this directory can be lost: no frozen pack snapshot, nothing of Iris' own under
|
||||
* {@code iris/}, and no stored chunk data. Every uncertain answer - a symbolic link, an unreadable
|
||||
* directory, an entry that is not the type it should be - is false, because uncertainty is not proof of
|
||||
* worthlessness.
|
||||
*/
|
||||
private static boolean isWorthlessHusk(Path dimensionRoot) {
|
||||
if (!isReadableDirectory(dimensionRoot)) {
|
||||
return false;
|
||||
}
|
||||
if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
|
||||
return false;
|
||||
}
|
||||
for (String directory : CHUNK_DATA_DIRECTORIES) {
|
||||
if (hasEntries(dimensionRoot.resolve(directory))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the {@code iris/} entry holds anything of Iris' own.
|
||||
* <p>
|
||||
* The desktop file managers recreate a directory to hold their own metadata while a delete is still in
|
||||
* flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
|
||||
* that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
|
||||
* ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
|
||||
* the list all count as content.
|
||||
*/
|
||||
private static boolean holdsIrisContent(Path irisRoot) {
|
||||
if (Files.isSymbolicLink(irisRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(irisRoot)) {
|
||||
return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOperatingSystemMetadata(Path entry) {
|
||||
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
Path name = entry.getFileName();
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String fileName = name.toString();
|
||||
return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the path is a real directory that can actually be listed. The permission bits alone are not
|
||||
* the answer: what matters is whether Iris can see everything in it before deciding it holds nothing.
|
||||
*/
|
||||
private static boolean isReadableDirectory(Path directory) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
entries.findAny();
|
||||
return true;
|
||||
} catch (IOException unreadable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasEntries(Path directory) {
|
||||
if (Files.isSymbolicLink(directory)) {
|
||||
return true;
|
||||
}
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.findAny().isPresent();
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path move(Path levelRoot, String key, Path husk, Instant at) throws IOException {
|
||||
Path huskRoot = requireDirectoryPath(levelRoot, levelRoot.resolve(IRIS_NAMESPACE).resolve("husks"));
|
||||
String base = key + "-" + STAMP.format(at);
|
||||
for (int attempt = 1; attempt <= MAX_DESTINATION_ATTEMPTS; attempt++) {
|
||||
Path destination = huskRoot.resolve(attempt == 1 ? base : base + "-" + attempt);
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Files.move(husk, destination, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException notAtomic) {
|
||||
Files.move(husk, destination);
|
||||
}
|
||||
DirectoryDurability.forceDirectoryAfterCommit(huskRoot, "An Iris husk world quarantine");
|
||||
return destination;
|
||||
}
|
||||
throw new IOException("No free husk destination under " + huskRoot + " for " + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the quarantine directory, refusing when any segment below the level root is a symbolic link so
|
||||
* a hostile or accidental link cannot redirect a world folder out of the level root. Segments above the
|
||||
* level root are the server's own installation path and are not Iris' to judge.
|
||||
*/
|
||||
private static Path requireDirectoryPath(Path levelRoot, Path directory) throws IOException {
|
||||
Path current = levelRoot;
|
||||
for (Path segment : levelRoot.relativize(directory)) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IOException("Husk quarantine path contains a symbolic link: " + current);
|
||||
}
|
||||
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Husk quarantine path is not a directory: " + current);
|
||||
}
|
||||
}
|
||||
Files.createDirectories(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static String describe(Throwable failure) {
|
||||
String message = failure.getMessage();
|
||||
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
public record Quarantine(String worldKey, Path husk, Path destination) {
|
||||
public Quarantine {
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(husk, "husk");
|
||||
Objects.requireNonNull(destination, "destination");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Loads a persistent Iris world back into a running server.
|
||||
* <p>
|
||||
* Iris creates its worlds from a keyed WorldCreator so the level lands in
|
||||
* {@code <levelRoot>/dimensions/iris/<key>} and carries the environment the pack's dimension declares.
|
||||
* Nothing else can: a plain name-keyed creator builds a fresh vanilla world beside the level root, and a
|
||||
* custom-dimension world reports {@code CUSTOM} after its first restart, which CraftServer refuses outright.
|
||||
* Any integration that wants an Iris world loaded has to come through here.
|
||||
* <p>
|
||||
* The implementation is installed by the platform adapter; core resolves it through
|
||||
* {@link art.arcane.iris.spi.IrisServices} and degrades when nothing is registered.
|
||||
*/
|
||||
public interface ManagedWorldLoader {
|
||||
/**
|
||||
* Loads the world registered under {@code configuredWorldName} - the Bukkit startup name, not an alias.
|
||||
* Never throws: a refusal is reported through the returned outcome.
|
||||
*/
|
||||
CompletableFuture<ManagedWorldLoad> load(String configuredWorldName);
|
||||
|
||||
record ManagedWorldLoad(boolean loaded, String message) {
|
||||
public ManagedWorldLoad {
|
||||
Objects.requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
public static ManagedWorldLoad loaded(String message) {
|
||||
return new ManagedWorldLoad(true, message);
|
||||
}
|
||||
|
||||
public static ManagedWorldLoad failed(String message) {
|
||||
return new ManagedWorldLoad(false, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* One warning per orphaned Iris world per boot.
|
||||
* <p>
|
||||
* The bootstrap storage audit, the bukkit.yml binding read and the worlds.json registry read all notice the
|
||||
* same orphan, and the registry read runs on every {@code getWorlds()}, so the warning is deduplicated by
|
||||
* configured world name instead of being emitted per code path.
|
||||
* <p>
|
||||
* Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the
|
||||
* console and the runtime log but never the instance's {@code logs/latest.log}, which is the only log most
|
||||
* operators read. Warnings raised while no platform is bound are held and re-emitted once by
|
||||
* {@link #replayToPlatformLog()}.
|
||||
* <p>
|
||||
* Nothing here prunes configuration. A world folder can be missing because a drive is unmounted or a backup
|
||||
* restore is in flight, and the surviving bukkit.yml and Multiverse entries are exactly what makes putting
|
||||
* the folder back a complete recovery.
|
||||
*/
|
||||
public final class MissingWorldStorageLog {
|
||||
private static final Set<String> WARNED = ConcurrentHashMap.newKeySet();
|
||||
private static final List<String[]> PENDING = new ArrayList<>();
|
||||
private static final Object PENDING_LOCK = new Object();
|
||||
|
||||
private MissingWorldStorageLog() {
|
||||
}
|
||||
|
||||
public static void warnOnce(String configuredWorldName, Path expectedStorage) {
|
||||
String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName");
|
||||
Path storage = Objects.requireNonNull(expectedStorage, "expectedStorage");
|
||||
if (!WARNED.add(worldName)) {
|
||||
return;
|
||||
}
|
||||
emit(new String[]{
|
||||
IrisLogging.format("Iris world %s is configured but has no world storage at %s; it will not load.",
|
||||
worldName, storage),
|
||||
IrisLogging.format("Restore that folder, or drop the world with /iris remove world=%s delete=true.",
|
||||
worldName)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a world folder that survives with no pack snapshot and no world data - the skeleton the server
|
||||
* writes back when it saves a level whose folder was deleted underneath it. There is nothing in it to
|
||||
* lose, so it is reported instead of stopping startup, and the remedy is a delete rather than a restore.
|
||||
*/
|
||||
public static void warnEmptyOnce(String configuredWorldName, Path storage) {
|
||||
String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName");
|
||||
Path worldStorage = Objects.requireNonNull(storage, "storage");
|
||||
if (!WARNED.add(worldName)) {
|
||||
return;
|
||||
}
|
||||
emit(new String[]{
|
||||
IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region"
|
||||
+ " data; it will generate as vanilla terrain.", worldName, worldStorage),
|
||||
IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
|
||||
worldName)
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean hasWarned(String configuredWorldName) {
|
||||
return WARNED.contains(Objects.requireNonNull(configuredWorldName, "configuredWorldName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emits every warning raised before a platform was bound, exactly once. Safe to call more than once
|
||||
* and safe to call when nothing was held.
|
||||
*/
|
||||
public static void replayToPlatformLog() {
|
||||
List<String[]> held;
|
||||
synchronized (PENDING_LOCK) {
|
||||
if (PENDING.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
held = List.copyOf(PENDING);
|
||||
PENDING.clear();
|
||||
}
|
||||
for (String[] lines : held) {
|
||||
write(lines);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for tests: the deduplication set is per JVM, not per server boot.
|
||||
*/
|
||||
public static void reset() {
|
||||
WARNED.clear();
|
||||
synchronized (PENDING_LOCK) {
|
||||
PENDING.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void emit(String[] lines) {
|
||||
write(lines);
|
||||
if (IrisPlatforms.isBound()) {
|
||||
return;
|
||||
}
|
||||
synchronized (PENDING_LOCK) {
|
||||
PENDING.add(lines);
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(String[] lines) {
|
||||
for (String line : lines) {
|
||||
// Pre-formatted: passing no arguments keeps a path containing '%' out of the format engine.
|
||||
IrisLogging.warn(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Detects an Iris world folder that was deleted out from under a live engine.
|
||||
* <p>
|
||||
* Every persistence path resolves its target from a world folder captured when the engine bound, and the
|
||||
* writes create their own parents. After an external delete that turns a save into a rebuild: a directory
|
||||
* tree with engine data and server {@code .dat} files but no pack snapshot and no regions, which the next
|
||||
* boot reads as an owned-but-broken world. Persistence stops instead, once the folder is gone.
|
||||
* <p>
|
||||
* The verdict latches. A {@code save-all} after the delete writes the level's own {@code data/*.dat} files
|
||||
* back, which recreates the world folder, and a plain "is it a directory" check would then let Iris resume
|
||||
* writing into a tree that no longer holds the world. It also accepts a directory Iris established under the
|
||||
* folder: once Iris has written {@code iris/engine-data} there, that directory disappearing is a delete
|
||||
* whether or not the server has already put the folder back, which is what makes the next boot's storage
|
||||
* classification the same on every run instead of depending on save ordering.
|
||||
*/
|
||||
public final class VanishedWorldStorage {
|
||||
private static final Set<String> VANISHED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private VanishedWorldStorage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the world folder is no longer a directory, or was already found gone earlier in this JVM.
|
||||
*/
|
||||
public static boolean vanished(File worldFolder) {
|
||||
return vanished(worldFolder, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the world folder is gone, or when {@code establishedTree} - a directory Iris is known to have
|
||||
* created under it already - is gone. Reports the first time it sees each folder.
|
||||
*
|
||||
* @param establishedTree a directory Iris has already written, or null when it has written none yet
|
||||
*/
|
||||
public static boolean vanished(File worldFolder, File establishedTree) {
|
||||
if (worldFolder == null) {
|
||||
return false;
|
||||
}
|
||||
Path path = worldFolder.toPath().toAbsolutePath().normalize();
|
||||
if (VANISHED.contains(path.toString())) {
|
||||
return true;
|
||||
}
|
||||
if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return report(path);
|
||||
}
|
||||
if (establishedTree == null) {
|
||||
return false;
|
||||
}
|
||||
Path tree = establishedTree.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isDirectory(tree, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
return report(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for tests: the report set is per JVM, not per server boot.
|
||||
*/
|
||||
public static void reset() {
|
||||
VANISHED.clear();
|
||||
}
|
||||
|
||||
private static boolean report(Path path) {
|
||||
if (VANISHED.add(path.toString())) {
|
||||
IrisLogging.error("Iris world storage is gone at %s; that world is no longer being written.", path);
|
||||
IrisLogging.error("Unload or remove the world, or restore the folder and restart the server.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,33 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import lombok.SneakyThrows;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.mvplugins.multiverse.core.MultiverseCoreApi;
|
||||
import org.mvplugins.multiverse.core.locale.message.Message;
|
||||
import org.mvplugins.multiverse.core.utils.result.Attempt;
|
||||
import org.mvplugins.multiverse.core.utils.result.FailureReason;
|
||||
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.MultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.WorldManager;
|
||||
import org.mvplugins.multiverse.core.world.options.ImportWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.options.LoadWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.reasons.LoadFailureReason;
|
||||
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@@ -44,37 +54,63 @@ import java.util.Objects;
|
||||
* its worlds under the Paper startup name ({@code <level>_<namespace>_<key>}), so the name Multiverse
|
||||
* sees at import is already the name it will see again after every restart, and it is the name every
|
||||
* Iris unregistration uses.
|
||||
* <p>
|
||||
* Every entry point here degrades instead of throwing when Multiverse is absent, not yet loaded, or
|
||||
* has moved the internals this link reflects into. A server without Multiverse must behave exactly as
|
||||
* it did before this link existed.
|
||||
*/
|
||||
public class MultiverseCoreLink {
|
||||
public static final String MULTIVERSE_PLUGIN = "Multiverse-Core";
|
||||
private static final String GENERATOR_PREFIX = "Iris:";
|
||||
/**
|
||||
* Reflection into Multiverse internals is warned about once. A server that keeps logging the same
|
||||
* broken accessor for every world tells the admin nothing the first line did not.
|
||||
*/
|
||||
private static volatile boolean worldConfigUnwritable;
|
||||
|
||||
/**
|
||||
* Best-effort scrub for worlds Iris never registered (studio worlds). Multiverse not knowing the
|
||||
* world is the normal case here, so a miss is not worth a warning.
|
||||
*/
|
||||
public boolean removeIfPresent(World world) {
|
||||
String worldName = Objects.requireNonNull(world, "world").getName();
|
||||
if (!isActive()) {
|
||||
return false;
|
||||
}
|
||||
WorldManager manager = worldManager();
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName);
|
||||
if (multiverseWorld == null) {
|
||||
if (manager == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName);
|
||||
return multiverseWorld != null && remove(manager, multiverseWorld, worldName);
|
||||
} catch (Throwable failure) {
|
||||
// Studio teardown is not allowed to fail over a Multiverse bookkeeping entry.
|
||||
IrisLogging.warn("Multiverse refused to drop \"%s\": %s", worldName, describe(failure));
|
||||
return false;
|
||||
}
|
||||
return remove(manager, multiverseWorld, worldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative unregistration for a world Iris registered. A miss leaves a ghost entry behind in
|
||||
* Multiverse's worlds.yml, so it is reported.
|
||||
* Multiverse's worlds.yml, so it is reported. A refusal from Multiverse itself is raised, because
|
||||
* the caller is a removal the admin asked for and a silent success would delete the world files
|
||||
* while Multiverse kept pointing at them.
|
||||
*/
|
||||
public boolean removeFromConfig(String configuredWorldName) {
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
if (!isActive()) {
|
||||
IrisLogging.debug("Multiverse is not enabled; skipped unregistering \"" + worldName + "\".");
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
IrisLogging.debug("Multiverse is not available; skipped unregistering \""
|
||||
+ configuredWorldName + "\".");
|
||||
return false;
|
||||
}
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
MultiverseWorld multiverseWorld;
|
||||
try {
|
||||
multiverseWorld = resolve(manager, worldName);
|
||||
} catch (Throwable failure) {
|
||||
// Only a refusal Multiverse states is worth stopping a removal for; an unusable link is not.
|
||||
IrisLogging.warn("Could not look %s up in Multiverse: %s; its worlds.yml entry was left as-is.",
|
||||
worldName, describe(failure));
|
||||
return false;
|
||||
}
|
||||
WorldManager manager = worldManager();
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName);
|
||||
if (multiverseWorld == null) {
|
||||
IrisLogging.warn("Multiverse has no world registered as %s; its worlds.yml entry was left as-is.",
|
||||
worldName);
|
||||
@@ -83,26 +119,30 @@ public class MultiverseCoreLink {
|
||||
return remove(manager, multiverseWorld, worldName);
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
/**
|
||||
* Registers a freshly created world with Multiverse. Never throws: a Multiverse failure here used
|
||||
* to unwind {@code IrisCreator}, whose rollback deletes the world storage that was just built.
|
||||
*/
|
||||
public void updateWorld(World bukkitWorld, String configuredWorldName, String pack) {
|
||||
try {
|
||||
World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld");
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
if (!isActive()) {
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
if (!worldName.equals(world.getName())) {
|
||||
// Multiverse records the live name as legacy-world-name. A live name that is not the
|
||||
// startup name makes it re-import the world next boot and collide with its own config key.
|
||||
IrisLogging.warn("World %s is live as %s; Multiverse will record the live name.",
|
||||
IrisLogging.info("World %s is live as %s; Multiverse will record the live name.",
|
||||
worldName, world.getName());
|
||||
}
|
||||
String generator = "Iris:" + pack;
|
||||
WorldManager manager = worldManager();
|
||||
String generator = GENERATOR_PREFIX + Objects.requireNonNull(pack, "pack");
|
||||
MultiverseWorld multiverseWorld = manager.getWorld(world)
|
||||
.orElse(() -> manager.getWorld(worldName))
|
||||
.getOrElse(() -> {
|
||||
// Import through the live world so Multiverse binds its own config key to the key
|
||||
// Paper gave the world and records the startup name the world was created under.
|
||||
// Import through the live world so Multiverse binds its own config key to the
|
||||
// key Paper gave the world and records the startup name it was created under.
|
||||
ImportWorldOptions options = ImportWorldOptions.worldName(world.getName())
|
||||
.generator(generator)
|
||||
.environment(world.getEnvironment())
|
||||
@@ -110,28 +150,363 @@ public class MultiverseCoreLink {
|
||||
return manager.importWorld(options).get();
|
||||
});
|
||||
|
||||
multiverseWorld.setAutoLoad(false);
|
||||
if (!generator.equals(multiverseWorld.getGenerator())) {
|
||||
setWorldConfigString(multiverseWorld, "setGenerator", generator);
|
||||
applyIrisWorldSettings(multiverseWorld, generator);
|
||||
manager.saveWorldsConfig().get();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Multiverse registration failed for %s: %s; the world is unaffected.",
|
||||
String.valueOf(configuredWorldName), describe(failure));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-imposes Iris' intended Multiverse state on every world Iris owns.
|
||||
* <p>
|
||||
* Iris registers worlds it creates, but worlds reconciled out of bukkit.yml at boot are registered
|
||||
* by Multiverse's own auto-import instead, which defaults {@code auto-load} and {@code adjust-spawn}
|
||||
* on. Auto-load makes Multiverse try to load an Iris world every boot and log a SEVERE when Paper
|
||||
* has already loaded it, and adjust-spawn hands Multiverse the authority to relocate and persist a
|
||||
* spawn Iris chose.
|
||||
* <p>
|
||||
* The stored environment is corrected here too. Multiverse refuses to adopt a world whose live
|
||||
* environment differs from the one it stored, and an Iris world's live environment is not stable across a
|
||||
* restart: it is whatever the pack declares in the session the world was created or loaded in, and
|
||||
* {@code CUSTOM} once the server enumerates the level from {@code <levelRoot>/dimensions/iris/<key>} on
|
||||
* the next boot. A runtime {@code /mv load} therefore leaves Multiverse holding the pack's environment,
|
||||
* and this pass is what moves it back to the live one before adoption is attempted.
|
||||
* <p>
|
||||
* This is also where a world Iris already loaded is offered to Multiverse, which is what makes the
|
||||
* running world visible to every Multiverse command that needs a loaded one.
|
||||
*
|
||||
* @return the number of worlds whose Multiverse entry was corrected; adoption is reported separately
|
||||
* because it changes Multiverse's registry rather than the world's entry
|
||||
*/
|
||||
public int reconcileOwnedWorlds() {
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
return 0;
|
||||
}
|
||||
Map<String, String> owned;
|
||||
String levelName;
|
||||
try {
|
||||
levelName = IrisWorldStorage.levelRoot().getName();
|
||||
owned = IrisWorlds.get().getWorlds();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not read the Iris world registry for Multiverse reconciliation: %s",
|
||||
describe(failure));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int corrected = 0;
|
||||
int adopted = 0;
|
||||
for (Map.Entry<String, String> entry : owned.entrySet()) {
|
||||
NamespacedKey worldKey;
|
||||
String worldName;
|
||||
try {
|
||||
worldKey = WorldIdentity.parse(entry.getKey());
|
||||
worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName);
|
||||
} catch (Throwable ignored) {
|
||||
// Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
|
||||
if (multiverseWorld == null) {
|
||||
continue;
|
||||
}
|
||||
warnOnStaleRecordedName(multiverseWorld, worldName);
|
||||
boolean changed = applyIrisWorldEnvironment(
|
||||
multiverseWorld,
|
||||
ownedWorldEnvironment(worldKey, entry.getValue()));
|
||||
changed = applyIrisWorldSettings(multiverseWorld, GENERATOR_PREFIX + entry.getValue()) || changed;
|
||||
if (changed) {
|
||||
corrected++;
|
||||
}
|
||||
if (adoptLoadedWorld(manager, multiverseWorld, worldKey)) {
|
||||
adopted++;
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not correct the Multiverse entry for %s: %s",
|
||||
worldName, describe(failure));
|
||||
}
|
||||
}
|
||||
if (corrected > 0) {
|
||||
try {
|
||||
manager.saveWorldsConfig().get();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not save worlds.yml after Multiverse reconciliation: %s",
|
||||
describe(failure));
|
||||
}
|
||||
IrisLogging.info("Corrected %d Multiverse world entr%s.", corrected, corrected == 1 ? "y" : "ies");
|
||||
}
|
||||
if (adopted > 0) {
|
||||
IrisLogging.notice("Adopted %d live Iris world%s into Multiverse.", adopted, adopted == 1 ? "" : "s");
|
||||
}
|
||||
return corrected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points Multiverse's stored environment at the one Iris is about to create the world with, so the
|
||||
* adoption Multiverse runs from its own {@code WorldLoadEvent} handler does not fail on the mismatch.
|
||||
* <p>
|
||||
* Multiverse compares the stored environment to the live world's before it will take a world, and it
|
||||
* rewrites the stored value from the live world every time it binds one. An Iris world reports the
|
||||
* environment its pack declares in the session it was created and {@code CUSTOM} on every later boot, so
|
||||
* the stored value is only ever right for one of the two - and it is this call, plus the boot-time pass
|
||||
* in {@link #reconcileOwnedWorlds()}, that moves it between them.
|
||||
*/
|
||||
public void prepareOwnedWorldLoad(String configuredWorldName) {
|
||||
try {
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
String levelName = IrisWorldStorage.levelRoot().getName();
|
||||
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName);
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
|
||||
if (multiverseWorld == null) {
|
||||
return;
|
||||
}
|
||||
if (applyIrisWorldEnvironment(
|
||||
multiverseWorld,
|
||||
ownedWorldEnvironment(worldKey, ownedWorldPack(worldKey)))) {
|
||||
manager.saveWorldsConfig().get();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not prepare the Multiverse entry for %s: %s",
|
||||
String.valueOf(configuredWorldName), describe(failure));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers a world Iris has just loaded to Multiverse, importing it when Multiverse has no entry at all.
|
||||
*
|
||||
* @return true when Multiverse now tracks the world as loaded
|
||||
*/
|
||||
public boolean adoptOwnedWorld(String configuredWorldName) {
|
||||
try {
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
return false;
|
||||
}
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
String levelName = IrisWorldStorage.levelRoot().getName();
|
||||
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName);
|
||||
World live = WorldIdentity.resolve(worldKey).orElse(null);
|
||||
if (live == null) {
|
||||
return false;
|
||||
}
|
||||
String pack = ownedWorldPack(worldKey);
|
||||
MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
|
||||
if (multiverseWorld == null) {
|
||||
if (pack == null) {
|
||||
return false;
|
||||
}
|
||||
updateWorld(live, worldName, pack);
|
||||
return true;
|
||||
}
|
||||
boolean changed = applyIrisWorldEnvironment(multiverseWorld, live.getEnvironment());
|
||||
changed = applyIrisWorldSettings(multiverseWorld, pack == null ? null : GENERATOR_PREFIX + pack)
|
||||
|| changed;
|
||||
boolean adopted = adoptLoadedWorld(manager, multiverseWorld, worldKey);
|
||||
if (changed || adopted) {
|
||||
manager.saveWorldsConfig().get();
|
||||
}
|
||||
return adopted;
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not hand %s to Multiverse: %s",
|
||||
String.valueOf(configuredWorldName), describe(failure));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String ownedWorldPack(NamespacedKey worldKey) {
|
||||
try {
|
||||
return IrisWorlds.get().getWorlds().get(worldKey.toString());
|
||||
} catch (Throwable unreadable) {
|
||||
IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": "
|
||||
+ describe(unreadable));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a live Iris world into Multiverse's loaded registry.
|
||||
* <p>
|
||||
* Iris turns {@code auto-load} off and loads its worlds out of bukkit.yml before Multiverse enables, so
|
||||
* Multiverse never adopts them: {@code mv list} calls a running world UNLOADED and every command that
|
||||
* needs a loaded world - {@code mv info}, {@code mv gamerule} - refuses it.
|
||||
* <p>
|
||||
* Multiverse's {@code loadWorld} short-circuits to {@code Bukkit.getWorld(<recorded name>)} when that
|
||||
* world already exists and binds it without going near a WorldCreator. That short circuit is the whole
|
||||
* safety property here: without a live world under the name Multiverse recorded, the same call would
|
||||
* build one over Iris storage instead, so the world is looked up and identified first and adoption is
|
||||
* skipped whenever it is not the target.
|
||||
*
|
||||
* @return true when Multiverse took the world
|
||||
*/
|
||||
boolean adoptLoadedWorld(WorldManager manager, MultiverseWorld multiverseWorld, NamespacedKey worldKey) {
|
||||
if (manager.isLoadedWorld(multiverseWorld)) {
|
||||
return false;
|
||||
}
|
||||
World live = Bukkit.getWorld(multiverseWorld.getName());
|
||||
if (live == null || !worldKey.equals(WorldIdentity.key(live))) {
|
||||
return false;
|
||||
}
|
||||
Attempt<LoadedMultiverseWorld, LoadFailureReason> adoption =
|
||||
manager.loadWorld(LoadWorldOptions.world(multiverseWorld));
|
||||
if (adoption.isFailure()) {
|
||||
IrisLogging.warn("Multiverse would not adopt live world %s: %s",
|
||||
multiverseWorld.getName(), describeFailure(adoption));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bukkit environment an Iris-owned world is or will be live with, or null when it cannot be
|
||||
* determined.
|
||||
* <p>
|
||||
* A live world already carries it, and it is the value Multiverse compares its own against before it will
|
||||
* adopt the world, so it wins whenever there is one. With no live world the pack's dimension answers it -
|
||||
* the same {@code BukkitEnvironment.from(dimension.getEnvironment())} Iris' own load path passes to the
|
||||
* WorldCreator - so a world about to be loaded gets the environment it is about to have, not a guess at
|
||||
* what Multiverse wants to hear.
|
||||
*/
|
||||
World.Environment ownedWorldEnvironment(NamespacedKey worldKey, String pack) {
|
||||
World live = WorldIdentity.resolve(worldKey).orElse(null);
|
||||
if (live != null) {
|
||||
return live.getEnvironment();
|
||||
}
|
||||
try {
|
||||
IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack);
|
||||
return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment());
|
||||
} catch (Throwable unreadable) {
|
||||
IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects the environment Multiverse stored for an Iris world.
|
||||
* <p>
|
||||
* Multiverse compares the stored value to the live world's and refuses to adopt the world when they
|
||||
* differ, and it rewrites the stored value from the live world every time it binds one. An Iris world's
|
||||
* live environment flips between the pack's own and {@code CUSTOM} across a restart, so this is the only
|
||||
* thing that keeps the two in step; without it {@code mv list} reports a running world as unloaded and
|
||||
* every command that needs a loaded one refuses it.
|
||||
*
|
||||
* @return true when something was changed
|
||||
*/
|
||||
boolean applyIrisWorldEnvironment(MultiverseWorld multiverseWorld, World.Environment environment) {
|
||||
if (environment == null || environment == multiverseWorld.getEnvironment()) {
|
||||
return false;
|
||||
}
|
||||
return setWorldConfigValue(multiverseWorld, "setEnvironment", World.Environment.class, environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-imposes Iris ownership on one Multiverse entry. Iris drives loading through bukkit.yml and owns
|
||||
* the spawn, so Multiverse must neither load the world nor move its spawn.
|
||||
*
|
||||
* @return true when something was changed
|
||||
*/
|
||||
boolean applyIrisWorldSettings(MultiverseWorld multiverseWorld, String generator) {
|
||||
boolean changed = false;
|
||||
if (multiverseWorld.isAutoLoad()) {
|
||||
multiverseWorld.setAutoLoad(false);
|
||||
changed = true;
|
||||
}
|
||||
if (multiverseWorld.getAdjustSpawn()) {
|
||||
multiverseWorld.setAdjustSpawn(false);
|
||||
changed = true;
|
||||
}
|
||||
if (generator != null && !generator.equals(multiverseWorld.getGenerator())) {
|
||||
changed = setWorldConfigValue(multiverseWorld, "setGenerator", String.class, generator) || changed;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse tracks the world name it saw at import and indexes its store by it. Iris cannot rewrite
|
||||
* that name without desynchronising Multiverse's own name index, so a mismatch is reported instead.
|
||||
*/
|
||||
void warnOnStaleRecordedName(MultiverseWorld multiverseWorld, String configuredWorldName) {
|
||||
String recorded = multiverseWorld.getName();
|
||||
if (configuredWorldName.equals(recorded)) {
|
||||
return;
|
||||
}
|
||||
IrisLogging.warn("Multiverse records %s as %s; remove that worlds.yml entry so Iris can re-register it.",
|
||||
configuredWorldName, recorded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a Bukkit world name names a persistent world Iris owns on disk. Never throws, so a
|
||||
* name Iris has no opinion about simply answers false.
|
||||
*/
|
||||
public static boolean isIrisOwnedWorldName(String worldName, File levelRoot) {
|
||||
if (worldName == null || levelRoot == null) {
|
||||
return false;
|
||||
}
|
||||
String levelName = levelRoot.getName();
|
||||
NamespacedKey key;
|
||||
try {
|
||||
key = IrisWorldStorage.managedKeyFromName(worldName.trim(), levelName);
|
||||
} catch (RuntimeException notAnIrisName) {
|
||||
return false;
|
||||
}
|
||||
return IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, key);
|
||||
}
|
||||
|
||||
private boolean remove(WorldManager manager, MultiverseWorld multiverseWorld, String worldName) {
|
||||
Attempt<String, RemoveFailureReason> removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
|
||||
Attempt<String, RemoveFailureReason> removal = manager.removeWorld(removalOptions(multiverseWorld));
|
||||
if (removal.isFailure()) {
|
||||
throw new IllegalStateException("Multiverse refused to remove world \"" + worldName + "\": "
|
||||
+ removal.getFailureMessage());
|
||||
+ describeFailure(removal));
|
||||
}
|
||||
manager.saveWorldsConfig().get();
|
||||
return true;
|
||||
}
|
||||
|
||||
private MultiverseWorld resolve(WorldManager manager, String worldName) {
|
||||
for (String candidate : lookupNames(worldName, IrisWorldStorage.levelRoot().getName())) {
|
||||
/**
|
||||
* Removal options for one Multiverse world.
|
||||
* <p>
|
||||
* Iris evacuates, unloads and closes a world before it unregisters it, but Multiverse still holds it in
|
||||
* its loaded registry and its unload-before-remove dereferences the Bukkit world that is already gone,
|
||||
* failing the whole removal. Multiverse only owns the unload while the Bukkit world is genuinely live.
|
||||
*/
|
||||
static RemoveWorldOptions removalOptions(MultiverseWorld multiverseWorld) {
|
||||
RemoveWorldOptions options = RemoveWorldOptions.world(multiverseWorld);
|
||||
if (hasLiveBukkitWorld(multiverseWorld)) {
|
||||
return options;
|
||||
}
|
||||
return options.unloadBukkitWorld(false).saveBukkitWorld(false);
|
||||
}
|
||||
|
||||
private static boolean hasLiveBukkitWorld(MultiverseWorld multiverseWorld) {
|
||||
LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
|
||||
return loaded != null && loaded.getBukkitWorld().getOrNull() != null;
|
||||
}
|
||||
|
||||
MultiverseWorld resolve(WorldManager manager, String worldName) {
|
||||
return resolve(manager, worldName, IrisWorldStorage.levelRoot().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* The Multiverse entry for one Iris world, under whichever of its names Multiverse currently holds.
|
||||
* <p>
|
||||
* A name other than the one asked for only identifies the same world when Multiverse keys its entry by
|
||||
* the Iris world key, so an entry sitting on the runtime name under some other key is never returned:
|
||||
* a removal must not delete a world that is not its target.
|
||||
*/
|
||||
MultiverseWorld resolve(WorldManager manager, String worldName, String levelName) {
|
||||
NamespacedKey expectedKey = managedKeyOrNull(worldName, levelName);
|
||||
for (String candidate : lookupNames(worldName, levelName)) {
|
||||
MultiverseWorld multiverseWorld = manager.getWorld(candidate).getOrElse((MultiverseWorld) null);
|
||||
if (multiverseWorld != null) {
|
||||
if (multiverseWorld == null) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.equals(worldName) || expectedKey != null && expectedKey.equals(multiverseWorld.getKey())) {
|
||||
return multiverseWorld;
|
||||
}
|
||||
}
|
||||
@@ -139,32 +514,83 @@ public class MultiverseCoreLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* worlds.yml files written by Iris builds that created keyed worlds under {@code <namespace>_<key>}
|
||||
* still carry that name, so a startup name must also resolve through the Multiverse config key it
|
||||
* encodes or those entries can never be removed.
|
||||
* Every name one Iris world can be registered under in Multiverse: the Bukkit startup name Iris
|
||||
* registers, the Multiverse config key, and the {@code <namespace>_<key>} runtime name a world gets
|
||||
* when Multiverse re-creates it from a keyed WorldCreator. Without all three an entry Multiverse
|
||||
* reloaded under a different form can never be removed.
|
||||
*/
|
||||
static List<String> lookupNames(String worldName, String levelName) {
|
||||
List<String> candidates = new ArrayList<>(2);
|
||||
List<String> candidates = new ArrayList<>(3);
|
||||
candidates.add(worldName);
|
||||
try {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(worldName, levelName);
|
||||
if (IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) {
|
||||
NamespacedKey key = managedKeyOrNull(worldName, levelName);
|
||||
if (key != null && IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) {
|
||||
candidates.add(key.toString());
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Not an Iris startup name; the plain name is the only identity Multiverse can have.
|
||||
candidates.add(key.getNamespace() + "_" + key.getKey());
|
||||
}
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
|
||||
private static void setWorldConfigString(MultiverseWorld world, String setter, String value) throws Exception {
|
||||
private static NamespacedKey managedKeyOrNull(String worldName, String levelName) {
|
||||
try {
|
||||
return IrisWorldStorage.managedKeyFromName(worldName, levelName);
|
||||
} catch (IllegalArgumentException notAnIrisName) {
|
||||
// Not an Iris startup name; the plain name is the only identity Multiverse can have.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code WorldConfig} and its setters are package-private in Multiverse, so its stored values can only
|
||||
* be corrected reflectively. A Multiverse release that renames either must degrade to leaving the value
|
||||
* alone rather than taking down whatever asked for the write.
|
||||
*/
|
||||
private static boolean setWorldConfigValue(
|
||||
MultiverseWorld world,
|
||||
String setter,
|
||||
Class<?> parameterType,
|
||||
Object value
|
||||
) {
|
||||
if (worldConfigUnwritable) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Field field = MultiverseWorld.class.getDeclaredField("worldConfig");
|
||||
field.setAccessible(true);
|
||||
|
||||
Object config = field.get(world);
|
||||
Method method = config.getClass().getDeclaredMethod(setter, String.class);
|
||||
Method method = config.getClass().getDeclaredMethod(setter, parameterType);
|
||||
method.setAccessible(true);
|
||||
method.invoke(config, value);
|
||||
return true;
|
||||
} catch (ReflectiveOperationException | RuntimeException | Error failure) {
|
||||
worldConfigUnwritable = true;
|
||||
// A probe on a private Multiverse field. It is latched, the fallback is Multiverse's own
|
||||
// handling of the setting, and nothing an operator can do changes the answer.
|
||||
IrisLogging.info("Multiverse WorldConfig.%s is not reachable; Iris world settings are left to Multiverse.",
|
||||
setter);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Multiverse failure reason as text. {@code getFailureMessage} is a Multiverse {@code Message}, whose
|
||||
* rendering goes through a locale manager that is not installed in every teardown order, so the reason
|
||||
* name is the fallback rather than an object identity string.
|
||||
*/
|
||||
private static String describeFailure(Attempt<?, ? extends FailureReason> attempt) {
|
||||
Message message = attempt.getFailureMessage();
|
||||
if (message != null) {
|
||||
try {
|
||||
String formatted = message.formatted();
|
||||
if (formatted != null && !formatted.isBlank()) {
|
||||
return formatted;
|
||||
}
|
||||
} catch (RuntimeException | Error unrenderable) {
|
||||
// Fall through to the reason name.
|
||||
}
|
||||
}
|
||||
FailureReason reason = attempt.getFailureReason();
|
||||
return reason == null ? "no reason given" : reason.toString();
|
||||
}
|
||||
|
||||
private static String requireWorldName(String worldName) {
|
||||
@@ -175,12 +601,38 @@ public class MultiverseCoreLink {
|
||||
return name;
|
||||
}
|
||||
|
||||
private WorldManager worldManager() {
|
||||
MultiverseCoreApi api = MultiverseCoreApi.get();
|
||||
return api.getWorldManager();
|
||||
private static String describe(Throwable failure) {
|
||||
String message = failure.getMessage();
|
||||
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private boolean isActive() {
|
||||
return Bukkit.getPluginManager().isPluginEnabled("Multiverse-Core");
|
||||
/**
|
||||
* The world manager, or null when Multiverse cannot serve one. Plugin enablement alone is not enough:
|
||||
* the API singleton is installed during Multiverse's own enable, and Iris is asked for generators
|
||||
* before that finishes.
|
||||
*/
|
||||
WorldManager worldManager() {
|
||||
if (!isActive()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MultiverseCoreApi api = MultiverseCoreApi.get();
|
||||
return api.getWorldManager();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
IrisLogging.debug("Multiverse world manager is unavailable: " + describe(failure));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
if (!Bukkit.getPluginManager().isPluginEnabled(MULTIVERSE_PLUGIN)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return MultiverseCoreApi.isLoaded();
|
||||
} catch (Error missingApi) {
|
||||
// Multiverse older than 5.1 has no readiness probe; enablement is the only signal there is.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.ConsoleCommandSender;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.server.ServerCommandEvent;
|
||||
import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent;
|
||||
import org.mvplugins.multiverse.core.event.world.MVWorldImportedEvent;
|
||||
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.MultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.WorldManager;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
||||
/**
|
||||
* Keeps Multiverse's destructive world commands away from Iris storage.
|
||||
* <p>
|
||||
* An Iris world folder is not a vanilla world folder: {@code <level>/dimensions/iris/<key>} also holds
|
||||
* {@code iris/pack}, the frozen pack snapshot the world is generated from, and {@code iris/engine-data}.
|
||||
* Multiverse deletes a world by recursively deleting its Bukkit world folder, and its {@code --keep-files}
|
||||
* only matches file basenames, so a pack directory can never survive. The delete leaves the world, the
|
||||
* pack and the Multiverse entry gone while bukkit.yml and Iris' worlds.json still point at them, and a
|
||||
* half-deleted tree reads back as an owned-but-broken world.
|
||||
* <p>
|
||||
* This listener is only registered while Multiverse is enabled. Every handler here names a Multiverse
|
||||
* type, so registering it without Multiverse on the classpath would fail at handler resolution.
|
||||
*/
|
||||
public final class MultiverseGuardListener implements Listener {
|
||||
private static final String IRIS_REMOVE_HINT = "Use /iris remove world=%s delete=true instead.";
|
||||
private static final String MULTIVERSE_LOAD_PERMISSION = "multiverse.core.load";
|
||||
private final MultiverseCoreLink link;
|
||||
/**
|
||||
* Multiverse world events carry no command sender, and its own cancellation message is the generic
|
||||
* failure string, so the sender who typed the command has to be remembered across the dispatch. The
|
||||
* capture is consumed on first read so a later API-driven delete cannot message an unrelated player.
|
||||
*/
|
||||
private volatile WeakReference<CommandSender> multiverseCommandSender;
|
||||
|
||||
public MultiverseGuardListener(MultiverseCoreLink link) {
|
||||
this.link = Objects.requireNonNull(link, "link");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels {@code mv delete} and {@code mv regen} for Iris worlds.
|
||||
* <p>
|
||||
* Both commands funnel through {@code WorldManager#doDeleteWorld}, which fires this event after
|
||||
* resolving the world folder but before it unloads the world or touches disk, so a cancel here is
|
||||
* free of side effects. Regen is refused with delete because the event cannot tell them apart, and
|
||||
* because a regen Iris did not drive would have Multiverse re-create the world through its own
|
||||
* {@code CreateWorldOptions} rather than through Iris storage.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onMultiverseWorldDelete(MVWorldDeleteEvent event) {
|
||||
MultiverseWorld multiverseWorld = event.getWorld();
|
||||
String worldName = multiverseWorld.getName();
|
||||
if (!isIrisWorld(multiverseWorld)) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
String irisName = irisCommandName(multiverseWorld, worldName);
|
||||
refuse(consumeCommandSender(),
|
||||
"Multiverse cannot delete or regenerate Iris world " + worldName
|
||||
+ "; it would destroy the world-local pack snapshot.",
|
||||
String.format(IRIS_REMOVE_HINT, irisName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse auto-imports every world it finds that is not already in worlds.yml, using its own
|
||||
* defaults, so an Iris world reconciled out of bukkit.yml arrives with {@code auto-load} and
|
||||
* {@code adjust-spawn} on. Iris drives its own loading and owns the spawn, so both are turned back off.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onMultiverseWorldImported(MVWorldImportedEvent event) {
|
||||
MultiverseWorld multiverseWorld = event.getWorld();
|
||||
if (!isIrisWorld(multiverseWorld)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!link.applyIrisWorldSettings(multiverseWorld, null)) {
|
||||
return;
|
||||
}
|
||||
WorldManager manager = link.worldManager();
|
||||
if (manager != null) {
|
||||
manager.saveWorldsConfig().get();
|
||||
}
|
||||
IrisLogging.debug("Reset Multiverse auto-load and adjust-spawn for " + multiverseWorld.getName());
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.warn("Could not apply Iris settings to the Multiverse import of %s: %s",
|
||||
multiverseWorld.getName(), failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
|
||||
if (inspectCommand(event.getPlayer(), event.getMessage())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void onServerCommand(ServerCommandEvent event) {
|
||||
if (inspectCommand(event.getSender(), event.getCommand())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the sender of a Multiverse command and vetoes a clone that reads from, or writes over, Iris
|
||||
* storage.
|
||||
* <p>
|
||||
* Multiverse fires no cancellable event for a clone: it copies the source world folder first and only
|
||||
* then imports the copy, and the import of an Iris copy always fails. That leaves an orphaned copy of
|
||||
* the world - frozen pack included - under {@code <level>/dimensions/minecraft/<name>}, which
|
||||
* Multiverse then advertises as importable, so a later plain {@code mv import} would build a vanilla
|
||||
* world on top of Iris region files. The command is the only place left to stop it.
|
||||
* <p>
|
||||
* The destination is checked as well. Multiverse decides a name is free with its own
|
||||
* {@code hasWorldFolder} check, which resolves the vanilla {@code <container>/<name>} path and never
|
||||
* looks in the Iris dimension namespace, so a clone can be aimed straight at a live Iris world's slot.
|
||||
*
|
||||
* @return true when the command must be cancelled
|
||||
*/
|
||||
private boolean inspectCommand(CommandSender sender, String commandLine) {
|
||||
if (multiverseCommandRoot(commandLine) == null) {
|
||||
return false;
|
||||
}
|
||||
multiverseCommandSender = new WeakReference<>(sender);
|
||||
String load = multiverseLoad(commandLine);
|
||||
if (load != null) {
|
||||
return interceptLoad(sender, load);
|
||||
}
|
||||
CloneCommand clone = multiverseClone(commandLine);
|
||||
if (clone == null) {
|
||||
return false;
|
||||
}
|
||||
if (isIrisWorld(clone.source())) {
|
||||
refuse(sender,
|
||||
"Multiverse cannot clone Iris world " + clone.source()
|
||||
+ "; its pack snapshot is world-local and the copy is not a loadable world.",
|
||||
"Create another Iris world with /iris create <name> type=<pack> instead.");
|
||||
return true;
|
||||
}
|
||||
if (clone.destination() != null && isIrisWorld(clone.destination())) {
|
||||
refuse(sender,
|
||||
"Multiverse cannot clone onto Iris world " + clone.destination()
|
||||
+ "; that name already owns Iris world storage.",
|
||||
"Choose another destination name, or free it with /iris remove world="
|
||||
+ clone.destination() + " delete=true.");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over {@code /mv load} for an Iris world Iris can load itself.
|
||||
* <p>
|
||||
* Multiverse only adopts a world it finds already in Bukkit; otherwise it rebuilds one from a
|
||||
* WorldCreator carrying the environment it stored. Neither half of that works for an Iris world. The
|
||||
* stored environment is written from the live world every time Multiverse binds it, and a custom
|
||||
* dimension reports {@code NORMAL} in the session it was created and {@code CUSTOM} on every later boot,
|
||||
* so {@code CraftServer.createWorld} refuses it with "Illegal dimension (CUSTOM)". Writing {@code normal}
|
||||
* instead only trades that for the other failure: Multiverse would build a vanilla world beside the level
|
||||
* root under the Iris world's name, and the next boot's adoption would fail on the mismatch.
|
||||
* <p>
|
||||
* So the load is handed to Iris, which creates the world from a keyed creator at
|
||||
* {@code <levelRoot>/dimensions/iris/<key>} with the pack's own environment, and the result is offered
|
||||
* back to Multiverse. With no loader installed the command is refused rather than passed through, because
|
||||
* passing it through is the failure this exists to prevent.
|
||||
*
|
||||
* @return true when the command must be cancelled
|
||||
*/
|
||||
private boolean interceptLoad(CommandSender sender, String worldNameOrAlias) {
|
||||
String worldName = irisWorldName(worldNameOrAlias);
|
||||
if (worldName == null || Bukkit.getWorld(worldName) != null) {
|
||||
// A live world is adopted by Multiverse without a WorldCreator; that path already works.
|
||||
return false;
|
||||
}
|
||||
if (sender != null && !sender.hasPermission(MULTIVERSE_LOAD_PERMISSION)) {
|
||||
// Taking the command over must never load a world for someone Multiverse would have refused.
|
||||
return false;
|
||||
}
|
||||
ManagedWorldLoader loader = IrisServices.getOrNull(ManagedWorldLoader.class);
|
||||
if (loader == null) {
|
||||
refuse(sender,
|
||||
"Iris owns loading for " + worldName + "; Multiverse would build a vanilla world in its place.",
|
||||
"Load it with /iris load " + worldName + ", or restart the server.");
|
||||
return true;
|
||||
}
|
||||
link.prepareOwnedWorldLoad(worldName);
|
||||
IrisLogging.info("Multiverse load of %s handed to Iris.", worldName);
|
||||
WeakReference<CommandSender> target = new WeakReference<>(sender);
|
||||
try {
|
||||
// The load settles off the main thread, and handing the result to Multiverse fires a Bukkit
|
||||
// event, so the whole tail runs back on the main thread exactly as /iris load's does.
|
||||
loader.load(worldName).whenComplete((result, failure) ->
|
||||
J.s(() -> settleLoad(target.get(), worldName, result, failure)));
|
||||
} catch (Throwable failure) {
|
||||
refuse(sender, "Iris could not load " + worldName + ": " + describe(failure),
|
||||
"Load it with /iris load " + worldName + ", or restart the server.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void settleLoad(
|
||||
CommandSender sender,
|
||||
String worldName,
|
||||
ManagedWorldLoader.ManagedWorldLoad result,
|
||||
Throwable failure
|
||||
) {
|
||||
if (failure != null || result == null || !result.loaded()) {
|
||||
String detail = failure != null
|
||||
? describe(failure)
|
||||
: result == null ? "the load completed without a result" : result.message();
|
||||
refuse(sender, "Iris could not load " + worldName + ": " + detail,
|
||||
"Check the Iris log, then retry with /iris load " + worldName + ".");
|
||||
return;
|
||||
}
|
||||
boolean adopted = link.adoptOwnedWorld(worldName);
|
||||
IrisLogging.info("Iris loaded %s; multiverse adopted=%s.", worldName, adopted);
|
||||
announce(sender, "Iris loaded " + worldName + ".");
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bukkit startup name of the Iris world a Multiverse world argument names, or null when it names no
|
||||
* Iris world. Multiverse accepts its own alias, so the argument is resolved through Multiverse first and
|
||||
* the name Multiverse recorded is what Iris is asked for.
|
||||
*/
|
||||
private String irisWorldName(String worldNameOrAlias) {
|
||||
if (isIrisOwnedName(worldNameOrAlias)) {
|
||||
return startupName(worldNameOrAlias);
|
||||
}
|
||||
WorldManager manager = link.worldManager();
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias)
|
||||
.getOrElse((MultiverseWorld) null);
|
||||
if (multiverseWorld == null) {
|
||||
return null;
|
||||
}
|
||||
String recorded = multiverseWorld.getName();
|
||||
return isIrisOwnedName(recorded) ? startupName(recorded) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bukkit startup name for an Iris world named in any of the forms Iris answers to - the startup name
|
||||
* itself, the bare key, or {@code iris:<key>} - so the live-world check and the loader both see the one
|
||||
* name the world is actually registered under.
|
||||
*/
|
||||
private static String startupName(String worldName) {
|
||||
try {
|
||||
String levelName = IrisWorldStorage.levelRoot().getName();
|
||||
return IrisWorldStorage.configuredWorldName(
|
||||
IrisWorldStorage.managedKeyFromName(worldName, levelName),
|
||||
levelName);
|
||||
} catch (Throwable notAnIrisName) {
|
||||
return worldName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The world argument of a Multiverse load command, or null when the line is not one. Syntax is
|
||||
* {@code /mv load <world> [flags]} with the legacy alias {@code /mvload}.
|
||||
*/
|
||||
static String multiverseLoad(String commandLine) {
|
||||
String root = multiverseCommandRoot(commandLine);
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
String line = commandLine.trim();
|
||||
if (line.startsWith("/")) {
|
||||
line = line.substring(1);
|
||||
}
|
||||
String[] parts = line.split("\\s+");
|
||||
if ("mv".equals(root)) {
|
||||
return parts.length >= 3 && "load".equalsIgnoreCase(parts[1]) ? worldArgument(parts, 2) : null;
|
||||
}
|
||||
if ("mvload".equals(root)) {
|
||||
return parts.length >= 2 ? worldArgument(parts, 1) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Multiverse command root, or null when the line is not one. Multiverse registers {@code /mv} plus
|
||||
* per-command legacy aliases; anything Iris does not recognise is left alone, which is the behaviour a
|
||||
* server without this listener already has.
|
||||
*/
|
||||
static String multiverseCommandRoot(String commandLine) {
|
||||
if (commandLine == null) {
|
||||
return null;
|
||||
}
|
||||
String line = commandLine.trim();
|
||||
if (line.startsWith("/")) {
|
||||
line = line.substring(1);
|
||||
}
|
||||
int firstSpace = line.indexOf(' ');
|
||||
String root = (firstSpace < 0 ? line : line.substring(0, firstSpace)).toLowerCase(Locale.ENGLISH);
|
||||
// Bukkit always accepts the "plugin:command" form as well.
|
||||
int namespace = root.lastIndexOf(':');
|
||||
if (namespace >= 0) {
|
||||
root = root.substring(namespace + 1);
|
||||
}
|
||||
// Every Multiverse alias starts with "mv". Matching the prefix rather than an enumeration keeps a
|
||||
// new legacy alias from silently escaping the guard; a false match only stores a sender reference
|
||||
// that nothing but a Multiverse world event ever reads.
|
||||
return root.startsWith("mv") ? root : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source and destination world arguments of a Multiverse clone command, or null when the line is
|
||||
* not one. Syntax is {@code /mv clone <world> <new-world-name> [flags]} with the legacy aliases
|
||||
* {@code /mvcl} and {@code /mvclone}. The destination is null when the admin has not typed it yet.
|
||||
*/
|
||||
static CloneCommand multiverseClone(String commandLine) {
|
||||
String root = multiverseCommandRoot(commandLine);
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
String line = commandLine.trim();
|
||||
if (line.startsWith("/")) {
|
||||
line = line.substring(1);
|
||||
}
|
||||
String[] parts = line.split("\\s+");
|
||||
int worldIndex;
|
||||
if ("mv".equals(root)) {
|
||||
if (parts.length < 3 || !"clone".equalsIgnoreCase(parts[1])) {
|
||||
return null;
|
||||
}
|
||||
worldIndex = 2;
|
||||
} else if ("mvcl".equals(root) || "mvclone".equals(root)) {
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
worldIndex = 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
String source = worldArgument(parts, worldIndex);
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
return new CloneCommand(source, worldArgument(parts, worldIndex + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* A world-name argument, or null when it is absent or a flag. Multiverse quotes names containing
|
||||
* spaces; the guard only needs the leading token, so the opening quote is dropped and the rest kept.
|
||||
*/
|
||||
private static String worldArgument(String[] parts, int index) {
|
||||
if (index >= parts.length) {
|
||||
return null;
|
||||
}
|
||||
String world = parts[index];
|
||||
if (world.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
if (world.startsWith("\"")) {
|
||||
world = world.substring(1);
|
||||
}
|
||||
return world.isEmpty() ? null : world;
|
||||
}
|
||||
|
||||
record CloneCommand(String source, String destination) {
|
||||
CloneCommand {
|
||||
Objects.requireNonNull(source, "source");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIrisWorld(MultiverseWorld multiverseWorld) {
|
||||
LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
|
||||
World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull();
|
||||
if (bukkitWorld != null && IrisToolbelt.isIrisWorld(bukkitWorld)) {
|
||||
return true;
|
||||
}
|
||||
return isIrisOwnedName(multiverseWorld.getName());
|
||||
}
|
||||
|
||||
private boolean isIrisWorld(String worldNameOrAlias) {
|
||||
World live = Bukkit.getWorld(worldNameOrAlias);
|
||||
if (live != null && IrisToolbelt.isIrisWorld(live)) {
|
||||
return true;
|
||||
}
|
||||
WorldManager manager = link.worldManager();
|
||||
if (manager != null) {
|
||||
MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias)
|
||||
.getOrElse((MultiverseWorld) null);
|
||||
if (multiverseWorld != null) {
|
||||
return isIrisWorld(multiverseWorld);
|
||||
}
|
||||
}
|
||||
return isIrisOwnedName(worldNameOrAlias);
|
||||
}
|
||||
|
||||
private static boolean isIrisOwnedName(String worldName) {
|
||||
try {
|
||||
return MultiverseCoreLink.isIrisOwnedWorldName(worldName, IrisWorldStorage.levelRoot());
|
||||
} catch (Throwable unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The name {@code /iris remove} accepts. Multiverse reports the name it recorded at import, which is
|
||||
* the Bukkit startup name for every world Iris registered.
|
||||
*/
|
||||
private static String irisCommandName(MultiverseWorld multiverseWorld, String fallback) {
|
||||
LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
|
||||
World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull();
|
||||
return bukkitWorld == null ? fallback : bukkitWorld.getName();
|
||||
}
|
||||
|
||||
private CommandSender consumeCommandSender() {
|
||||
WeakReference<CommandSender> captured = multiverseCommandSender;
|
||||
multiverseCommandSender = null;
|
||||
return captured == null ? null : captured.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse reports a cancelled destructive command as its generic failure string, so the refusal has
|
||||
* to reach the sender from here or the admin only sees "something went wrong".
|
||||
*/
|
||||
private static void refuse(CommandSender sender, String reason, String remedy) {
|
||||
// The refusal is the guard working, and the admin who typed the command is told directly. This is
|
||||
// the console's copy of the same sentence, not a warning about the state of the server.
|
||||
IrisLogging.info("%s %s", reason, remedy);
|
||||
announce(sender, reason, remedy);
|
||||
}
|
||||
|
||||
private static void announce(CommandSender sender, String... lines) {
|
||||
if (sender == null || sender instanceof ConsoleCommandSender) {
|
||||
// The console already saw it through the Iris log.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
VolmitSender target = new VolmitSender(sender);
|
||||
for (String line : lines) {
|
||||
target.sendMessage(line);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
for (String line : lines) {
|
||||
sender.sendMessage(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(Throwable failure) {
|
||||
Throwable cause = failure instanceof CompletionException && failure.getCause() != null
|
||||
? failure.getCause()
|
||||
: failure;
|
||||
String message = cause.getMessage();
|
||||
return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
|
||||
}
|
||||
}
|
||||
@@ -193,10 +193,6 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
return m;
|
||||
}
|
||||
|
||||
private static void printData(ResourceLoader<?> rl) {
|
||||
IrisLogging.warn(" " + rl.getResourceTypeName() + " @ /" + rl.getFolderName() + ": Cache=" + rl.getLoadCache().getSize() + " Folders=" + rl.getFolders().size());
|
||||
}
|
||||
|
||||
public static IrisObject loadAnyObject(String key, @Nullable IrisData nearest) {
|
||||
return loadAny(IrisObject.class, key, nearest);
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ public class IrisPregenerator {
|
||||
if (failedCount > 0) {
|
||||
IrisLogging.warn("Pregen finished with " + Form.f(failedCount) + " failed chunk(s); failures are not cached, rerun to fill them");
|
||||
}
|
||||
IrisLogging.info("Pregen finished: generated=" + Form.f(generated.get())
|
||||
IrisLogging.notice("Pregen finished: generated=" + Form.f(generated.get())
|
||||
+ " total=" + Form.f(totalChunks.get())
|
||||
+ " failed=" + Form.f(failedCount)
|
||||
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds())
|
||||
|
||||
@@ -39,7 +39,9 @@ public final class MantleHeapPressure {
|
||||
System::currentTimeMillis,
|
||||
System::gc,
|
||||
() -> invokeHotSpotDiagnosticGc(ManagementFactory.getPlatformMBeanServer()),
|
||||
(double fraction) -> IrisLogging.warn(
|
||||
// An escalation step in the reclaim policy, rate limited by that policy rather than by
|
||||
// anything the operator did. What is actionable is the wait budget being exceeded.
|
||||
(double fraction) -> IrisLogging.info(
|
||||
"Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.",
|
||||
fraction * 100.0D),
|
||||
(String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
|
||||
|
||||
@@ -102,7 +102,9 @@ public final class PregenMantleBackpressure {
|
||||
long logNow = M.ms();
|
||||
if (logNow - lastLog >= 5_000L) {
|
||||
lastLog = logNow;
|
||||
IrisLogging.warn("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap
|
||||
// Pausing to stay under the plate cap is the design working, and it is reported every five
|
||||
// seconds for the whole duration of a large pregen. Only exceeding the budget is a warning.
|
||||
IrisLogging.info("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap
|
||||
+ "), freed " + freed + " last pass, waited " + elapsed + "ms.");
|
||||
}
|
||||
|
||||
@@ -147,7 +149,7 @@ public final class PregenMantleBackpressure {
|
||||
long logNow = M.ms();
|
||||
if (logNow - lastLog >= 5_000L) {
|
||||
lastLog = logNow;
|
||||
IrisLogging.warn("Pregen heap pressure: pausing generation at "
|
||||
IrisLogging.info("Pregen heap pressure: pausing generation at "
|
||||
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom"
|
||||
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
|
||||
}
|
||||
|
||||
+25
-2
@@ -40,6 +40,7 @@ public class PregenCacheImpl implements PregenCache {
|
||||
|
||||
private final File directory;
|
||||
private final int maxSize;
|
||||
private final AtomicBoolean missingStorageReported = new AtomicBoolean();
|
||||
private final Long2ObjectLinkedOpenHashMap<Plate> cache = new Long2ObjectLinkedOpenHashMap<>();
|
||||
|
||||
public PregenCacheImpl(File directory, int maxSize) {
|
||||
@@ -183,6 +184,9 @@ public class PregenCacheImpl implements PregenCache {
|
||||
if (!plate.dirty) {
|
||||
return;
|
||||
}
|
||||
if (!prepareCacheDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = null;
|
||||
try {
|
||||
@@ -196,10 +200,29 @@ public class PregenCacheImpl implements PregenCache {
|
||||
}
|
||||
}
|
||||
|
||||
private File fileForPlate(int x, int z) {
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
/**
|
||||
* Creates the cache directory under an existing parent only. This is a cache: when the storage it lives
|
||||
* in has been deleted the plate is dropped rather than rebuilt, so a trim cannot resurrect a world tree.
|
||||
*/
|
||||
private boolean prepareCacheDirectory() {
|
||||
if (directory.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
File parent = directory.getParentFile();
|
||||
if (parent == null || !parent.isDirectory()) {
|
||||
if (missingStorageReported.compareAndSet(false, true)) {
|
||||
IrisLogging.warn("Pregen cache storage is gone at %s; cached plates are being dropped.",
|
||||
directory.getAbsolutePath());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!directory.mkdirs() && !directory.isDirectory()) {
|
||||
throw new IllegalStateException("Cannot create directory: " + directory.getAbsolutePath());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private File fileForPlate(int x, int z) {
|
||||
return new File(directory, "c." + x + "." + z + ".lz4b");
|
||||
}
|
||||
|
||||
|
||||
@@ -518,7 +518,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
|
||||
int suppressed = suppressedSlowRequestLogs.getAndSet(0);
|
||||
String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed;
|
||||
IrisLogging.warn("Async pregen chunk load at " + x + "," + z
|
||||
// Slow chunk loads are what the adaptive in flight limit exists to absorb, and this line is already
|
||||
// interval throttled with a suppression count. It reports the throttle working, not a fault.
|
||||
IrisLogging.info("Async pregen chunk load at " + x + "," + z
|
||||
+ " is still pending after " + slowRequestWarningSeconds + "s."
|
||||
+ " adaptiveLimit=" + adaptiveInFlightLimit.get()
|
||||
+ suppressedText + " " + metricsSnapshot());
|
||||
|
||||
@@ -86,7 +86,9 @@ public final class IrisSafeguard {
|
||||
}
|
||||
|
||||
for (ValueWithDiagnostics<Mode> value : results.values()) {
|
||||
value.log(true, true);
|
||||
// Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log
|
||||
// record per frame at the diagnostic's own severity. Traces go through reportError.
|
||||
value.log(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,20 +100,18 @@ public final class IrisSafeguard {
|
||||
}
|
||||
}
|
||||
|
||||
// A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of
|
||||
// dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log.
|
||||
private static void warning() {
|
||||
IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode");
|
||||
IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions.");
|
||||
IrisLogging.warn(C.GRAY + "Iris will continue startup normally.");
|
||||
IrisLogging.warn("");
|
||||
}
|
||||
|
||||
private static void unstable() {
|
||||
IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode");
|
||||
IrisLogging.error("");
|
||||
IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--");
|
||||
IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible.");
|
||||
// No startup sleep: blocking the boot thread protected nothing — world creation and
|
||||
// player admission are already gated by IrisStartupValidation.
|
||||
IrisLogging.info("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.core.link.MultiverseGuardListener;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.plugin.IrisService;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.PluginDisableEvent;
|
||||
import org.bukkit.event.server.PluginEnableEvent;
|
||||
|
||||
/**
|
||||
* Brings Iris' Multiverse integration up exactly when Multiverse is usable and takes it back down when it
|
||||
* is not.
|
||||
* <p>
|
||||
* Nothing in this class names a Multiverse type. The guard listener does, and Bukkit resolves a listener's
|
||||
* handler parameter types at registration, so it is only constructed once Multiverse is enabled. Iris
|
||||
* declares {@code loadbefore: Multiverse-Core}, which means Multiverse normally enables after Iris and the
|
||||
* plugin event is the live path; a reload or a plugin manager can invert that, so enablement is checked
|
||||
* directly as well.
|
||||
*/
|
||||
public class MultiverseSVC implements IrisService {
|
||||
private Listener guard;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
activate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
deactivate();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPluginEnable(PluginEnableEvent event) {
|
||||
if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) {
|
||||
return;
|
||||
}
|
||||
activate();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPluginDisable(PluginDisableEvent event) {
|
||||
if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) {
|
||||
return;
|
||||
}
|
||||
deactivate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the destructive-command guard and re-imposes Iris' intended Multiverse state on every
|
||||
* world Iris owns. Multiverse imports the worlds it did not know about during its own enable, which is
|
||||
* before this runs, so the reconciliation pass is the only thing that can correct those entries.
|
||||
*/
|
||||
private synchronized void activate() {
|
||||
if (guard != null) {
|
||||
return;
|
||||
}
|
||||
MultiverseCoreLink link = IrisServices.getOrNull(MultiverseCoreLink.class);
|
||||
if (link == null || !link.isActive()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Listener listener = new MultiverseGuardListener(link);
|
||||
BukkitPlatform.volmitPlugin().registerListener(listener);
|
||||
guard = listener;
|
||||
} catch (Throwable failure) {
|
||||
// A Multiverse whose world events Iris cannot bind to is a Multiverse Iris must not touch.
|
||||
IrisLogging.warn("Multiverse world events are not bindable (%s); Iris will not guard its"
|
||||
+ " destructive commands.", failure.getClass().getSimpleName());
|
||||
return;
|
||||
}
|
||||
link.reconcileOwnedWorlds();
|
||||
}
|
||||
|
||||
private synchronized void deactivate() {
|
||||
Listener listener = guard;
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
guard = null;
|
||||
try {
|
||||
BukkitPlatform.volmitPlugin().unregisterListener(listener);
|
||||
} catch (Throwable ignored) {
|
||||
// Shutdown ordering already unregisters every handler.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.VanishedWorldStorage;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisEngineData;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -31,6 +32,7 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@@ -40,8 +42,14 @@ import java.nio.file.StandardCopyOption;
|
||||
* atomic temp-file move so a crash mid-save can never truncate the live engine data.
|
||||
*/
|
||||
final class EngineDataStore {
|
||||
private static final String ENGINE_DATA_DIRECTORY = "iris/engine-data";
|
||||
private final IrisEngine engine;
|
||||
private final Object engineDataLock = new Object();
|
||||
/**
|
||||
* Set once this store has written {@code iris/engine-data} into the world folder. From then on that
|
||||
* directory going missing is a delete, whether or not the server has already put the world folder back.
|
||||
*/
|
||||
private volatile boolean engineDataEstablished;
|
||||
|
||||
EngineDataStore(IrisEngine engine) {
|
||||
this.engine = engine;
|
||||
@@ -57,8 +65,9 @@ final class EngineDataStore {
|
||||
if (loaded != null) {
|
||||
return loaded;
|
||||
}
|
||||
File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json");
|
||||
File f = engineDataFile();
|
||||
if (f.exists()) {
|
||||
engineDataEstablished = true;
|
||||
try {
|
||||
loaded = new Gson().fromJson(IO.readAll(f), IrisEngineData.class);
|
||||
if (loaded == null) {
|
||||
@@ -79,24 +88,55 @@ final class EngineDataStore {
|
||||
if (loaded.getStatistics().getVersion() == -1 || loaded.getStatistics().getMCVersion() == -1) {
|
||||
IrisLogging.error("Failed to setup Engine Data!");
|
||||
}
|
||||
if (!storageVanished()) {
|
||||
try {
|
||||
writeEngineDataAtomically(f, loaded);
|
||||
engineDataEstablished = true;
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.engineData = loaded;
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this engine's world storage is gone, which stops persistence rather than letting the write
|
||||
* rebuild the tree it is supposed to be writing into.
|
||||
* <p>
|
||||
* The world folder existing is not enough. A {@code save-all} after a hot delete writes the level's own
|
||||
* {@code data/*.dat} files back and recreates the folder, and Iris' {@code WorldSaveEvent} handler runs
|
||||
* after that, so a folder check alone lets the save rebuild {@code iris/engine-data} - which is exactly
|
||||
* the directory the next boot's storage audit reads as "this is an Iris world whose pack snapshot broke".
|
||||
* Once this store has written that directory, its absence is the delete.
|
||||
*/
|
||||
private boolean storageVanished() {
|
||||
File worldFolder = engine.getWorld().worldFolder();
|
||||
if (!engineDataEstablished) {
|
||||
return VanishedWorldStorage.vanished(worldFolder);
|
||||
}
|
||||
return VanishedWorldStorage.vanished(worldFolder, new File(worldFolder, ENGINE_DATA_DIRECTORY));
|
||||
}
|
||||
|
||||
private File engineDataFile() {
|
||||
return new File(
|
||||
engine.getWorld().worldFolder(),
|
||||
ENGINE_DATA_DIRECTORY + "/" + engine.getDimension().getLoadKey() + ".json");
|
||||
}
|
||||
|
||||
void saveEngineData() {
|
||||
synchronized (engineDataLock) {
|
||||
File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json");
|
||||
if (storageVanished()) {
|
||||
return;
|
||||
}
|
||||
File f = engineDataFile();
|
||||
try {
|
||||
writeEngineDataAtomically(f, engine.getEngineData());
|
||||
engineDataEstablished = true;
|
||||
IrisLogging.debug("Saved Engine Data");
|
||||
} catch (IOException e) {
|
||||
IrisLogging.error("Failed to save Engine Data");
|
||||
@@ -122,6 +162,13 @@ final class EngineDataStore {
|
||||
if (parent == null) {
|
||||
throw new IOException("Engine data path has no parent: " + output);
|
||||
}
|
||||
// <worldFolder>/iris/engine-data/<key>.json: the world folder itself is never created here, so a
|
||||
// deleted world cannot be rebuilt by a save that races the guard in saveEngineData.
|
||||
Path irisRoot = parent.getParent();
|
||||
Path worldFolder = irisRoot == null ? null : irisRoot.getParent();
|
||||
if (worldFolder == null || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Iris world storage is missing: " + worldFolder);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path temporary = Files.createTempFile(parent, output.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
|
||||
@@ -713,9 +713,12 @@ public class IrisComplex implements DataProvider {
|
||||
} catch (Throwable e) {
|
||||
long now = System.currentTimeMillis();
|
||||
long last = lastBoundsFailureLog.get();
|
||||
if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now)) {
|
||||
// The five second gate keeps this off the hot path; the once key keeps it out of a log
|
||||
// scan for the rest of the generation, where the same cause repeats for every column.
|
||||
if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now)
|
||||
&& IrisLogging.warnOnce("biome-bounds:" + e.getClass().getName() + ":" + e.getMessage(),
|
||||
"Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage())) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.warn("Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ public class IrisEngine implements Engine {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
|
||||
IrisLogging.notice("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
|
||||
_t0 = M.ms();
|
||||
phaseStartedAt = System.nanoTime();
|
||||
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
|
||||
|
||||
@@ -28,6 +28,7 @@ public class AtomicCache<T> {
|
||||
private transient final Object initLock = new Object();
|
||||
private transient final boolean nullSupport;
|
||||
private transient volatile Object value;
|
||||
private transient volatile Throwable failure;
|
||||
|
||||
public AtomicCache() {
|
||||
this(false);
|
||||
@@ -40,6 +41,7 @@ public class AtomicCache<T> {
|
||||
public void reset() {
|
||||
synchronized (initLock) {
|
||||
value = null;
|
||||
failure = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +74,10 @@ public class AtomicCache<T> {
|
||||
* Like {@link #aquire(Supplier)} but propagates a supplier failure to the caller instead
|
||||
* of swallowing it into a null return. For values that are mandatory: a caller of a
|
||||
* "@NotNull" accessor should see the supplier's real exception, not a downstream NPE.
|
||||
* <p>
|
||||
* The supplier is retried on every call until it produces a value, which is what a caller that can
|
||||
* repair the cause between attempts needs. Use {@link #aquireOnceOrThrow(Supplier)} when re-running a
|
||||
* failed supplier is itself the problem.
|
||||
*/
|
||||
public T aquireOrThrow(Supplier<T> t) {
|
||||
Object v = value;
|
||||
@@ -96,6 +102,56 @@ public class AtomicCache<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #aquireOrThrow(Supplier)} but memoizes the failure as well as the value, so a supplier
|
||||
* that cannot produce one is run exactly once and every later caller is told the same reason. For
|
||||
* values whose supplier rebuilds shared state, where re-running it per call repeats that work and
|
||||
* hides the original cause behind whatever the retry happens to fail on. {@link #reset()} clears it.
|
||||
*/
|
||||
public T aquireOnceOrThrow(Supplier<T> t) {
|
||||
Object v = value;
|
||||
|
||||
if (v != null) {
|
||||
return unwrap(v);
|
||||
}
|
||||
rethrowFailure(failure);
|
||||
|
||||
synchronized (initLock) {
|
||||
v = value;
|
||||
|
||||
if (v != null) {
|
||||
return unwrap(v);
|
||||
}
|
||||
rethrowFailure(failure);
|
||||
|
||||
T computed;
|
||||
try {
|
||||
computed = t.get();
|
||||
if (computed == null) {
|
||||
throw new IllegalStateException("Atomic cache supplier produced null");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
throw e;
|
||||
}
|
||||
value = computed;
|
||||
return computed;
|
||||
}
|
||||
}
|
||||
|
||||
private static void rethrowFailure(Throwable memoized) {
|
||||
if (memoized == null) {
|
||||
return;
|
||||
}
|
||||
if (memoized instanceof RuntimeException runtimeFailure) {
|
||||
throw runtimeFailure;
|
||||
}
|
||||
if (memoized instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException("Atomic cache supplier failed", memoized);
|
||||
}
|
||||
|
||||
public T aquire(Supplier<T> t) {
|
||||
Object v = value;
|
||||
|
||||
@@ -122,8 +178,12 @@ public class AtomicCache<T> {
|
||||
value = NULL_VALUE;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("Atomic cache failure!");
|
||||
e.printStackTrace();
|
||||
// aquire retries on every call, so a supplier that stays broken is reached per sample.
|
||||
// The first statement of it carries the stack; the rest are traces of the same cause.
|
||||
if (IrisLogging.warnOnce("atomic-cache:" + e.getClass().getName() + ":" + e.getMessage(),
|
||||
"Atomic cache supplier failed: %s: %s", e.getClass().getSimpleName(), e.getMessage())) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -68,7 +68,9 @@ public class WorldObjectPlacer implements IObjectPlacer {
|
||||
|
||||
if (d instanceof IrisCustomData data) {
|
||||
block.setBlockData(data.getBase(), false);
|
||||
IrisLogging.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!");
|
||||
// Reached per block for every custom block in the pack; the pack is what needs changing, and
|
||||
// one statement of that is enough.
|
||||
IrisLogging.warnOnce("custom-block-placer", "Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported.");
|
||||
} else block.setBlockData(d, false);
|
||||
|
||||
if (storageChest && !J.runRegion(world, x >> 4, z >> 4, () -> fillLoot(block), 1)) {
|
||||
|
||||
@@ -341,7 +341,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
|
||||
public MantleChunk<Matter> acquireChunk(int cx, int cz) {
|
||||
int index = windowIndex(cx, cz);
|
||||
if (index < 0) {
|
||||
IrisLogging.error("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz);
|
||||
IrisLogging.debug("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -927,7 +927,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
|
||||
try {
|
||||
setData(pos.getX(), pos.getY(), pos.getZ(), data);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("No set? " + data.toString() + " for " + pos.toString());
|
||||
// Reached per position while an object writes past the edge of the writer window, which is
|
||||
// how a large object is clipped. Nothing here is actionable from outside the engine.
|
||||
IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ public class IrisCompat {
|
||||
searching:
|
||||
while (true) {
|
||||
if (err-- <= 0) {
|
||||
IrisLogging.error("Can't find block data for " + n);
|
||||
IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE.");
|
||||
return BukkitBlockResolution.getNoCompat("STONE");
|
||||
}
|
||||
String m = buf;
|
||||
@@ -300,7 +300,7 @@ public class IrisCompat {
|
||||
}
|
||||
}
|
||||
|
||||
IrisLogging.error("Can't find block data for " + n);
|
||||
IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE.");
|
||||
return BukkitBlockResolution.getNoCompat("STONE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ public class IrisDecorator {
|
||||
|
||||
public PlatformBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) {
|
||||
if (getBlockData(data).isEmpty()) {
|
||||
IrisLogging.warn("Empty Block Data for " + b.getName());
|
||||
IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,9 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class IrisImage extends IrisRegistrant {
|
||||
@@ -116,21 +112,4 @@ public class IrisImage extends IrisRegistrant {
|
||||
return "Image";
|
||||
}
|
||||
|
||||
public void writeDebug(IrisImageChannel channel) {
|
||||
|
||||
|
||||
try {
|
||||
File at = new File(getLoadFile().getParentFile(), "debug-see-" + getLoadFile().getName());
|
||||
BufferedImage b = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
for (int i = 0; i < getWidth(); i++) {
|
||||
for (int j = 0; j < getHeight(); j++) {
|
||||
b.setRGB(i, j, Color.getHSBColor(0, 0, (float) getValue(channel, i, j)).getRGB());
|
||||
}
|
||||
}
|
||||
ImageIO.write(b, "png", at);
|
||||
IrisLogging.warn("Debug image written to " + at.getPath() + " for channel " + channel.name());
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ public class IrisImageMap {
|
||||
public double getNoise(IrisData data, int x, int z) {
|
||||
IrisImage i = imageCache.aquire(() -> data.getImageLoader().load(image));
|
||||
if (i == null) {
|
||||
IrisLogging.error("NULL IMAGE FOR " + image);
|
||||
// Reached per sample for as long as the pack points at an image that will not load, so the
|
||||
// pack key is what the operator needs and one statement of it is enough.
|
||||
IrisLogging.warnOnce("image-map:" + image, "No image %s in the pack; that noise samples as zero.", image);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ public class SlimJar {
|
||||
.debug(DEBUG)
|
||||
.build();
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.warn("Failed to inject the library loader, falling back to application builder");
|
||||
// The Spigot builder is a probe: not every server exposes it, and the fallback is the
|
||||
// supported path on the ones that do not.
|
||||
IrisLogging.info("Failed to inject the library loader, falling back to application builder");
|
||||
ApplicationBuilder.appending(plugin.getName())
|
||||
.injectableFactory(InjectableFactory.selecting(InjectableFactory.ERROR, InjectableFactory.INJECTABLE, InjectableFactory.WRAPPED, InjectableFactory.UNSAFE))
|
||||
.downloadDirectoryPath(downloadPath)
|
||||
|
||||
@@ -1009,7 +1009,9 @@ public interface Hunk<T> extends HunkLike<T> {
|
||||
*/
|
||||
default void set(int x, int y, int z, T t) {
|
||||
if (!contains(x, y, z)) {
|
||||
IrisLogging.warn("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth());
|
||||
// The clamp on the default write path: callers write past the edge of a hunk by design and
|
||||
// rely on the write being dropped, so this is a trace of a normal event, not a problem.
|
||||
IrisLogging.debug("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Every core diagnostic keeps its severity through to the server log, so the level a call site picks is the
|
||||
* level an operator scanning for WARN and SEVERE sees. This pins the two ends of that decision: conditions an
|
||||
* operator must act on stay at WARN or ERROR, and conditions that repeat per block, per sample or per chunk -
|
||||
* or that report designed backpressure, a probe on an optional dependency, or a success - do not.
|
||||
* <p>
|
||||
* The pin is on the source rather than on a live engine because most of these call sites need a running world
|
||||
* to reach, and what is being fixed is the choice of level, not the condition that triggers it.
|
||||
*/
|
||||
public class DiagnosticSeverityPolicyTest {
|
||||
@Test
|
||||
public void perBlockAndPerSampleConditionsDoNotReachTheServerLogAtWarnLevel() {
|
||||
assertLoggedWith("util/project/hunk/Hunk.java", "OUT OF BOUNDS ", "debug");
|
||||
assertLoggedWith("engine/mantle/MantleWriter.java", "No set? ", "debug");
|
||||
assertLoggedWith("engine/mantle/MantleWriter.java", "Mantle Writer Accessed chunk out of bounds", "debug");
|
||||
assertLoggedWith("engine/object/IrisImageMap.java", "No image ", "warnOnce");
|
||||
assertLoggedWith("engine/object/IrisDecorator.java", "Empty Block Data for ", "warnOnce");
|
||||
assertLoggedWith("engine/object/IrisCompat.java", "Can't find block data for ", "warnOnce");
|
||||
assertLoggedWith("engine/data/cache/AtomicCache.java", "Atomic cache supplier failed: %s: %s", "warnOnce");
|
||||
assertLoggedWith("engine/IrisComplex.java", "Failed to sample interpolated biome bounds", "warnOnce");
|
||||
assertLoggedWith("engine/framework/placer/WorldObjectPlacer.java", "Tried to place custom block at", "warnOnce");
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpressure pausing generation is the design working. It is reported every five seconds for the whole
|
||||
* duration of a large pregen, so at WARN it would be the only thing a log scan finds.
|
||||
*/
|
||||
@Test
|
||||
public void designedBackpressureIsReportedWithoutRaisingAWarning() {
|
||||
assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure: ", "info");
|
||||
assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure: pausing generation", "info");
|
||||
assertLoggedWith("core/pregenerator/MantleHeapPressure.java", "Iris heap remained at", "info");
|
||||
assertLoggedWith("core/pregenerator/methods/AsyncPregenMethod.java", "is still pending after", "info");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void probesOnOptionalDependenciesAreNotWarnings() {
|
||||
assertLoggedWith("core/link/MultiverseCoreLink.java", "is not reachable; Iris world settings", "info");
|
||||
assertLoggedWith("core/link/MultiverseCoreLink.java", "Multiverse will record the live name", "info");
|
||||
assertLoggedWith("core/gui/GuiHost.java", "Unable to install the Iris desktop quit guard", "info");
|
||||
assertLoggedWith("util/common/misc/SlimJar.java", "Failed to inject the library loader", "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused destructive command is the guard working, and the admin who typed it is told directly. The
|
||||
* console record of it is not a warning about the server.
|
||||
*/
|
||||
@Test
|
||||
public void aRefusedMultiverseCommandIsRecordedWithoutRaisingAWarning() throws IOException {
|
||||
String guard = read("core/link/MultiverseGuardListener.java");
|
||||
|
||||
assertTrue("the console record of a refusal is kept", guard.contains("IrisLogging.info(\"%s %s\", reason, remedy)"));
|
||||
assertFalse("the refusal is not a warning about the server", guard.contains("IrisLogging.warn(\"%s %s\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decorationAndBlankLinesAreNotEmittedAtAnySeverity() throws IOException {
|
||||
String safeguard = read("core/safeguard/IrisSafeguard.java");
|
||||
assertFalse("a blank record renders as an empty [WARN] line", safeguard.contains("IrisLogging.warn(\"\")"));
|
||||
assertFalse("a blank record renders as an empty [SEVERE] line", safeguard.contains("IrisLogging.error(\"\")"));
|
||||
assertFalse("a blank record renders as an empty [INFO] line", safeguard.contains("IrisLogging.info(\"\")"));
|
||||
assertFalse("a separator rule is not a diagnostic", safeguard.contains("--==<"));
|
||||
|
||||
String configurator = read("core/ServerConfigurator.java");
|
||||
assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"===="));
|
||||
assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"----"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A written debug image is a success, and the method that wrote it had no callers at all.
|
||||
*/
|
||||
@Test
|
||||
public void theUnusedDebugImageWriterIsGone() throws IOException {
|
||||
assertFalse(read("engine/object/IrisImage.java").contains("writeDebug"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The other end of the policy. These are the conditions the promotion exists for.
|
||||
*/
|
||||
@Test
|
||||
public void operatorActionableConditionsKeepTheirSeverity() {
|
||||
assertLoggedWith("core/lifecycle/VanishedWorldStorage.java", "Iris world storage is gone at", "error");
|
||||
assertLoggedWith("core/IrisWorlds.java", "has unusable world storage and is excluded", "error");
|
||||
assertLoggedWith("engine/EngineDataStore.java", "Failed to setup Engine Data", "error");
|
||||
assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk section, skipping it.", "error");
|
||||
assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk, creating a new chunk instead.", "error");
|
||||
assertLoggedWith("util/common/reflect/WrappedField.java", "Failed to created WrappedField", "error");
|
||||
assertLoggedWith("core/datapack/DatapackIngestService.java", "Repairing modified or corrupt Iris-managed datapack", "warn");
|
||||
assertLoggedWith("core/pregenerator/cache/PregenCacheImpl.java", "Pregen cache storage is gone at", "warn");
|
||||
assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure exceeded ", "warn");
|
||||
assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure wait exceeded ", "warn");
|
||||
assertLoggedWith("core/pregenerator/IrisPregenerator.java", "failed chunk(s); failures are not cached", "warn");
|
||||
assertLoggedWith("core/service/MultiverseSVC.java", "Multiverse world events are not bindable", "warn");
|
||||
}
|
||||
|
||||
/**
|
||||
* A handful of lifecycle lines an operator reads logs/latest.log to find. NOTICE is the level adapters
|
||||
* route to the server's own logger rather than to the console sender.
|
||||
*/
|
||||
@Test
|
||||
public void lifecycleLinesAreRaisedAtNoticeLevel() {
|
||||
assertLoggedWith("engine/IrisEngine.java", "Engine init: ", "notice");
|
||||
assertLoggedWith("core/link/MultiverseCoreLink.java", "Adopted %d live Iris world", "notice");
|
||||
assertLoggedWith("core/pregenerator/IrisPregenerator.java", "Pregen finished: ", "notice");
|
||||
}
|
||||
|
||||
private static void assertLoggedWith(String relativePath, String fragment, String expectedMethod) {
|
||||
String source;
|
||||
try {
|
||||
source = read(relativePath);
|
||||
} catch (IOException unreadable) {
|
||||
throw new AssertionError("Cannot read " + relativePath, unreadable);
|
||||
}
|
||||
|
||||
int occurrences = 0;
|
||||
for (int at = source.indexOf(fragment); at >= 0; at = source.indexOf(fragment, at + 1)) {
|
||||
occurrences++;
|
||||
assertEquals(relativePath + " @ \"" + fragment + "\" occurrence " + occurrences,
|
||||
expectedMethod, loggingMethodBefore(source, at, relativePath, fragment));
|
||||
}
|
||||
assertTrue(relativePath + " no longer contains \"" + fragment + "\"", occurrences > 0);
|
||||
}
|
||||
|
||||
private static String loggingMethodBefore(String source, int at, String relativePath, String fragment) {
|
||||
int call = source.lastIndexOf("IrisLogging.", at);
|
||||
assertTrue(relativePath + " @ \"" + fragment + "\" is not logged through IrisLogging", call >= 0);
|
||||
int start = call + "IrisLogging.".length();
|
||||
int end = start;
|
||||
while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) {
|
||||
end++;
|
||||
}
|
||||
return source.substring(start, end);
|
||||
}
|
||||
|
||||
private static String read(String relativePath) throws IOException {
|
||||
return Files.readString(Path.of("src/main/java/art/arcane/iris").resolve(relativePath));
|
||||
}
|
||||
}
|
||||
@@ -427,4 +427,21 @@ public class IrisWorldStorageTest {
|
||||
Path packRoot = Files.createDirectory(irisRoot.resolve("pack"));
|
||||
assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void managedWorldStorageIsOnlyReportedForRealIrisWorldDirectories() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("managed-storage-level-root");
|
||||
|
||||
assertFalse(IrisWorldStorage.hasManagedWorldStorage(null));
|
||||
assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
|
||||
|
||||
Path namespace = Files.createDirectories(levelRoot.toPath().resolve("dimensions/iris"));
|
||||
assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
|
||||
|
||||
Files.writeString(namespace.resolve("stray"), "not a world directory");
|
||||
assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
|
||||
|
||||
Files.createDirectory(namespace.resolve("moon"));
|
||||
assertTrue(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
@@ -11,7 +12,9 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisWorldsTest {
|
||||
@Rule
|
||||
@@ -66,6 +69,54 @@ public class IrisWorldsTest {
|
||||
assertNull(IrisWorlds.generatorLoadKey(null, "overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void orphanedIrisWorldsAreReportedButVanillaSlotsAreNot() throws Exception {
|
||||
MissingWorldStorageLog.reset();
|
||||
Path levelRoot = temporaryFolder.newFolder("orphan-report", "world").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/present"));
|
||||
|
||||
Map<String, String> configuredWorlds = new LinkedHashMap<>();
|
||||
configuredWorlds.put("world_nether", "underworld");
|
||||
configuredWorlds.put("world_iris_present", "overworld");
|
||||
configuredWorlds.put("world_iris_gone", "overworld");
|
||||
|
||||
assertEquals(
|
||||
Set.of("world_iris_present"),
|
||||
IrisWorlds.filterBukkitWorldsByStorage(levelRoot, configuredWorlds).keySet());
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
|
||||
assertFalse("a vanilla slot that was never created is not an orphan",
|
||||
MissingWorldStorageLog.hasWarned("world_nether"));
|
||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_present"));
|
||||
MissingWorldStorageLog.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry is built from a private constructor behind a static cache, so the isolation contract is
|
||||
* asserted against the source: one unusable world folder used to throw out of {@code clean()}, through
|
||||
* the constructor and into the cache, which returned null and NPE'd every caller.
|
||||
*/
|
||||
@Test
|
||||
public void oneUnusableWorldIsExcludedInsteadOfFailingTheWholeRegistry() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/IrisWorlds.java"));
|
||||
|
||||
int accessor = source.indexOf("public static IrisWorlds get()");
|
||||
int accessorEnd = source.indexOf("public synchronized void put(", accessor);
|
||||
assertTrue("get() must not swallow a failure into a null return",
|
||||
source.substring(accessor, accessorEnd).contains("cache.aquireOnceOrThrow("));
|
||||
|
||||
int clean = source.indexOf("public synchronized void clean()");
|
||||
int cleanEnd = source.indexOf("public synchronized void save()", clean);
|
||||
String cleanBody = source.substring(clean, cleanEnd);
|
||||
assertTrue("clean() must isolate an unusable entry",
|
||||
cleanBody.contains("catch (IllegalStateException e)"));
|
||||
assertTrue("an unusable entry stays in the registry so /iris remove can still find it",
|
||||
cleanBody.contains("warnUnusableStorage(entry.getKey(), e);"));
|
||||
|
||||
int loadDimension = source.indexOf("private IrisDimension loadDimension(");
|
||||
assertTrue("loadDimension must exclude an unusable world rather than propagate",
|
||||
source.substring(loadDimension).contains("catch (IllegalStateException unusableStorage)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitWorldFilteringRecognizesCurrentCraftBukkitConfiguredStorage() throws Exception {
|
||||
Path worldContainer = temporaryFolder.newFolder("configured-server").toPath();
|
||||
|
||||
@@ -151,6 +151,186 @@ public class BukkitWorldConfigurationTest {
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void auditSeparatesUsableStorageFromOrphansAndBrokenSnapshots() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-bukkit.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/healthy/iris/pack"));
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/broken/region"));
|
||||
Files.writeString(levelRoot.resolve("dimensions/iris/broken/region/r.0.0.mca"), "terrain");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_healthy.generator", "Iris:overworld");
|
||||
yaml.set("worlds.world_iris_broken.generator", "Iris:overworld");
|
||||
yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> audited =
|
||||
BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
|
||||
|
||||
assertEquals(3, audited.size());
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_broken"));
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.MISSING, stateOf(audited, "world_iris_gone"));
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.PRESENT, stateOf(audited, "world_iris_healthy"));
|
||||
assertEquals(
|
||||
levelRoot.resolve("dimensions/iris/broken").toAbsolutePath().normalize(),
|
||||
entryOf(audited, "world_iris_broken").storagePath());
|
||||
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("pack"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-file-slot.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-file-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris"));
|
||||
Files.writeString(levelRoot.resolve("dimensions/iris/file"), "not a world directory");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_file.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertEquals(
|
||||
BukkitWorldConfiguration.IrisWorldStorageState.MISSING,
|
||||
stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
|
||||
"world_iris_file"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a live world's folder and letting the server save it back leaves a five-file data/ skeleton
|
||||
* with no regions and no iris/ directory. Nothing in it can be overwritten, so it must not hold the
|
||||
* server hostage on the next boot.
|
||||
*/
|
||||
@Test
|
||||
public void auditTreatsAServerRewrittenHuskAsAnOrphan() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-husk.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-husk-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/paper"));
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/minecraft"));
|
||||
Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
|
||||
Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/minecraft/raids.dat"), "x");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_husk.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> audited =
|
||||
BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.EMPTY, stateOf(audited, "world_iris_husk"));
|
||||
assertEquals(
|
||||
levelRoot.resolve("dimensions/iris/husk").toAbsolutePath().normalize(),
|
||||
entryOf(audited, "world_iris_husk").storagePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void auditFailsClosedForMantleDataWithoutAPackSnapshot() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-mantle.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-mantle-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/mantled/mantle"));
|
||||
Files.writeString(levelRoot.resolve("dimensions/iris/mantled/mantle/0.0.ttp"), "mantle");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_mantled.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertEquals(
|
||||
BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE,
|
||||
stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
|
||||
"world_iris_mantled"));
|
||||
}
|
||||
|
||||
/**
|
||||
* An iris/ directory without iris/pack is an Iris world whose pack snapshot broke, not an empty folder.
|
||||
*/
|
||||
@Test
|
||||
public void auditFailsClosedForAnIrisMarkerWithoutAPackSnapshot() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-marker.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-marker-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/marked/iris/engine-data"));
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_marked.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertEquals(
|
||||
BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE,
|
||||
stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
|
||||
"world_iris_marked"));
|
||||
}
|
||||
|
||||
/**
|
||||
* IrisWorldStorage refuses to resolve through a symbolic link, so a linked dimension path is storage Iris
|
||||
* cannot use - not storage that is not there. Reporting it as a cold orphan tells the operator to restore
|
||||
* a folder that is already present.
|
||||
*/
|
||||
@Test
|
||||
public void auditFailsClosedForASymlinkedDimensionPath() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-symlink.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-symlink-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris"));
|
||||
Path elsewhere = temporaryFolder.newFolder("audit-symlink-target").toPath();
|
||||
Files.createDirectories(elsewhere.resolve("iris/pack"));
|
||||
try {
|
||||
Files.createSymbolicLink(levelRoot.resolve("dimensions/iris/linked"), elsewhere);
|
||||
} catch (IOException | UnsupportedOperationException unsupported) {
|
||||
assumeNoException("symbolic links are not creatable on this filesystem", unsupported);
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_linked.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> audited =
|
||||
BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_linked"));
|
||||
assertTrue(entryOf(audited, "world_iris_linked").detail(),
|
||||
entryOf(audited, "world_iris_linked").detail().contains("symbolic link"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void auditStillReportsATrulyAbsentDimensionPathAsAnOrphan() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-absent.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-absent-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris"));
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_absent.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertEquals(
|
||||
BukkitWorldConfiguration.IrisWorldStorageState.MISSING,
|
||||
stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
|
||||
"world_iris_absent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingStorageIsReportedOncePerWorld() throws Exception {
|
||||
MissingWorldStorageLog.reset();
|
||||
File configuration = temporaryFolder.newFile("orphan-warning-bukkit.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("orphan-warning-level-root").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris"));
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_gone"));
|
||||
BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot);
|
||||
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
|
||||
MissingWorldStorageLog.reset();
|
||||
}
|
||||
|
||||
private static BukkitWorldConfiguration.IrisWorldStorageState stateOf(
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> entries,
|
||||
String configuredWorldName
|
||||
) {
|
||||
return entryOf(entries, configuredWorldName).state();
|
||||
}
|
||||
|
||||
private static BukkitWorldConfiguration.IrisWorldStorageEntry entryOf(
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> entries,
|
||||
String configuredWorldName
|
||||
) {
|
||||
return entries.stream()
|
||||
.filter(entry -> entry.configuredWorldName().equals(configuredWorldName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("No audit entry for " + configuredWorldName));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registersAndRemovesWorldAtomically() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
|
||||
/**
|
||||
* Deleting a loaded Iris world's folder and letting the server save the level back leaves a directory the
|
||||
* server still enumerates but that owns nothing: no pack snapshot, no chunk data. Left in the dimensions
|
||||
* tree it stops startup either way - Iris refuses to boot over it, or excluding it trips Paper's interactive
|
||||
* world-migration gate - so the bootstrap has to move it out before any level is created.
|
||||
*/
|
||||
public class HuskWorldQuarantineTest {
|
||||
private static final Instant STAMP = Instant.parse("2026-08-20T04:05:06Z");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void aServerSkeletonWithNoPackAndNoChunkDataIsMovedOutOfTheDimensionsTree() throws IOException {
|
||||
Path levelRoot = level("husk");
|
||||
Path husk = dimensionRoot(levelRoot, "irisworld");
|
||||
write(husk.resolve("data/paper/level_overrides.dat"));
|
||||
write(husk.resolve("data/minecraft/raids.dat"));
|
||||
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP);
|
||||
|
||||
assertEquals(1, moved.size());
|
||||
assertFalse("the server must not enumerate the husk", Files.exists(husk, LinkOption.NOFOLLOW_LINKS));
|
||||
Path destination = levelRoot.resolve("iris/husks/irisworld-20260820T040506");
|
||||
assertEquals(destination, moved.getFirst().destination());
|
||||
assertTrue(Files.isRegularFile(destination.resolve("data/paper/level_overrides.dat")));
|
||||
assertEquals(1, warnings.size());
|
||||
String warning = warnings.getFirst();
|
||||
assertTrue(warning, warning.contains(husk.toString()));
|
||||
assertTrue(warning, warning.contains(destination.toString()));
|
||||
assertTrue(warning, warning.contains("/iris remove world=world_iris_irisworld delete=true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldWithRegionDataIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("region");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("region/r.0.0.mca"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldWithEntitiesOrPoiDataIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("entities");
|
||||
Path entities = dimensionRoot(levelRoot, "onlyentities");
|
||||
write(entities.resolve("entities/r.0.0.mca"));
|
||||
Path poi = dimensionRoot(levelRoot, "onlypoi");
|
||||
write(poi.resolve("poi/r.0.0.mca"));
|
||||
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertTrue(moved.isEmpty());
|
||||
assertTrue(Files.isDirectory(entities));
|
||||
assertTrue(Files.isDirectory(poi));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldWithMantleDataIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("mantle");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("mantle/pv.0.ttp.lz4b"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anIrisMarkerDirectoryIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("marker");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
Files.createDirectories(world.resolve("iris/engine-data"));
|
||||
write(world.resolve("data/paper/level_overrides.dat"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFrozenPackSnapshotIsLeftAlone() throws IOException {
|
||||
Path levelRoot = level("pack");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
Files.createDirectories(world.resolve("iris/pack/dimensions"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS recreates a directory to hold a .DS_Store while a delete is still in flight, so a husk whose
|
||||
* iris/ folder holds nothing but that file is exactly as worthless as one with no iris/ folder at all.
|
||||
*/
|
||||
@Test
|
||||
public void anIrisFolderHoldingOnlyOsMetadataIsTreatedAsAbsent() throws IOException {
|
||||
Path levelRoot = level("dsstore");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("iris/.DS_Store"));
|
||||
write(world.resolve("data/paper/level_overrides.dat"));
|
||||
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertEquals(1, moved.size());
|
||||
assertFalse("the server must not enumerate the husk", Files.exists(world, LinkOption.NOFOLLOW_LINKS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyOsMetadataFileCountsAsAbsent() throws IOException {
|
||||
Path levelRoot = level("osmetadata");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("iris/.DS_Store"));
|
||||
write(world.resolve("iris/Thumbs.db"));
|
||||
write(world.resolve("iris/desktop.ini"));
|
||||
write(world.resolve("iris/._pack"));
|
||||
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertEquals(1, moved.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anIrisFolderWithRealContentBesideOsMetadataIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("dsstore-and-pack");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("iris/.DS_Store"));
|
||||
Files.createDirectories(world.resolve("iris/pack/dimensions"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anIrisFolderHoldingOnlyAnEmptySubdirectoryIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("dsstore-and-dir");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("iris/.DS_Store"));
|
||||
Files.createDirectories(world.resolve("iris/engine-data"));
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSymlinkedIrisFolderIsLeftForTheFailClosedGuard() throws IOException {
|
||||
Path levelRoot = level("iris-symlink");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
Path elsewhere = temporaryFolder.newFolder("iris-symlink-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(world.resolve("iris"), elsewhere);
|
||||
} catch (IOException | UnsupportedOperationException unsupported) {
|
||||
assumeTrue("symbolic links are not creatable on this filesystem", false);
|
||||
}
|
||||
|
||||
assertQuarantinesNothing(levelRoot, world);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSymlinkedDimensionRootIsNeverMoved() throws IOException {
|
||||
Path levelRoot = level("symlink");
|
||||
Path namespace = levelRoot.resolve("dimensions/iris");
|
||||
Files.createDirectories(namespace);
|
||||
Path elsewhere = temporaryFolder.newFolder("symlink-target").toPath();
|
||||
write(elsewhere.resolve("data/paper/level_overrides.dat"));
|
||||
Path link = namespace.resolve("irisworld");
|
||||
try {
|
||||
Files.createSymbolicLink(link, elsewhere);
|
||||
} catch (IOException | UnsupportedOperationException unsupported) {
|
||||
assumeTrue("symbolic links are not creatable on this filesystem", false);
|
||||
}
|
||||
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertTrue(moved.isEmpty());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertTrue(Files.isRegularFile(elsewhere.resolve("data/paper/level_overrides.dat")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnreadableDimensionRootIsNeverMoved() throws IOException {
|
||||
Path levelRoot = level("unreadable");
|
||||
Path world = dimensionRoot(levelRoot, "irisworld");
|
||||
write(world.resolve("data/paper/level_overrides.dat"));
|
||||
assumeTrue("posix permissions are required to make a directory unreadable",
|
||||
Files.getFileStore(world).supportsFileAttributeView("posix"));
|
||||
Files.setPosixFilePermissions(world, Set.of());
|
||||
try {
|
||||
assumeTrue("running as root defeats the permission bits", !Files.isReadable(world));
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertTrue(moved.isEmpty());
|
||||
} finally {
|
||||
Files.setPosixFilePermissions(world, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSecondHuskInTheSameSecondGetsItsOwnDestination() throws IOException {
|
||||
Path levelRoot = level("collision");
|
||||
Path first = dimensionRoot(levelRoot, "irisworld");
|
||||
write(first.resolve("data/paper/level_overrides.dat"));
|
||||
Files.createDirectories(levelRoot.resolve("iris/husks/irisworld-20260820T040506"));
|
||||
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP);
|
||||
|
||||
assertEquals(1, moved.size());
|
||||
assertEquals(levelRoot.resolve("iris/husks/irisworld-20260820T040506-2"), moved.getFirst().destination());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aLevelWithNoIrisNamespaceIsAnEmptySweep() throws IOException {
|
||||
Path levelRoot = level("none");
|
||||
|
||||
assertTrue(HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
|
||||
}, STAMP).isEmpty());
|
||||
}
|
||||
|
||||
private void assertQuarantinesNothing(Path levelRoot, Path world) {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<HuskWorldQuarantine.Quarantine> moved =
|
||||
HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP);
|
||||
|
||||
assertTrue(moved.isEmpty());
|
||||
assertTrue(warnings.isEmpty());
|
||||
assertTrue(Files.isDirectory(world, LinkOption.NOFOLLOW_LINKS));
|
||||
}
|
||||
|
||||
private Path level(String scope) throws IOException {
|
||||
return temporaryFolder.newFolder(scope, "world").toPath();
|
||||
}
|
||||
|
||||
private static Path dimensionRoot(Path levelRoot, String key) throws IOException {
|
||||
Path root = levelRoot.resolve("dimensions/iris").resolve(key);
|
||||
Files.createDirectories(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void write(Path file) throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, "x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
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.assertTrue;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the
|
||||
* console and the runtime log but never the instance's logs/latest.log - the only log most operators read.
|
||||
*/
|
||||
public class MissingWorldStorageLogTest {
|
||||
private final List<String> emitted = new ArrayList<>();
|
||||
private final List<LogLevel> levels = new ArrayList<>();
|
||||
private IrisPlatform previousPlatform;
|
||||
|
||||
@Before
|
||||
public void captureLog() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
MissingWorldStorageLog.reset();
|
||||
emitted.clear();
|
||||
levels.clear();
|
||||
}
|
||||
|
||||
@After
|
||||
public void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
MissingWorldStorageLog.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapWarningsAreReplayedOnceThePlatformLogIsUp() {
|
||||
MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
|
||||
|
||||
bindCapturingPlatform();
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
|
||||
assertEquals(2, emitted.size());
|
||||
assertTrue(emitted.get(0), emitted.get(0).contains("world_iris_gone"));
|
||||
assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/gone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replayIsIdempotentAndNeverRepeatsAWarningThePlatformAlreadySaw() {
|
||||
MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
|
||||
bindCapturingPlatform();
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
emitted.clear();
|
||||
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
MissingWorldStorageLog.warnOnce("world_iris_later", Path.of("/srv/world/dimensions/iris/later"));
|
||||
int afterLiveWarning = emitted.size();
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
|
||||
assertEquals(afterLiveWarning, emitted.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEmptyWorldFolderIsReportedWithItsOwnRemedy() {
|
||||
bindCapturingPlatform();
|
||||
|
||||
MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk"));
|
||||
|
||||
assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
|
||||
assertEquals(2, emitted.size());
|
||||
assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/husk"));
|
||||
assertTrue(emitted.get(1), emitted.get(1).contains("world_iris_husk"));
|
||||
assertFalse("an empty folder is deleted, not restored", emitted.get(1).contains("Restore"));
|
||||
}
|
||||
|
||||
/**
|
||||
* An orphan report is what an operator scans logs/latest.log at WARN level for; emitting it at INFO makes
|
||||
* it invisible to exactly the search that would find it.
|
||||
*/
|
||||
@Test
|
||||
public void everyOrphanReportIsRaisedAtWarnLevel() {
|
||||
bindCapturingPlatform();
|
||||
|
||||
MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
|
||||
MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk"));
|
||||
|
||||
assertEquals(4, levels.size());
|
||||
assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replayedOrphanReportsKeepTheirWarnLevel() {
|
||||
MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
|
||||
bindCapturingPlatform();
|
||||
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
|
||||
assertEquals(2, levels.size());
|
||||
assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN));
|
||||
}
|
||||
|
||||
private void bindCapturingPlatform() {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
doAnswer(invocation -> {
|
||||
levels.add(invocation.getArgument(0, LogLevel.class));
|
||||
emitted.add(invocation.getArgument(1, String.class));
|
||||
return null;
|
||||
}).when(platform).log(org.mockito.ArgumentMatchers.any(LogLevel.class), org.mockito.ArgumentMatchers.anyString());
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,34 @@ public class WorldRemovalPathPolicyTest {
|
||||
);
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, targetFailure.reason());
|
||||
}
|
||||
|
||||
/**
|
||||
* "iris worlds", Multiverse and the storage folder each print a different name for the same world, so
|
||||
* all three forms have to select it. Storage presence must not change which world is selected either;
|
||||
* an orphan is exactly the world an admin needs to remove.
|
||||
*/
|
||||
@Test
|
||||
public void everyPrintedFormOfAWorldNameSelectsTheSameTarget() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("name-forms", "world").toPath();
|
||||
|
||||
for (boolean storagePresent : new boolean[]{false, true}) {
|
||||
if (storagePresent) {
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/orphan1"));
|
||||
}
|
||||
WorldRemovalPathPolicy.Target bare =
|
||||
WorldRemovalPathPolicy.resolve("orphan1", "world", levelRoot);
|
||||
WorldRemovalPathPolicy.Target startup =
|
||||
WorldRemovalPathPolicy.resolve("world_iris_orphan1", "world", levelRoot);
|
||||
WorldRemovalPathPolicy.Target namespaced =
|
||||
WorldRemovalPathPolicy.resolve("iris:orphan1", "world", levelRoot);
|
||||
|
||||
assertEquals("iris:orphan1", bare.worldKey().toString());
|
||||
assertEquals(bare.worldKey(), startup.worldKey());
|
||||
assertEquals(bare.worldKey(), namespaced.worldKey());
|
||||
assertEquals(bare.worldDirectory(), startup.worldDirectory());
|
||||
assertEquals(bare.worldDirectory(), namespaced.worldDirectory());
|
||||
assertEquals(bare.storageDirectory(), startup.storageDirectory());
|
||||
assertEquals(bare.storageDirectory(), namespaced.storageDirectory());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mvplugins.multiverse.core.utils.result.Attempt;
|
||||
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.MultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.WorldManager;
|
||||
import org.mvplugins.multiverse.core.world.options.LoadWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
|
||||
import org.mvplugins.multiverse.external.vavr.control.Option;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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;
|
||||
|
||||
public class MultiverseCoreLinkTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void multiverseRegistrationRequiresTheConfiguredWorldName() throws Exception {
|
||||
Method updateWorld = MultiverseCoreLink.class
|
||||
@@ -37,17 +64,179 @@ public class MultiverseCoreLinkTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lookupNamesAddTheIrisKeyForConfiguredStartupNames() {
|
||||
public void lookupNamesAddTheIrisKeyAndRuntimeNameForConfiguredStartupNames() {
|
||||
assertEquals(
|
||||
List.of("world_iris_mvtest", "iris:mvtest"),
|
||||
List.of("world_iris_mvtest", "iris:mvtest", "iris_mvtest"),
|
||||
MultiverseCoreLink.lookupNames("world_iris_mvtest", "world")
|
||||
);
|
||||
assertEquals(
|
||||
List.of("survival_iris_mvtest", "iris:mvtest"),
|
||||
List.of("survival_iris_mvtest", "iris:mvtest", "iris_mvtest"),
|
||||
MultiverseCoreLink.lookupNames("survival_iris_mvtest", "survival")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse re-creates a world under whatever name its own WorldCreator produced, so an entry it
|
||||
* reloaded can be indexed under the keyed runtime name instead of the startup name Iris registered.
|
||||
*/
|
||||
@Test
|
||||
public void resolveFindsAWorldMultiverseOnlyHoldsUnderItsRuntimeName() {
|
||||
MultiverseWorld target = multiverseWorld("iris_mvtest", new NamespacedKey("iris", "mvtest"));
|
||||
|
||||
assertSame(target, new MultiverseCoreLink()
|
||||
.resolve(worldManagerHolding(Map.of("iris_mvtest", target)), "world_iris_mvtest", "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveNeverReturnsADifferentWorldSittingOnTheRuntimeName() {
|
||||
MultiverseWorld impostor = multiverseWorld("iris_mvtest", NamespacedKey.minecraft("iris_mvtest"));
|
||||
|
||||
assertNull(new MultiverseCoreLink()
|
||||
.resolve(worldManagerHolding(Map.of("iris_mvtest", impostor)), "world_iris_mvtest", "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveStillMatchesALegacyNameKeyedEntryUnderTheConfiguredName() {
|
||||
MultiverseWorld legacy = multiverseWorld("world_iris_mvtest", NamespacedKey.minecraft("world_iris_mvtest"));
|
||||
|
||||
assertSame(legacy, new MultiverseCoreLink()
|
||||
.resolve(worldManagerHolding(Map.of("world_iris_mvtest", legacy)), "world_iris_mvtest", "world"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Iris unloads the world and closes its generator before it unregisters it, so Multiverse must not run
|
||||
* its own unload-before-remove: that dereferences a Bukkit world which is already gone and fails the
|
||||
* whole removal.
|
||||
*/
|
||||
@Test
|
||||
public void removalSkipsTheMultiverseUnloadWhenTheBukkitWorldIsAlreadyGone() {
|
||||
MultiverseWorld unloaded = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
|
||||
when(unloaded.asLoadedWorld()).thenReturn(Option.none());
|
||||
|
||||
RemoveWorldOptions options = MultiverseCoreLink.removalOptions(unloaded);
|
||||
|
||||
assertFalse(options.unloadBukkitWorld());
|
||||
assertFalse(options.saveBukkitWorld());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removalStillLetsMultiverseUnloadAWorldThatIsStillLive() {
|
||||
MultiverseWorld live = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
|
||||
LoadedMultiverseWorld loaded = mock(LoadedMultiverseWorld.class);
|
||||
when(loaded.getBukkitWorld()).thenReturn(Option.of(mock(World.class)));
|
||||
when(live.asLoadedWorld()).thenReturn(Option.of(loaded));
|
||||
|
||||
assertTrue(MultiverseCoreLink.removalOptions(live).unloadBukkitWorld());
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse's loadWorld short-circuits to the already-loaded Bukkit world only when Bukkit has one
|
||||
* under the name Multiverse recorded. Without that world it builds one, so adoption is skipped rather
|
||||
* than letting Multiverse re-create a world Iris already owns.
|
||||
*/
|
||||
@Test
|
||||
public void adoptionIsSkippedWhenBukkitHasNoWorldUnderTheRecordedName() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
|
||||
MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
|
||||
WorldManager manager = mock(WorldManager.class);
|
||||
when(manager.isLoadedWorld(target)).thenReturn(false);
|
||||
|
||||
withBukkitWorld(null, () -> assertFalse(
|
||||
new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
|
||||
verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adoptionIsSkippedWhenTheLiveWorldUnderThatNameIsNotTheTarget() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
|
||||
MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
|
||||
WorldManager manager = mock(WorldManager.class);
|
||||
when(manager.isLoadedWorld(target)).thenReturn(false);
|
||||
World other = mock(World.class);
|
||||
when(other.getKey()).thenReturn(NamespacedKey.minecraft("overworld"));
|
||||
|
||||
withBukkitWorld(other, () -> assertFalse(
|
||||
new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
|
||||
verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adoptionIsSkippedWhenMultiverseAlreadyHoldsTheWorldAsLoaded() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
|
||||
MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
|
||||
WorldManager manager = mock(WorldManager.class);
|
||||
when(manager.isLoadedWorld(target)).thenReturn(true);
|
||||
|
||||
assertFalse(new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey));
|
||||
verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adoptionBindsALiveIrisWorldIntoTheMultiverseLoadedRegistry() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
|
||||
MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
|
||||
WorldManager manager = mock(WorldManager.class);
|
||||
when(manager.isLoadedWorld(target)).thenReturn(false);
|
||||
when(manager.loadWorld(any(LoadWorldOptions.class)))
|
||||
.thenReturn(Attempt.success(mock(LoadedMultiverseWorld.class)));
|
||||
World live = mock(World.class);
|
||||
when(live.getKey()).thenReturn(worldKey);
|
||||
|
||||
withBukkitWorld(live, () -> assertTrue(
|
||||
new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse refuses to adopt a world whose stored environment disagrees with the live one, so the live
|
||||
* world is the value the entry is corrected to whenever there is one.
|
||||
*/
|
||||
@Test
|
||||
public void theStoredEnvironmentIsTakenFromTheLiveWorld() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
|
||||
World live = mock(World.class);
|
||||
when(live.getKey()).thenReturn(worldKey);
|
||||
when(live.getEnvironment()).thenReturn(World.Environment.NETHER);
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(live));
|
||||
|
||||
assertEquals(World.Environment.NETHER,
|
||||
new MultiverseCoreLink().ownedWorldEnvironment(worldKey, "overworld"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEnvironmentThatMatchesWhatMultiverseAlreadyStoredIsNotRewritten() {
|
||||
MultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
|
||||
when(multiverseWorld.getEnvironment()).thenReturn(World.Environment.NORMAL);
|
||||
MultiverseCoreLink link = new MultiverseCoreLink();
|
||||
|
||||
assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, World.Environment.NORMAL));
|
||||
assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, null));
|
||||
}
|
||||
|
||||
private static void withBukkitWorld(World world, Runnable body) {
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world);
|
||||
body.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static MultiverseWorld multiverseWorld(String name, NamespacedKey key) {
|
||||
MultiverseWorld multiverseWorld = mock(MultiverseWorld.class);
|
||||
when(multiverseWorld.getName()).thenReturn(name);
|
||||
when(multiverseWorld.getKey()).thenReturn(key);
|
||||
when(multiverseWorld.asLoadedWorld()).thenReturn(Option.none());
|
||||
return multiverseWorld;
|
||||
}
|
||||
|
||||
private static WorldManager worldManagerHolding(Map<String, MultiverseWorld> registry) {
|
||||
WorldManager manager = mock(WorldManager.class);
|
||||
when(manager.getWorld(anyString()))
|
||||
.thenAnswer(invocation -> Option.of(registry.get(invocation.<String>getArgument(0))));
|
||||
return manager;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lookupNamesKeepNonConfiguredNamesUntouched() {
|
||||
assertEquals(List.of("iris_mvtest"), MultiverseCoreLink.lookupNames("iris_mvtest", "world"));
|
||||
@@ -55,4 +244,73 @@ public class MultiverseCoreLinkTest {
|
||||
assertEquals(List.of("iris:mvtest"), MultiverseCoreLink.lookupNames("iris:mvtest", "world"));
|
||||
assertEquals(List.of("iris_studio-demo"), MultiverseCoreLink.lookupNames("iris_studio-demo", "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLinkCallIsANoOpWithoutMultiverse() {
|
||||
withMultiverse(false, link -> {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn("world_iris_mvtest");
|
||||
|
||||
assertFalse(link.isActive());
|
||||
assertFalse(link.removeIfPresent(world));
|
||||
assertFalse(link.removeFromConfig("world_iris_mvtest"));
|
||||
assertEquals(0, link.reconcileOwnedWorlds());
|
||||
link.updateWorld(world, "world_iris_mvtest", "overworld");
|
||||
link.prepareOwnedWorldLoad("world_iris_mvtest");
|
||||
assertFalse(link.adoptOwnedWorld("world_iris_mvtest"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEnabledButUnloadedMultiverseDegradesInsteadOfThrowing() {
|
||||
// Iris is asked for generators while Multiverse is still enabling, so plugin enablement alone
|
||||
// must never be treated as a usable API.
|
||||
withMultiverse(true, link -> {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn("world_iris_mvtest");
|
||||
|
||||
assertFalse("MultiverseCoreApi has no instance until Multiverse finishes enabling",
|
||||
link.isActive());
|
||||
assertFalse(link.removeIfPresent(world));
|
||||
assertFalse(link.removeFromConfig("world_iris_mvtest"));
|
||||
assertEquals(0, link.reconcileOwnedWorlds());
|
||||
link.updateWorld(world, "world_iris_mvtest", "overworld");
|
||||
link.prepareOwnedWorldLoad("world_iris_mvtest");
|
||||
assertFalse(link.adoptOwnedWorld("world_iris_mvtest"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBlankWorldNameIsNotAnErrorWhileMultiverseIsAbsent() {
|
||||
withMultiverse(false, link -> {
|
||||
assertFalse(link.removeFromConfig(" "));
|
||||
assertFalse(link.removeFromConfig(null));
|
||||
link.prepareOwnedWorldLoad(null);
|
||||
assertFalse(link.adoptOwnedWorld(" "));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisOwnershipOfAWorldNameFollowsItsStorage() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("ownership", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
|
||||
assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_mvtest", levelRoot));
|
||||
assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", levelRoot));
|
||||
assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("iris:mvtest", levelRoot));
|
||||
assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_other", levelRoot));
|
||||
assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world", levelRoot));
|
||||
assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("../escape", levelRoot));
|
||||
assertFalse(MultiverseCoreLink.isIrisOwnedWorldName(null, levelRoot));
|
||||
assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", null));
|
||||
}
|
||||
|
||||
private static void withMultiverse(boolean enabled, java.util.function.Consumer<MultiverseCoreLink> body) {
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(enabled);
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
|
||||
body.accept(new MultiverseCoreLink());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.ConsoleCommandSender;
|
||||
import org.bukkit.event.server.ServerCommandEvent;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent;
|
||||
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
|
||||
import org.mvplugins.multiverse.external.vavr.control.Option;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MultiverseGuardListenerTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void multiverseCommandRootsAreRecognisedThroughEveryDispatchForm() {
|
||||
assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/mv clone a b"));
|
||||
assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("mv clone a b"));
|
||||
assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/multiverse-core:mv delete a"));
|
||||
assertEquals("mvclone", MultiverseGuardListener.multiverseCommandRoot("/MVClone a b"));
|
||||
assertEquals("mvregen", MultiverseGuardListener.multiverseCommandRoot("/mvregen a"));
|
||||
assertNull(MultiverseGuardListener.multiverseCommandRoot("/iris remove world=a"));
|
||||
assertNull(MultiverseGuardListener.multiverseCommandRoot("/tp somebody"));
|
||||
assertNull(MultiverseGuardListener.multiverseCommandRoot(" "));
|
||||
assertNull(MultiverseGuardListener.multiverseCommandRoot(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloneSourceAndDestinationAreReadFromEveryCloneAlias() {
|
||||
assertClone("mvtest", "copy", "/mv clone mvtest copy");
|
||||
assertClone("mvtest", "copy", "mv clone mvtest copy --reset-gamerules");
|
||||
assertClone("mvtest", "copy", "/mvcl mvtest copy");
|
||||
assertClone("mvtest", "copy", "/mvclone mvtest copy");
|
||||
assertClone("mvtest", "copy", "/mv clone mvtest copy");
|
||||
assertClone("mvtest", null, "/mvcl mvtest");
|
||||
assertClone("mvtest", null, "/mvcl mvtest --reset-gamerules");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadTargetsAreReadFromEveryLoadAlias() {
|
||||
assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest"));
|
||||
assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("mv LOAD world_iris_mvtest"));
|
||||
assertEquals("world_iris_mvtest",
|
||||
MultiverseGuardListener.multiverseLoad("/mvload world_iris_mvtest --skip-folder-check"));
|
||||
assertEquals("world_iris_mvtest",
|
||||
MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest"));
|
||||
assertNull(MultiverseGuardListener.multiverseLoad("/mv load"));
|
||||
assertNull(MultiverseGuardListener.multiverseLoad("/mvload"));
|
||||
assertNull(MultiverseGuardListener.multiverseLoad("/mv unload world_iris_mvtest"));
|
||||
assertNull(MultiverseGuardListener.multiverseLoad("/mv clone a b"));
|
||||
assertNull(MultiverseGuardListener.multiverseLoad("/iris load world_iris_mvtest"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse rebuilds an unloaded world from a WorldCreator carrying the environment it stored, and an
|
||||
* Iris world reports CUSTOM after its first restart, which CraftServer refuses outright. Iris owns the
|
||||
* load, so the command is taken over rather than left to fail.
|
||||
*/
|
||||
@Test
|
||||
public void loadingAnUnloadedIrisWorldIsDelegatedToIris() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-delegate", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
RecordingLoader loader = new RecordingLoader(true, "Loaded world_iris_mvtest.");
|
||||
IrisServices.register(ManagedWorldLoader.class, loader);
|
||||
try {
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue("Multiverse must never reach CraftServer.createWorld for an Iris world",
|
||||
event.isCancelled());
|
||||
});
|
||||
assertEquals("world_iris_mvtest", loader.requested);
|
||||
} finally {
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse takes the bare key or iris:<key> just as happily as the startup name; Iris has to load the
|
||||
* one name the world is registered under either way.
|
||||
*/
|
||||
@Test
|
||||
public void anAliasFormOfAnIrisWorldIsLoadedUnderItsStartupName() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-alias", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
RecordingLoader loader = new RecordingLoader(true, "Loaded.");
|
||||
IrisServices.register(ManagedWorldLoader.class, loader);
|
||||
try {
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv load iris:mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue(event.isCancelled());
|
||||
});
|
||||
assertEquals("world_iris_mvtest", loader.requested);
|
||||
} finally {
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingAnIrisWorldThatIsAlreadyLiveIsLeftToMultiverse() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-live", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
RecordingLoader loader = new RecordingLoader(true, "Loaded.");
|
||||
IrisServices.register(ManagedWorldLoader.class, loader);
|
||||
try {
|
||||
withServer(levelRoot, irisWorld("world_iris_mvtest"), listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse("Multiverse adopts a live world without a WorldCreator", event.isCancelled());
|
||||
});
|
||||
assertNull(loader.requested);
|
||||
} finally {
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingAWorldIrisDoesNotOwnIsLeftAlone() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-vanilla", "world");
|
||||
RecordingLoader loader = new RecordingLoader(true, "Loaded.");
|
||||
IrisServices.register(ManagedWorldLoader.class, loader);
|
||||
try {
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv load plots");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse(event.isCancelled());
|
||||
});
|
||||
assertNull(loader.requested);
|
||||
} finally {
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a loader the command is still refused: letting Multiverse through would either fail on the
|
||||
* CUSTOM environment or build a vanilla world beside the level root under the Iris world's name.
|
||||
*/
|
||||
@Test
|
||||
public void loadingAnIrisWorldWithNoLoaderRegisteredIsRefusedRatherThanPassedThrough() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-no-loader", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue(event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Taking the command over must never do for a sender what Multiverse would have refused them.
|
||||
*/
|
||||
@Test
|
||||
public void loadingAnIrisWorldWithoutTheMultiversePermissionIsLeftToMultiverse() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-unprivileged", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
RecordingLoader loader = new RecordingLoader(true, "Loaded.");
|
||||
IrisServices.register(ManagedWorldLoader.class, loader);
|
||||
try {
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = unprivilegedCommand("mv load world_iris_mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse(event.isCancelled());
|
||||
});
|
||||
assertNull(loader.requested);
|
||||
} finally {
|
||||
IrisServices.remove(ManagedWorldLoader.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail of a delegated load runs on the main thread and hands the world to Multiverse. Without
|
||||
* Multiverse it must still settle, in both directions, rather than throw into the scheduler.
|
||||
*/
|
||||
@Test
|
||||
public void aSettledLoadHandsTheWorldToMultiverseAndNeverThrows() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("load-settle", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
|
||||
withServer(levelRoot, null, listener -> {
|
||||
listener.settleLoad(null, "world_iris_mvtest",
|
||||
new ManagedWorldLoader.ManagedWorldLoad(true, "Loaded."), null);
|
||||
listener.settleLoad(null, "world_iris_mvtest",
|
||||
new ManagedWorldLoader.ManagedWorldLoad(false, "Storage is missing."), null);
|
||||
listener.settleLoad(null, "world_iris_mvtest", null, new IllegalStateException("boom"));
|
||||
listener.settleLoad(null, "world_iris_mvtest", null, null);
|
||||
});
|
||||
}
|
||||
|
||||
private static final class RecordingLoader implements ManagedWorldLoader {
|
||||
private final boolean loaded;
|
||||
private final String message;
|
||||
private volatile String requested;
|
||||
|
||||
private RecordingLoader(boolean loaded, String message) {
|
||||
this.loaded = loaded;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ManagedWorldLoad> load(String configuredWorldName) {
|
||||
requested = configuredWorldName;
|
||||
return CompletableFuture.completedFuture(new ManagedWorldLoad(loaded, message));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonCloneMultiverseCommandsCarryNoCloneSource() {
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/mv delete mvtest"));
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/mv regen mvtest"));
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/mv clone"));
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/mv"));
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/mvcl"));
|
||||
assertNull(MultiverseGuardListener.multiverseClone("/iris create mvtest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloningOntoAnIrisWorldSlotIsRefused() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("clone-destination", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv clone plots world_iris_mvtest");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue("Multiverse resolves the vanilla path and never sees the Iris slot",
|
||||
event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloningOntoAFreeNameIsLeftAlone() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("clone-destination-free", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv clone plots plots_copy");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse(event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertClone(String source, String destination, String commandLine) {
|
||||
MultiverseGuardListener.CloneCommand clone = MultiverseGuardListener.multiverseClone(commandLine);
|
||||
assertEquals(commandLine, new MultiverseGuardListener.CloneCommand(source, destination), clone);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseDeleteOfALoadedIrisWorldIsCancelled() {
|
||||
World world = irisWorld("world_iris_mvtest");
|
||||
LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", world);
|
||||
MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
|
||||
|
||||
withServer(emptyLevelRoot("delete-iris"), listener -> listener.onMultiverseWorldDelete(event));
|
||||
|
||||
assertTrue("mv delete and mv regen both funnel through this event", event.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseDeleteOfAWorldIrisDoesNotOwnIsLeftAlone() {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn("plots");
|
||||
LoadedMultiverseWorld multiverseWorld = multiverseWorld("plots", world);
|
||||
MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
|
||||
|
||||
withServer(emptyLevelRoot("delete-vanilla"), listener -> listener.onMultiverseWorldDelete(event));
|
||||
|
||||
assertFalse(event.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseDeleteOfAnUnloadedIrisWorldIsCancelledFromStorage() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("delete-unloaded", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", null);
|
||||
MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
|
||||
|
||||
withServer(levelRoot, listener -> listener.onMultiverseWorldDelete(event));
|
||||
|
||||
assertTrue(event.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloningALoadedIrisWorldIsRefusedAtTheCommand() {
|
||||
World world = irisWorld("world_iris_mvtest");
|
||||
|
||||
withServer(emptyLevelRoot("clone-iris"), world, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv clone world_iris_mvtest copy");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue("Multiverse fires no cancellable event for a clone", event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloningAnUnloadedIrisWorldIsRefusedFromStorage() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("clone-unloaded", "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
|
||||
|
||||
withServer(levelRoot, null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mvclone world_iris_mvtest copy");
|
||||
listener.onServerCommand(event);
|
||||
assertTrue(event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloningAWorldIrisDoesNotOwnIsLeftAlone() {
|
||||
withServer(emptyLevelRoot("clone-vanilla"), null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("mv clone plots copy");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse(event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedCommandsAreNeverInspected() {
|
||||
withServer(emptyLevelRoot("clone-unrelated"), null, listener -> {
|
||||
ServerCommandEvent event = consoleCommand("iris remove world=world_iris_mvtest delete=true");
|
||||
listener.onServerCommand(event);
|
||||
assertFalse(event.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
private static ServerCommandEvent consoleCommand(String command) {
|
||||
ConsoleCommandSender sender = mock(ConsoleCommandSender.class);
|
||||
when(sender.hasPermission(org.mockito.ArgumentMatchers.anyString())).thenReturn(true);
|
||||
return new ServerCommandEvent(sender, command);
|
||||
}
|
||||
|
||||
private static ServerCommandEvent unprivilegedCommand(String command) {
|
||||
return new ServerCommandEvent(mock(ConsoleCommandSender.class), command);
|
||||
}
|
||||
|
||||
private File emptyLevelRoot(String scope) {
|
||||
try {
|
||||
return temporaryFolder.newFolder(scope, "world");
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static World irisWorld(String name) {
|
||||
World world = mock(World.class);
|
||||
when(world.getName()).thenReturn(name);
|
||||
when(world.getGenerator()).thenReturn(mock(BukkitChunkGenerator.class));
|
||||
return world;
|
||||
}
|
||||
|
||||
private static LoadedMultiverseWorld multiverseWorld(String name, World bukkitWorld) {
|
||||
LoadedMultiverseWorld multiverseWorld = mock(LoadedMultiverseWorld.class);
|
||||
when(multiverseWorld.getName()).thenReturn(name);
|
||||
when(multiverseWorld.asLoadedWorld()).thenReturn(Option.of(multiverseWorld));
|
||||
when(multiverseWorld.getBukkitWorld()).thenReturn(Option.of(bukkitWorld));
|
||||
return multiverseWorld;
|
||||
}
|
||||
|
||||
private static void withServer(File levelRoot, java.util.function.Consumer<MultiverseGuardListener> body) {
|
||||
withServer(levelRoot, null, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiverse is left disabled so the listener resolves everything from Bukkit and Iris storage; that is
|
||||
* the shape the guard has to hold in whether or not the Multiverse API answers.
|
||||
*/
|
||||
private static void withServer(
|
||||
File levelRoot,
|
||||
World namedWorld,
|
||||
java.util.function.Consumer<MultiverseGuardListener> body
|
||||
) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(false);
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(levelRoot.getParentFile());
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
|
||||
if (namedWorld != null) {
|
||||
bukkit.when(() -> Bukkit.getWorld(namedWorld.getName())).thenReturn(namedWorld);
|
||||
}
|
||||
body.accept(new MultiverseGuardListener(new MultiverseCoreLink()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.core.link.MultiverseGuardListener;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.PluginDisableEvent;
|
||||
import org.bukkit.event.server.PluginEnableEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MultiverseSVCTest {
|
||||
@After
|
||||
public void unbindLink() {
|
||||
IrisServices.remove(MultiverseCoreLink.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anAbsentMultiverseLeavesTheGuardUnregistered() {
|
||||
MultiverseCoreLink link = bindLink(false);
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
|
||||
run(plugin, service -> service.onEnable());
|
||||
|
||||
verify(plugin, never()).registerListener(any());
|
||||
verify(link, never()).reconcileOwnedWorlds();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anAlreadyEnabledMultiverseIsPickedUpAtServiceEnable() {
|
||||
MultiverseCoreLink link = bindLink(true);
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
|
||||
run(plugin, service -> service.onEnable());
|
||||
|
||||
verify(plugin).registerListener(isA(MultiverseGuardListener.class));
|
||||
verify(link).reconcileOwnedWorlds();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseEnablingAfterIrisRegistersTheGuardExactlyOnce() {
|
||||
MultiverseCoreLink link = bindLink(true);
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
|
||||
run(plugin, service -> {
|
||||
service.onEnable();
|
||||
service.onPluginEnable(new PluginEnableEvent(namedPlugin("Multiverse-Core")));
|
||||
});
|
||||
|
||||
verify(plugin, times(1)).registerListener(any(Listener.class));
|
||||
verify(link, times(1)).reconcileOwnedWorlds();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnrelatedPluginNeverTouchesTheMultiverseLink() {
|
||||
MultiverseCoreLink link = bindLink(true);
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
|
||||
run(plugin, service -> {
|
||||
service.onPluginEnable(new PluginEnableEvent(namedPlugin("WorldEdit")));
|
||||
service.onPluginDisable(new PluginDisableEvent(namedPlugin("WorldEdit")));
|
||||
});
|
||||
|
||||
verify(plugin, never()).registerListener(any());
|
||||
verify(link, never()).reconcileOwnedWorlds();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiverseDisablingTakesTheGuardBackDown() {
|
||||
bindLink(true);
|
||||
VolmitPlugin plugin = mock(VolmitPlugin.class);
|
||||
|
||||
run(plugin, service -> {
|
||||
service.onEnable();
|
||||
service.onPluginDisable(new PluginDisableEvent(namedPlugin("Multiverse-Core")));
|
||||
});
|
||||
|
||||
verify(plugin).unregisterListener(isA(MultiverseGuardListener.class));
|
||||
}
|
||||
|
||||
private static MultiverseCoreLink bindLink(boolean active) {
|
||||
MultiverseCoreLink link = mock(MultiverseCoreLink.class);
|
||||
when(link.isActive()).thenReturn(active);
|
||||
IrisServices.register(MultiverseCoreLink.class, link);
|
||||
return link;
|
||||
}
|
||||
|
||||
private static Plugin namedPlugin(String name) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.getName()).thenReturn(name);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void run(VolmitPlugin plugin, java.util.function.Consumer<MultiverseSVC> body) {
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class)) {
|
||||
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
|
||||
platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin);
|
||||
body.accept(new MultiverseSVC());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.VanishedWorldStorage;
|
||||
import art.arcane.iris.engine.object.IrisEngineData;
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Rule;
|
||||
@@ -12,14 +13,81 @@ import java.nio.file.Files;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisEngineDataPersistenceTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void atomicWriteRefusesToRebuildADeletedWorldFolder() throws Exception {
|
||||
File worldFolder = temporaryFolder.newFolder("deleted-world");
|
||||
File output = new File(worldFolder, "iris/engine-data/dimension.json");
|
||||
assertTrue(worldFolder.delete());
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> EngineDataStore.writeEngineDataAtomically(output, new IrisEngineData()));
|
||||
|
||||
assertFalse("a save must never resurrect a world tree", worldFolder.exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanishedWorldStorageIsDetectedAndReported() throws Exception {
|
||||
VanishedWorldStorage.reset();
|
||||
File worldFolder = temporaryFolder.newFolder("vanishing-world");
|
||||
|
||||
assertFalse(VanishedWorldStorage.vanished(worldFolder));
|
||||
assertTrue(worldFolder.delete());
|
||||
assertTrue(VanishedWorldStorage.vanished(worldFolder));
|
||||
VanishedWorldStorage.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* A save-all after a hot delete writes the level's own data/*.dat files back, which recreates the world
|
||||
* folder. That folder is not the world returning, and letting Iris resume writing into it rebuilds an
|
||||
* iris/ directory that makes the next boot classify the husk as unusable on some runs and empty on
|
||||
* others.
|
||||
*/
|
||||
@Test
|
||||
public void aWorldFolderTheServerRecreatesNeverReEnablesPersistence() throws Exception {
|
||||
VanishedWorldStorage.reset();
|
||||
File worldFolder = temporaryFolder.newFolder("recreated-world");
|
||||
|
||||
assertFalse(VanishedWorldStorage.vanished(worldFolder));
|
||||
assertTrue(worldFolder.delete());
|
||||
assertTrue(VanishedWorldStorage.vanished(worldFolder));
|
||||
assertTrue(worldFolder.mkdirs());
|
||||
|
||||
assertTrue("a deleted world stays deleted for this JVM", VanishedWorldStorage.vanished(worldFolder));
|
||||
VanishedWorldStorage.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* The world folder can be back before Iris ever looks at it, so the latch alone is not enough: a tree
|
||||
* Iris has already written that is now missing is the same delete.
|
||||
*/
|
||||
@Test
|
||||
public void anEstablishedIrisTreeThatIsGoneCountsAsVanishedUnderARecreatedFolder() throws Exception {
|
||||
VanishedWorldStorage.reset();
|
||||
File worldFolder = temporaryFolder.newFolder("hot-deleted-world");
|
||||
File engineData = new File(worldFolder, "iris/engine-data");
|
||||
assertTrue(engineData.mkdirs());
|
||||
|
||||
assertFalse(VanishedWorldStorage.vanished(worldFolder, engineData));
|
||||
assertTrue(engineData.delete());
|
||||
assertTrue(new File(worldFolder, "iris").delete());
|
||||
|
||||
assertTrue(VanishedWorldStorage.vanished(worldFolder, engineData));
|
||||
assertTrue("the verdict holds without the established tree too",
|
||||
VanishedWorldStorage.vanished(worldFolder));
|
||||
VanishedWorldStorage.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atomicWriteCreatesAndReplacesEngineData() throws Exception {
|
||||
File folder = temporaryFolder.newFolder("engine-data");
|
||||
File worldFolder = temporaryFolder.newFolder("live-world");
|
||||
File folder = new File(worldFolder, "iris/engine-data");
|
||||
File output = new File(folder, "dimension.json");
|
||||
IrisEngineData first = new IrisEngineData();
|
||||
first.getStatistics().setVersion(10);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package art.arcane.iris.engine.data.cache;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class AtomicCacheTest {
|
||||
@Test
|
||||
public void aquireOnceOrThrowCachesTheComputedValue() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
assertEquals("value", cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return "value";
|
||||
}));
|
||||
assertEquals("value", cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return "other";
|
||||
}));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* The retrying variant is what a caller that can repair the cause between attempts needs, and the
|
||||
* world generator relies on it, so memoization must stay confined to aquireOnceOrThrow.
|
||||
*/
|
||||
@Test
|
||||
public void aquireOrThrowStillRetriesAFailedSupplier() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> cache.aquireOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
throw new IllegalStateException("repairable");
|
||||
}));
|
||||
assertEquals("repaired", cache.aquireOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return "repaired";
|
||||
}));
|
||||
assertEquals(2, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aquireOnceOrThrowMemoizesTheSupplierFailureInsteadOfRerunningIt() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
IllegalStateException cause = new IllegalStateException("Frozen Iris pack snapshot is missing");
|
||||
|
||||
IllegalStateException first = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
throw cause;
|
||||
}));
|
||||
IllegalStateException second = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return "recovered";
|
||||
}));
|
||||
|
||||
assertSame(cause, first);
|
||||
assertSame(cause, second);
|
||||
assertEquals("the failure is stated once and replayed, never recomputed", 1, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aquireOnceOrThrowRejectsANullValueAndRemembersThat() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return null;
|
||||
}));
|
||||
assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
|
||||
calls.incrementAndGet();
|
||||
return "recovered";
|
||||
}));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetClearsAMemoizedFailure() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
|
||||
throw new IllegalStateException("transient");
|
||||
}));
|
||||
cache.reset();
|
||||
|
||||
assertEquals("recovered", cache.aquireOnceOrThrow(() -> "recovered"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aquireStillSwallowsFailuresForOptionalValues() {
|
||||
AtomicCache<String> cache = new AtomicCache<>();
|
||||
|
||||
assertNull(cache.aquire(() -> {
|
||||
throw new IllegalStateException("optional value");
|
||||
}));
|
||||
assertEquals("later", cache.aquire(() -> "later"));
|
||||
}
|
||||
}
|
||||
@@ -4,65 +4,115 @@ import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Severity is stated by the caller and honoured by every adapter, so the levels {@link IrisLogging} offers
|
||||
* decide what an operator sees in a WARN-level scan. The once helpers exist for conditions that are worth
|
||||
* exactly one warning and would otherwise repeat per block, per sample or per chunk.
|
||||
*/
|
||||
public class IrisLoggingTest {
|
||||
private final List<LogLevel> levels = new ArrayList<>();
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private IrisPlatform previousPlatform;
|
||||
|
||||
@Before
|
||||
public void resetBinding() {
|
||||
public void captureLog() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
levels.clear();
|
||||
messages.clear();
|
||||
bindCapturingPlatform();
|
||||
}
|
||||
|
||||
@After
|
||||
public void clearBinding() {
|
||||
public void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lifecycle line is not a warning, but it is what an operator reads logs/latest.log for. NOTICE is the
|
||||
* level adapters route to their own logger at INFO instead of to the console sender.
|
||||
*/
|
||||
@Test
|
||||
public void noticeCarriesItsOwnLevel() {
|
||||
IrisLogging.notice("Engine init: %s", "world");
|
||||
|
||||
assertEquals(List.of(LogLevel.NOTICE), levels);
|
||||
assertEquals("Engine init: world", messages.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contextualReportPrintsFullStacktraceWithBoundPlatform() {
|
||||
IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS);
|
||||
IrisPlatforms.bind(platform);
|
||||
IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner"));
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream originalErr = System.err;
|
||||
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
IrisLogging.reportError("Runtime world creation failed.", failure);
|
||||
} finally {
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
public void warnOnceStatesTheConditionOnceAndDropsRepeatsToDebug() {
|
||||
String key = uniqueKey();
|
||||
|
||||
verify(platform).log(LogLevel.ERROR, "Runtime world creation failed.");
|
||||
verify(platform).reportError("Runtime world creation failed.", failure);
|
||||
verify(platform).reportError(failure);
|
||||
String text = output.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(text.contains("IllegalStateException"));
|
||||
assertTrue(text.contains("IllegalArgumentException"));
|
||||
assertTrue(text.contains("inner"));
|
||||
assertTrue(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
|
||||
assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
|
||||
assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
|
||||
|
||||
assertEquals(List.of(LogLevel.WARN, LogLevel.DEBUG, LogLevel.DEBUG), levels);
|
||||
assertTrue(messages.toString(), messages.stream().allMatch(m -> m.equals("Can't find block data for mod:block")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contextualReportHonorsPlatformOverrideSuppression() {
|
||||
public void aDistinctKeyIsItsOwnWarning() {
|
||||
assertTrue(IrisLogging.warnOnce(uniqueKey(), "first"));
|
||||
assertTrue(IrisLogging.warnOnce(uniqueKey(), "second"));
|
||||
|
||||
assertEquals(List.of(LogLevel.WARN, LogLevel.WARN), levels);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorOnceKeepsTheSameContract() {
|
||||
String key = uniqueKey();
|
||||
|
||||
assertTrue(IrisLogging.errorOnce(key, "Atomic cache supplier failed"));
|
||||
assertFalse(IrisLogging.errorOnce(key, "Atomic cache supplier failed"));
|
||||
|
||||
assertEquals(List.of(LogLevel.ERROR, LogLevel.DEBUG), levels);
|
||||
}
|
||||
|
||||
/**
|
||||
* An operator fixes the pack and reloads. Holding the key for the life of the JVM would hide whether the
|
||||
* fix worked, so unbinding the platform - which is what a reload does - clears them.
|
||||
*/
|
||||
@Test
|
||||
public void unbindingThePlatformClearsTheOnceKeys() {
|
||||
String key = uniqueKey();
|
||||
assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains"));
|
||||
|
||||
IrisPlatforms.unbind();
|
||||
levels.clear();
|
||||
bindCapturingPlatform();
|
||||
|
||||
assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains"));
|
||||
assertEquals(List.of(LogLevel.WARN), levels);
|
||||
}
|
||||
|
||||
private void bindCapturingPlatform() {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
doAnswer(invocation -> {
|
||||
levels.add(invocation.getArgument(0, LogLevel.class));
|
||||
messages.add(invocation.getArgument(1, String.class));
|
||||
return null;
|
||||
}).when(platform).log(any(LogLevel.class), anyString());
|
||||
IrisPlatforms.bind(platform);
|
||||
IllegalStateException failure = new IllegalStateException("suppressed");
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream originalErr = System.err;
|
||||
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
IrisLogging.reportError("Throttled failure.", failure);
|
||||
} finally {
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
|
||||
verify(platform).reportError("Throttled failure.", failure);
|
||||
assertFalse(output.toString(StandardCharsets.UTF_8).contains("suppressed"));
|
||||
private static String uniqueKey() {
|
||||
return "iris-logging-test:" + UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ eco = "2026.27" # https://repo.auxilor.io/repository/maven-public/com/willfp/eco
|
||||
mythic = "5.12.1"
|
||||
mythic-crucible = "2.2.0"
|
||||
kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml
|
||||
multiverseCore = "5.7.2"
|
||||
multiverseCore = "5.8.0"
|
||||
craftengine = "26.7.3" # https://github.com/Xiao-MoMi/craft-engine/releases
|
||||
|
||||
# Fabric API (net.fabricmc.fabric-api) - each module is independently versioned
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
package art.arcane.iris.spi;
|
||||
|
||||
import java.util.IllegalFormatException;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,8 @@ import java.util.regex.Pattern;
|
||||
* Internal to Iris; not a published integration surface.
|
||||
*/
|
||||
public final class IrisLogging {
|
||||
private static final int MAX_ONCE_KEYS = 512;
|
||||
private static final Set<String> ONCE_KEYS = ConcurrentHashMap.newKeySet();
|
||||
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
|
||||
private static final Pattern MINI_MESSAGE_TAG = Pattern.compile("(?i)</?(?:reset|bold|b|italic|i|underlined?|u|strikethrough|st|obfuscated?|obf|black|dark_blue|dark_green|dark_aqua|dark_red|dark_purple|gold|gray|dark_gray|blue|green|aqua|red|light_purple|yellow|white|gradient|font|hover|click|rainbow)(?::[^>\\n]{0,96})?>|<#[0-9a-f]{6}>");
|
||||
|
||||
@@ -54,6 +58,14 @@ public final class IrisLogging {
|
||||
emit(LogLevel.DEBUG, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs at {@link LogLevel#NOTICE}, applying {@link #format(String, Object...)} to the arguments. For the
|
||||
* few lifecycle milestones per boot that have to survive into the server's own log file.
|
||||
*/
|
||||
public static void notice(String format, Object... args) {
|
||||
emit(LogLevel.NOTICE, format(format, args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs at {@link LogLevel#WARN}, applying {@link #format(String, Object...)} to the arguments.
|
||||
*/
|
||||
@@ -68,6 +80,25 @@ public final class IrisLogging {
|
||||
emit(LogLevel.ERROR, format(format, args));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs at {@link LogLevel#WARN} the first time {@code key} is seen and at {@link LogLevel#DEBUG} every
|
||||
* time after, for a condition that is worth stating once but is reached per block, per sample or per
|
||||
* chunk. Returns true when the warning was the one that got stated, so a caller can attach a stack trace
|
||||
* or other one-off detail to it.
|
||||
*
|
||||
* @see #resetOnceKeys()
|
||||
*/
|
||||
public static boolean warnOnce(String key, String format, Object... args) {
|
||||
return once(LogLevel.WARN, key, format, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #warnOnce(String, String, Object...)} at {@link LogLevel#ERROR}.
|
||||
*/
|
||||
public static boolean errorOnce(String key, String format, Object... args) {
|
||||
return once(LogLevel.ERROR, key, format, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a player-facing message to the console, preserving colour markup when a platform is bound and
|
||||
* stripping it via {@link #clean(String)} when one is not.
|
||||
@@ -156,6 +187,38 @@ public final class IrisLogging {
|
||||
return MINI_MESSAGE_TAG.matcher(legacyStripped).replaceAll("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets every key {@link #warnOnce(String, String, Object...)} and {@link #errorOnce} have claimed, so
|
||||
* the next occurrence is stated again. Called when the platform unbinds: an operator who fixes a pack and
|
||||
* reloads has to be able to see whether the fix took.
|
||||
*/
|
||||
static void resetOnceKeys() {
|
||||
ONCE_KEYS.clear();
|
||||
}
|
||||
|
||||
private static boolean once(LogLevel level, String key, String format, Object... args) {
|
||||
String message = format(format, args);
|
||||
if (!claim(key)) {
|
||||
emit(LogLevel.DEBUG, message);
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(level, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap is what keeps a key space nobody bounded - a block name, a biome name, a mod id - from becoming
|
||||
* a leak. Past it nothing new is claimed, which drops later conditions to debug rather than growing.
|
||||
*/
|
||||
private static boolean claim(String key) {
|
||||
if (ONCE_KEYS.size() >= MAX_ONCE_KEYS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ONCE_KEYS.add(key == null ? "null" : key);
|
||||
}
|
||||
|
||||
private static void emit(LogLevel level, String message) {
|
||||
LogLevel target = level == null ? LogLevel.INFO : level;
|
||||
if (IrisPlatforms.isBound()) {
|
||||
|
||||
@@ -47,9 +47,14 @@ public final class IrisPlatforms {
|
||||
|
||||
/**
|
||||
* Clears the binding. Safe to call when nothing is bound.
|
||||
* <p>
|
||||
* Also forgets the keys {@link IrisLogging#warnOnce(String, String, Object...)} has claimed. Unbinding is
|
||||
* what a reload does, and an operator who has just fixed a pack has to see whether the condition that was
|
||||
* stated once is still there.
|
||||
*/
|
||||
public static synchronized void unbind() {
|
||||
platform = null;
|
||||
IrisLogging.resetOnceKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,12 @@ public enum LogLevel {
|
||||
DEBUG,
|
||||
/** Normal operational messages. */
|
||||
INFO,
|
||||
/**
|
||||
* Lifecycle milestones an operator reads the server log to find. Not a problem, but adapters route it to
|
||||
* the host logger rather than to a console sender, so it survives into the log file the server writes.
|
||||
* Reserved for a handful of events per boot.
|
||||
*/
|
||||
NOTICE,
|
||||
/** Recoverable problems and misconfiguration. */
|
||||
WARN,
|
||||
/** Failures; usually paired with {@link IrisPlatform#reportError(Throwable)}. */
|
||||
|
||||
Reference in New Issue
Block a user