mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
d
This commit is contained in:
@@ -3,7 +3,7 @@ def mainClass = 'art.arcane.iris.Iris'
|
||||
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
||||
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||
.get()
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -756,6 +756,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisStartupValidation.begin();
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
pendingWorldReplacements.registerPlatformEntryListener();
|
||||
boolean enabled = enable();
|
||||
if (!enabled) {
|
||||
return;
|
||||
|
||||
+29
-17
@@ -22,13 +22,13 @@ import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
@@ -698,16 +698,17 @@ public final class BukkitWorldReconciler {
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||
try {
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||
String configuredWorldName = configuredWorldName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
if (generator == null || irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||
}
|
||||
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "...");
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
|
||||
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||
.generator(generator)
|
||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||
if (seed != null) {
|
||||
@@ -726,7 +727,7 @@ public final class BukkitWorldReconciler {
|
||||
|
||||
@Override
|
||||
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
|
||||
File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions");
|
||||
File dimensionsDirectory = new File(snapshotRoot(worldKey), "dimensions");
|
||||
if (!dimensionsDirectory.isDirectory()) {
|
||||
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
|
||||
}
|
||||
@@ -764,19 +765,30 @@ public final class BukkitWorldReconciler {
|
||||
|
||||
@Override
|
||||
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
|
||||
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
|
||||
boolean snapshotPresent = snapshotRoot.isDirectory();
|
||||
if (snapshotPresent) {
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
|
||||
}
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
||||
File snapshotRoot = snapshotRoot(worldKey);
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
|
||||
String configuredWorldName = configuredWorldName(worldKey);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||
if (irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
|
||||
}
|
||||
if (!snapshotPresent) {
|
||||
PackValidationRegistry.requireLoadable(irisDimension.getLoader().getDataFolder().getName());
|
||||
}
|
||||
|
||||
private File snapshotRoot(NamespacedKey worldKey) {
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
levelRoot,
|
||||
configuredWorldName(worldKey),
|
||||
worldKey
|
||||
);
|
||||
File expectedRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey);
|
||||
if (!dimensionRoot.toPath().toAbsolutePath().normalize()
|
||||
.equals(expectedRoot.toPath().toAbsolutePath().normalize())) {
|
||||
throw new IllegalStateException("Iris world storage does not match the current platform layout for "
|
||||
+ worldKey + ".");
|
||||
}
|
||||
return IrisWorldStorage.requireFrozenPackRoot(dimensionRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
@@ -11,6 +10,6 @@ public final class IrisStartupAdmissionListener implements Listener {
|
||||
public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) {
|
||||
IrisStartupValidation.denialReason().ifPresent(reason -> event.disallow(
|
||||
AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
|
||||
Component.text(reason + " Check the server console, correct the reported Iris state, and restart.")));
|
||||
reason + " Check the server console, correct the reported Iris state, and restart."));
|
||||
}
|
||||
}
|
||||
|
||||
+55
-40
@@ -29,12 +29,12 @@ 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.IrisPlatforms;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||
import lombok.NonNull;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.generator.BiomeProvider;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
@@ -231,9 +231,18 @@ public final class IrisWorldGeneratorResolver {
|
||||
|
||||
@Nullable
|
||||
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
||||
File pack = IrisWorldStorage.packRoot(worldKey);
|
||||
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||
String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName());
|
||||
File pack = IrisWorldStorage.frozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
levelRoot,
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
)
|
||||
.map(IrisWorldStorage::requireFrozenPackRoot)
|
||||
.orElse(null);
|
||||
IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id);
|
||||
if (dimension == null) dimension = IrisData.loadAnyDimension(id, null);
|
||||
if (dimension == null) {
|
||||
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
|
||||
@@ -279,57 +288,63 @@ public final class IrisWorldGeneratorResolver {
|
||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
||||
|
||||
IrisDimension dim = loadDimension(worldName, id);
|
||||
if (dim == null) {
|
||||
throw new RuntimeException("Can't find dimension " + id + "!");
|
||||
}
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
||||
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
|
||||
File dimensionPackRoot = dim.getLoader().getDataFolder();
|
||||
String packName = dimensionPackRoot.getName();
|
||||
try {
|
||||
if (snapshotRoot.toPath().toAbsolutePath().normalize()
|
||||
.equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) {
|
||||
requireSnapshotLoadable(snapshotRoot);
|
||||
} else {
|
||||
PackValidationRegistry.requireLoadable(packName);
|
||||
}
|
||||
return resolveFrozenWorldGenerator(worldName, id);
|
||||
} catch (RuntimeException failure) {
|
||||
Iris.reportError("Refusing to load configured Iris world '" + worldName
|
||||
+ "' because its frozen world-local pack snapshot could not be used.", failure);
|
||||
Bukkit.shutdown();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) {
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||
File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
levelRoot,
|
||||
worldName,
|
||||
worldKey
|
||||
);
|
||||
File expectedDimensionRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey);
|
||||
if (!dimensionRoot.toPath().toAbsolutePath().normalize()
|
||||
.equals(expectedDimensionRoot.toPath().toAbsolutePath().normalize())) {
|
||||
throw new IllegalStateException("Frozen Iris world storage does not match the current platform layout for "
|
||||
+ worldKey + ".");
|
||||
}
|
||||
File snapshotRoot = IrisWorldStorage.requireFrozenPackRoot(dimensionRoot);
|
||||
try {
|
||||
requireSnapshotLoadable(snapshotRoot);
|
||||
} catch (BrokenPackException exception) {
|
||||
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':");
|
||||
Iris.error("Refusing to create world '" + worldName + "' using broken snapshot at '"
|
||||
+ snapshotRoot + "':");
|
||||
for (String reason : exception.getReasons()) {
|
||||
Iris.error(" - " + reason);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
|
||||
Iris.debug("Assuming IrisDimension: " + dim.getName());
|
||||
IrisDimension dimension = IrisData.get(snapshotRoot).getDimensionLoader().load(id, false);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Frozen Iris pack snapshot at " + snapshotRoot
|
||||
+ " does not contain dimension " + id + ".");
|
||||
}
|
||||
|
||||
IrisWorld w = IrisWorld.builder()
|
||||
Iris.debug("Assuming IrisDimension: " + dimension.getName());
|
||||
|
||||
IrisWorld world = IrisWorld.builder()
|
||||
.platformIdentity(worldKey.toString())
|
||||
.name(worldName)
|
||||
.seed(1337)
|
||||
.worldFolder(IrisWorldStorage.dimensionRoot(worldKey))
|
||||
.minHeight(dim.getMinHeight())
|
||||
.maxHeight(dim.getMaxHeight())
|
||||
.worldFolder(dimensionRoot)
|
||||
.minHeight(dimension.getMinHeight())
|
||||
.maxHeight(dimension.getMaxHeight())
|
||||
.build();
|
||||
|
||||
Iris.debug("Generator Config: " + w.toString());
|
||||
Iris.debug("Generator Config: " + world);
|
||||
|
||||
File ff = new File(w.worldFolder(), "iris/pack");
|
||||
IrisDimension installedDimension = ff.isDirectory()
|
||||
? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false)
|
||||
: null;
|
||||
if (installedDimension == null) {
|
||||
dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder());
|
||||
if (dim == null) {
|
||||
throw new IllegalStateException("Failed to install dimension pack for " + id);
|
||||
}
|
||||
} else {
|
||||
dim = installedDimension;
|
||||
}
|
||||
requireSnapshotLoadable(ff);
|
||||
|
||||
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
|
||||
return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey());
|
||||
}
|
||||
|
||||
private record FreshValidation(
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class PaperWorldReplacementEntryListener implements Listener {
|
||||
private final PendingWorldReplacementManager manager;
|
||||
|
||||
public PaperWorldReplacementEntryListener(PendingWorldReplacementManager manager) {
|
||||
this.manager = Objects.requireNonNull(manager, "manager");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event) {
|
||||
UUID playerId = event.getConnection().getProfile().getId();
|
||||
if (playerId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PendingWorldReplacementManager.ReplacementEntryRedirect redirect = manager.prepareReplacementEntry(
|
||||
playerId,
|
||||
event.getSpawnLocation(),
|
||||
event.isNewPlayer()
|
||||
);
|
||||
if (redirect == null) {
|
||||
return;
|
||||
}
|
||||
Location location = redirect.location();
|
||||
event.setSpawnLocation(location);
|
||||
if (redirect.acknowledgementRequired()) {
|
||||
manager.expectReplacementEntryAcknowledgement(playerId, redirect.transactionId());
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
refuseUnsafeEntry(event, playerId, failure);
|
||||
} catch (Throwable failure) {
|
||||
refuseUnsafeEntry(event, playerId, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void refuseUnsafeEntry(AsyncPlayerSpawnLocationEvent event, UUID playerId, Throwable failure) {
|
||||
manager.reportUnsafeEntry(playerId, failure);
|
||||
event.getConnection().disconnect(Component.text(
|
||||
"Iris could not verify a safe login location after the Overworld replacement. Retry after startup completes."
|
||||
));
|
||||
}
|
||||
}
|
||||
+35
-2
@@ -451,18 +451,51 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
|
||||
|
||||
if (type == QueueEntryType.EXACT) {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName());
|
||||
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
|
||||
Path path = currentStorageRoot(levelRoot, key);
|
||||
return List.of(new DeleteTarget(key, path));
|
||||
}
|
||||
|
||||
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
|
||||
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
|
||||
Path path = currentStorageRoot(levelRoot, key);
|
||||
targets.add(new DeleteTarget(key, path));
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static Path currentStorageRoot(File levelRoot, NamespacedKey key) {
|
||||
File worldContainer = levelRoot.getAbsoluteFile().getParentFile();
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalArgumentException("Selected level root has no world container: " + levelRoot);
|
||||
}
|
||||
String configuredWorldName = IrisWorldStorage.configuredWorldName(key, levelRoot.getName());
|
||||
Path directRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key)
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
configuredWorldName,
|
||||
key
|
||||
).map(file -> file.toPath().toAbsolutePath().normalize()).orElse(directRoot);
|
||||
if (dimensionRoot.equals(directRoot)) {
|
||||
return directRoot;
|
||||
}
|
||||
Path configuredDimensionRoot = IrisWorldStorage.configuredDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
key
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
if (!dimensionRoot.equals(configuredDimensionRoot)) {
|
||||
throw new IllegalStateException("Iris world storage does not match the current platform layout.");
|
||||
}
|
||||
return IrisWorldStorage.configuredLevelRoot(worldContainer, levelRoot, key)
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException {
|
||||
|
||||
+419
@@ -8,6 +8,7 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSna
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementEntryGuard;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
|
||||
@@ -15,24 +16,32 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -40,23 +49,60 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class PendingWorldReplacementManager implements Listener {
|
||||
private static final WorldSlotKey OVERWORLD_KEY = WorldSlotKey.minecraft("overworld");
|
||||
private static final long SAFE_ENTRY_TIMEOUT_SECONDS = 30L;
|
||||
|
||||
private final Iris plugin;
|
||||
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
||||
private final Set<UUID> verificationInFlight = new HashSet<>();
|
||||
private final ConcurrentHashMap<UUID, UUID> pendingEntryAcknowledgements = new ConcurrentHashMap<>();
|
||||
private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard;
|
||||
private volatile Path overworldEntryWorldDirectory;
|
||||
private volatile CompletableFuture<Location> overworldSafeEntry;
|
||||
private volatile UUID overworldEntryAwaitingVerification;
|
||||
private volatile World overworldEntryWorld;
|
||||
private volatile boolean paperEntryListenerRegistered;
|
||||
|
||||
public PendingWorldReplacementManager(Iris plugin) {
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
}
|
||||
|
||||
public void registerPlatformEntryListener() {
|
||||
ClassLoader loader = getClass().getClassLoader();
|
||||
try {
|
||||
Class.forName("io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent", false, loader);
|
||||
} catch (ClassNotFoundException | LinkageError unavailable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Class<?> listenerType = Class.forName(
|
||||
"art.arcane.iris.core.PaperWorldReplacementEntryListener",
|
||||
true,
|
||||
loader
|
||||
);
|
||||
Constructor<?> constructor = listenerType.getConstructor(PendingWorldReplacementManager.class);
|
||||
Listener listener = (Listener) constructor.newInstance(this);
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin);
|
||||
paperEntryListenerRegistered = true;
|
||||
} catch (InvocationTargetException failure) {
|
||||
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
|
||||
throw new IllegalStateException("Paper replacement entry listener registration failed.", cause);
|
||||
} catch (ReflectiveOperationException | LinkageError failure) {
|
||||
throw new IllegalStateException("Paper replacement entry listener is unavailable.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
|
||||
NamespacedKey worldKey = IrisWorldStorage.replacementKeyFromName(
|
||||
requestedName,
|
||||
@@ -129,6 +175,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
paths.stage(),
|
||||
seedSelection
|
||||
);
|
||||
if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) {
|
||||
WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId);
|
||||
}
|
||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||
@@ -205,6 +254,20 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
public synchronized void processPendingStartupReplacements() {
|
||||
ArrayList<String> failures = new ArrayList<>();
|
||||
ArrayList<String> restartBoundaries = new ArrayList<>();
|
||||
try {
|
||||
loadOverworldEntryGuard();
|
||||
} catch (Throwable failure) {
|
||||
String message = "Iris could not load the Overworld replacement entry guard: " + detail(failure);
|
||||
Iris.reportError(message, failure);
|
||||
IrisStartupValidation.markPacksInvalid(List.of(message));
|
||||
return;
|
||||
}
|
||||
if (overworldEntryGuard != null && !paperEntryListenerRegistered) {
|
||||
String message = "A pending Overworld replacement requires the Paper safe-entry capability.";
|
||||
Iris.error(message);
|
||||
IrisStartupValidation.markPacksInvalid(List.of(message));
|
||||
return;
|
||||
}
|
||||
List<Transaction> transactions;
|
||||
try {
|
||||
transactions = loadTransactions();
|
||||
@@ -215,6 +278,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
return;
|
||||
}
|
||||
for (Transaction transaction : transactions) {
|
||||
if (OVERWORLD_KEY.equals(transaction.worldKey()) && transaction.phase() == Phase.PUBLISHED) {
|
||||
overworldEntryAwaitingVerification = transaction.id();
|
||||
}
|
||||
try {
|
||||
inspectStartupTransaction(transaction);
|
||||
} catch (RestartBoundaryRequired boundary) {
|
||||
@@ -229,6 +295,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
Iris.reportError(message, failure);
|
||||
}
|
||||
}
|
||||
scheduleLoadedOverworldEntryPreparation();
|
||||
if (!failures.isEmpty()) {
|
||||
IrisStartupValidation.markPacksInvalid(failures);
|
||||
}
|
||||
@@ -258,6 +325,245 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
scheduleRuntimeCapture(transaction, 0);
|
||||
}
|
||||
}
|
||||
scheduleLoadedOverworldEntryPreparation();
|
||||
}
|
||||
|
||||
private void scheduleLoadedOverworldEntryPreparation() {
|
||||
if (overworldEntryGuard == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
J.s(this::prepareLoadedOverworldEntry);
|
||||
} catch (Throwable failure) {
|
||||
failOverworldEntryPreparation(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareLoadedOverworldEntry() {
|
||||
World world;
|
||||
try {
|
||||
world = WorldIdentity.resolve(toNamespacedKey(OVERWORLD_KEY)).orElse(null);
|
||||
} catch (Throwable failure) {
|
||||
failOverworldEntryPreparation(failure);
|
||||
return;
|
||||
}
|
||||
if (world != null) {
|
||||
prepareOverworldEntry(world);
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareOverworldEntry(World world) {
|
||||
WorldReplacementEntryGuard.Entry guard = overworldEntryGuard;
|
||||
if (guard == null
|
||||
|| guard.transactionId().equals(overworldEntryAwaitingVerification)
|
||||
|| !OVERWORLD_KEY.equals(toWorldSlotKey(WorldIdentity.key(world)))) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture<Location> targetFuture;
|
||||
synchronized (this) {
|
||||
if (overworldSafeEntry != null) {
|
||||
return;
|
||||
}
|
||||
targetFuture = new CompletableFuture<>();
|
||||
overworldSafeEntry = targetFuture;
|
||||
overworldEntryWorld = world;
|
||||
}
|
||||
try {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (!(generator instanceof BukkitChunkGenerator bukkitGenerator)) {
|
||||
throw new IOException("The replaced Overworld does not have a Bukkit Iris generator.");
|
||||
}
|
||||
Location anchor = bukkitGenerator.getInitialSpawnLocation(world);
|
||||
int chunkX = anchor.getBlockX() >> 4;
|
||||
int chunkZ = anchor.getBlockZ() >> 4;
|
||||
WorldRuntimeControlService runtime = WorldRuntimeControlService.get();
|
||||
CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true);
|
||||
if (chunkFuture == null) {
|
||||
throw new IOException("The replacement spawn chunk request was not accepted.");
|
||||
}
|
||||
chunkFuture
|
||||
.thenCompose(chunk -> runtime.resolveSafeEntry(world, anchor))
|
||||
.thenCompose(this::applyOverworldSpawn)
|
||||
.thenCompose(this::persistOverworldSpawn)
|
||||
.whenComplete((safeEntry, failure) -> {
|
||||
if (failure != null) {
|
||||
targetFuture.completeExceptionally(failure);
|
||||
Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure);
|
||||
return;
|
||||
}
|
||||
targetFuture.complete(safeEntry.clone());
|
||||
retireOverworldEntryIfComplete(guard.transactionId());
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
targetFuture.completeExceptionally(failure);
|
||||
Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Location> applyOverworldSpawn(Location safeEntry) {
|
||||
Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone();
|
||||
World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world");
|
||||
CompletableFuture<Location> applied = new CompletableFuture<>();
|
||||
int chunkX = requiredSafeEntry.getBlockX() >> 4;
|
||||
int chunkZ = requiredSafeEntry.getBlockZ() >> 4;
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
if (!world.setSpawnLocation(requiredSafeEntry)) {
|
||||
throw new IOException("The server rejected the replacement spawn location.");
|
||||
}
|
||||
applied.complete(requiredSafeEntry.clone());
|
||||
} catch (Throwable failure) {
|
||||
applied.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
applied.completeExceptionally(new IOException("Could not schedule the replacement spawn update."));
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
private CompletableFuture<Location> persistOverworldSpawn(Location safeEntry) {
|
||||
Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone();
|
||||
World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world");
|
||||
CompletableFuture<Location> persisted = new CompletableFuture<>();
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
world.save();
|
||||
persisted.complete(requiredSafeEntry.clone());
|
||||
} catch (Throwable failure) {
|
||||
persisted.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
persisted.completeExceptionally(new IOException("Could not schedule replacement spawn persistence."));
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> inspectLoginCollision(Location location) {
|
||||
Location requiredLocation = Objects.requireNonNull(location, "location").clone();
|
||||
World world = Objects.requireNonNull(requiredLocation.getWorld(), "location.world");
|
||||
int chunkX = requiredLocation.getBlockX() >> 4;
|
||||
int chunkZ = requiredLocation.getBlockZ() >> 4;
|
||||
WorldRuntimeControlService runtime = WorldRuntimeControlService.get();
|
||||
CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true);
|
||||
if (chunkFuture == null) {
|
||||
return CompletableFuture.failedFuture(new IOException("The saved login chunk request was not accepted."));
|
||||
}
|
||||
return chunkFuture.thenCompose(chunk -> inspectLoadedLoginCollision(requiredLocation));
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> inspectLoadedLoginCollision(Location location) {
|
||||
World world = Objects.requireNonNull(location.getWorld(), "location.world");
|
||||
CompletableFuture<Boolean> inspected = new CompletableFuture<>();
|
||||
int chunkX = location.getBlockX() >> 4;
|
||||
int chunkZ = location.getBlockZ() >> 4;
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
int blockY = location.getBlockY();
|
||||
if (blockY < world.getMinHeight() || blockY + 1 >= world.getMaxHeight()) {
|
||||
inspected.complete(false);
|
||||
return;
|
||||
}
|
||||
boolean feetPassable = world.getBlockAt(location.getBlockX(), blockY, location.getBlockZ())
|
||||
.isPassable();
|
||||
boolean headPassable = world.getBlockAt(location.getBlockX(), blockY + 1, location.getBlockZ())
|
||||
.isPassable();
|
||||
inspected.complete(feetPassable && headPassable);
|
||||
} catch (Throwable failure) {
|
||||
inspected.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
inspected.completeExceptionally(new IOException("Could not schedule the saved login collision check."));
|
||||
}
|
||||
return inspected;
|
||||
}
|
||||
|
||||
private void completeOverworldEntry(UUID playerId, UUID transactionId) {
|
||||
try {
|
||||
J.a(() -> completeOverworldEntryAsync(playerId, transactionId));
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not record a completed Overworld replacement entry for " + playerId + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeOverworldEntryAsync(UUID playerId, UUID transactionId) {
|
||||
boolean retire = false;
|
||||
try {
|
||||
synchronized (this) {
|
||||
WorldReplacementEntryGuard.Entry current = overworldEntryGuard;
|
||||
Path worldDirectory = overworldEntryWorldDirectory;
|
||||
if (current == null
|
||||
|| worldDirectory == null
|
||||
|| !current.transactionId().equals(transactionId)
|
||||
|| !current.pendingPlayers().contains(playerId)) {
|
||||
return;
|
||||
}
|
||||
Optional<WorldReplacementEntryGuard.Entry> updated = WorldReplacementEntryGuard.completePlayer(
|
||||
worldDirectory,
|
||||
transactionId,
|
||||
playerId
|
||||
);
|
||||
overworldEntryGuard = updated.orElse(null);
|
||||
retire = overworldEntryGuard != null && overworldEntryGuard.pendingPlayers().isEmpty();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not record a completed Overworld replacement entry for " + playerId + ".", failure);
|
||||
return;
|
||||
}
|
||||
if (retire) {
|
||||
retireOverworldEntryIfCompleteAsync(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void retireOverworldEntryIfComplete(UUID transactionId) {
|
||||
try {
|
||||
J.a(() -> retireOverworldEntryIfCompleteAsync(transactionId));
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not schedule Overworld replacement entry marker retirement.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId) {
|
||||
WorldReplacementEntryGuard.Entry current = overworldEntryGuard;
|
||||
Path worldDirectory = overworldEntryWorldDirectory;
|
||||
CompletableFuture<Location> safeEntry = overworldSafeEntry;
|
||||
if (current == null
|
||||
|| worldDirectory == null
|
||||
|| !current.transactionId().equals(transactionId)
|
||||
|| !current.pendingPlayers().isEmpty()
|
||||
|| safeEntry == null
|
||||
|| !safeEntry.isDone()
|
||||
|| safeEntry.isCompletedExceptionally()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!WorldReplacementEntryGuard.retireIfEmpty(worldDirectory, transactionId)) {
|
||||
return;
|
||||
}
|
||||
overworldEntryGuard = null;
|
||||
overworldEntryWorldDirectory = null;
|
||||
overworldSafeEntry = null;
|
||||
overworldEntryWorld = null;
|
||||
pendingEntryAcknowledgements.entrySet().removeIf(entry -> entry.getValue().equals(transactionId));
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not retire the completed Overworld replacement entry marker.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
void reportUnsafeEntry(UUID playerId, Throwable failure) {
|
||||
Iris.reportError("Refused unsafe Overworld replacement entry for " + playerId + ".", failure);
|
||||
}
|
||||
|
||||
private synchronized void failOverworldEntryPreparation(Throwable failure) {
|
||||
CompletableFuture<Location> future = overworldSafeEntry;
|
||||
if (future == null) {
|
||||
future = new CompletableFuture<>();
|
||||
overworldSafeEntry = future;
|
||||
}
|
||||
future.completeExceptionally(failure);
|
||||
Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -269,9 +575,81 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure);
|
||||
return;
|
||||
}
|
||||
if (OVERWORLD_KEY.equals(worldKey)) {
|
||||
prepareOverworldEntry(event.getWorld());
|
||||
}
|
||||
J.a(() -> discoverLoadedWorldTransaction(worldKey));
|
||||
}
|
||||
|
||||
ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer)
|
||||
throws IOException, InterruptedException, ExecutionException, TimeoutException {
|
||||
WorldReplacementEntryGuard.Entry guard = overworldEntryGuard;
|
||||
if (guard == null || playerId == null) {
|
||||
return null;
|
||||
}
|
||||
boolean pendingPlayer = guard.pendingPlayers().contains(playerId);
|
||||
if (!pendingPlayer && !newPlayer) {
|
||||
return null;
|
||||
}
|
||||
Location requiredSavedLocation = Objects.requireNonNull(savedLocation, "savedLocation").clone();
|
||||
World replacementWorld = overworldEntryWorld;
|
||||
if (replacementWorld == null) {
|
||||
throw new IOException("The replacement Overworld is not ready.");
|
||||
}
|
||||
if (requiredSavedLocation.getWorld() != replacementWorld) {
|
||||
if (pendingPlayer) {
|
||||
completeOverworldEntry(playerId, guard.transactionId());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!newPlayer) {
|
||||
boolean collisionSafe = inspectLoginCollision(requiredSavedLocation)
|
||||
.get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (collisionSafe) {
|
||||
completeOverworldEntry(playerId, guard.transactionId());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
CompletableFuture<Location> safeEntry = overworldSafeEntry;
|
||||
if (safeEntry == null) {
|
||||
throw new IOException("The replacement safe spawn is not ready.");
|
||||
}
|
||||
Location prepared = safeEntry.get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS).clone();
|
||||
prepared.setYaw(requiredSavedLocation.getYaw());
|
||||
prepared.setPitch(requiredSavedLocation.getPitch());
|
||||
return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer);
|
||||
}
|
||||
|
||||
void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId) throws IOException {
|
||||
WorldReplacementEntryGuard.Entry guard = overworldEntryGuard;
|
||||
if (guard == null
|
||||
|| !guard.transactionId().equals(transactionId)
|
||||
|| !guard.pendingPlayers().contains(playerId)) {
|
||||
throw new IOException("The Overworld replacement entry receipt is no longer active.");
|
||||
}
|
||||
pendingEntryAcknowledgements.put(playerId, transactionId);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
UUID playerId = event.getPlayer().getUniqueId();
|
||||
UUID transactionId = pendingEntryAcknowledgements.remove(playerId);
|
||||
if (transactionId == null) {
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runEntity(event.getPlayer(), () -> {
|
||||
try {
|
||||
event.getPlayer().saveData();
|
||||
completeOverworldEntry(playerId, transactionId);
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not persist safe Overworld replacement entry for " + playerId + ".", failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
Iris.error("Could not schedule safe Overworld replacement entry persistence for " + playerId + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) {
|
||||
Transaction transaction;
|
||||
try {
|
||||
@@ -412,6 +790,10 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
try {
|
||||
writeTransaction(committed);
|
||||
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
|
||||
if (OVERWORLD_KEY.equals(transaction.worldKey())) {
|
||||
overworldEntryAwaitingVerification = null;
|
||||
scheduleLoadedOverworldEntryPreparation();
|
||||
}
|
||||
scheduleCommittedCleanup(committed);
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("The replacement for " + transaction.worldKey()
|
||||
@@ -456,6 +838,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
private void initiateRollback(Transaction transaction, Throwable failure) {
|
||||
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
||||
+ "; the retained world will be restored on restart.", failure);
|
||||
if (OVERWORLD_KEY.equals(transaction.worldKey())) {
|
||||
clearOverworldEntryGuard();
|
||||
}
|
||||
try {
|
||||
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||
writeTransaction(rollback);
|
||||
@@ -608,6 +993,24 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private synchronized void loadOverworldEntryGuard() throws IOException {
|
||||
ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY);
|
||||
Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory());
|
||||
overworldEntryGuard = loaded.orElse(null);
|
||||
overworldEntryWorldDirectory = loaded.isPresent() ? target.worldDirectory() : null;
|
||||
overworldSafeEntry = null;
|
||||
overworldEntryAwaitingVerification = null;
|
||||
}
|
||||
|
||||
private synchronized void clearOverworldEntryGuard() {
|
||||
overworldEntryGuard = null;
|
||||
overworldEntryWorldDirectory = null;
|
||||
overworldSafeEntry = null;
|
||||
overworldEntryAwaitingVerification = null;
|
||||
overworldEntryWorld = null;
|
||||
pendingEntryAcknowledgements.clear();
|
||||
}
|
||||
|
||||
private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException {
|
||||
return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
|
||||
}
|
||||
@@ -738,6 +1141,22 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
private record VanillaLevelContext(boolean allowNether, boolean allowEnd) {
|
||||
}
|
||||
|
||||
record ReplacementEntryRedirect(
|
||||
UUID transactionId,
|
||||
Location location,
|
||||
boolean acknowledgementRequired
|
||||
) {
|
||||
ReplacementEntryRedirect {
|
||||
Objects.requireNonNull(transactionId, "transactionId");
|
||||
location = Objects.requireNonNull(location, "location").clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Location location() {
|
||||
return location.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RestartBoundaryRequired extends IOException {
|
||||
private RestartBoundaryRequired(String message) {
|
||||
super(message);
|
||||
|
||||
@@ -801,8 +801,14 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
|
||||
boolean doesWorldExist(String worldName) {
|
||||
File worldDirectory = IrisWorldStorage.dimensionRoot(worldName);
|
||||
return worldDirectory.exists() && worldDirectory.isDirectory();
|
||||
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName);
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
return IrisWorldStorage.frozenDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
levelRoot,
|
||||
IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()),
|
||||
worldKey
|
||||
).isPresent();
|
||||
}
|
||||
|
||||
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
|
||||
|
||||
+55
@@ -93,6 +93,61 @@ public class IrisWorldGeneratorResolverTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredWorldResolutionUsesOnlyFrozenWorldLocalSnapshot() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java"));
|
||||
int resolverStart = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(");
|
||||
int resolverEnd = source.indexOf("private record FreshValidation", resolverStart);
|
||||
String resolver = source.substring(resolverStart, resolverEnd);
|
||||
|
||||
int dimensionRoot = resolver.indexOf("IrisWorldStorage.requireFrozenDimensionRoot(");
|
||||
int currentPlatformRoot = resolver.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int layoutRefusal = resolver.indexOf(
|
||||
"Frozen Iris world storage does not match the current platform layout",
|
||||
currentPlatformRoot
|
||||
);
|
||||
int snapshotRoot = resolver.indexOf("IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)");
|
||||
int validation = resolver.indexOf("requireSnapshotLoadable(snapshotRoot)");
|
||||
int exactLoad = resolver.indexOf(
|
||||
"IrisData.get(snapshotRoot).getDimensionLoader().load(id, false)"
|
||||
);
|
||||
int canonicalIdentity = resolver.indexOf(".platformIdentity(worldKey.toString())");
|
||||
int resolvedStorage = resolver.indexOf(".worldFolder(dimensionRoot)");
|
||||
|
||||
assertTrue(dimensionRoot >= 0);
|
||||
assertTrue(currentPlatformRoot > dimensionRoot);
|
||||
assertTrue(layoutRefusal > currentPlatformRoot);
|
||||
assertTrue(snapshotRoot > layoutRefusal);
|
||||
assertTrue(validation > snapshotRoot);
|
||||
assertTrue(exactLoad > validation);
|
||||
assertTrue(canonicalIdentity > exactLoad);
|
||||
assertTrue(resolvedStorage > canonicalIdentity);
|
||||
assertFalse(resolver.contains("loadDimension("));
|
||||
assertFalse(resolver.contains("loadAnyDimension("));
|
||||
assertFalse(resolver.contains("replaceIntoWorld("));
|
||||
assertFalse(resolver.contains("installIntoWorld("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredWorldSnapshotFailureStopsStartupAndRethrows() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java"));
|
||||
int resolverStart = source.indexOf("public ChunkGenerator resolveDefaultWorldGenerator(");
|
||||
int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart);
|
||||
String resolver = source.substring(resolverStart, resolverEnd);
|
||||
|
||||
int failureCapture = resolver.indexOf("catch (RuntimeException failure)");
|
||||
int report = resolver.indexOf("Iris.reportError(", failureCapture);
|
||||
int shutdown = resolver.indexOf("Bukkit.shutdown()", report);
|
||||
int rethrow = resolver.indexOf("throw failure", shutdown);
|
||||
|
||||
assertTrue(failureCapture >= 0);
|
||||
assertTrue(report > failureCapture);
|
||||
assertTrue(shutdown > report);
|
||||
assertTrue(rethrow > shutdown);
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
|
||||
+13
@@ -174,6 +174,19 @@ public class PendingWorldDeleteQueueTest {
|
||||
), family);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactLogicalEntryResolvesCurrentCraftBukkitLevelStorage() throws IOException {
|
||||
Path worldContainer = temporaryFolder.newFolder("configured-delete-server").toPath();
|
||||
File levelRoot = Files.createDirectory(worldContainer.resolve("world")).toFile();
|
||||
Path configuredLevelRoot = worldContainer.resolve("world_iris_alpha");
|
||||
Files.createDirectories(configuredLevelRoot.resolve("dimensions/iris/alpha"));
|
||||
|
||||
assertEquals(
|
||||
List.of(configuredLevelRoot.toAbsolutePath().normalize()),
|
||||
PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("retry-world");
|
||||
|
||||
+78
@@ -164,6 +164,84 @@ public class PendingWorldReplacementThreadAffinityTest {
|
||||
assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperLoginHookIsIsolatedFromAlwaysLoadedSpigotClasses() throws Exception {
|
||||
String managerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java"));
|
||||
String irisSource = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
|
||||
String listenerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java"));
|
||||
String registration = method(managerSource, "public void registerPlatformEntryListener()");
|
||||
|
||||
assertFalse(managerSource.contains("import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent"));
|
||||
assertFalse(managerSource.contains("AsyncPlayerSpawnLocationEvent event"));
|
||||
assertFalse(irisSource.contains("AsyncPlayerSpawnLocationEvent"));
|
||||
assertTrue(registration.contains("Class.forName(\"io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent\""));
|
||||
assertTrue(registration.contains("Class.forName("));
|
||||
assertTrue(registration.contains("PaperWorldReplacementEntryListener"));
|
||||
assertTrue(irisSource.contains("pendingWorldReplacements.registerPlatformEntryListener();"));
|
||||
assertTrue(listenerSource.contains("onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redirectedPlayerReceiptSurvivesUntilTheMaterializedPositionIsSaved() throws Exception {
|
||||
String managerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java"));
|
||||
String listenerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java"));
|
||||
String preparation = method(
|
||||
managerSource,
|
||||
"ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer)"
|
||||
);
|
||||
String acknowledgement = method(
|
||||
managerSource,
|
||||
"void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId)"
|
||||
);
|
||||
String join = method(managerSource, "public void onPlayerJoin(PlayerJoinEvent event)");
|
||||
String listener = method(
|
||||
listenerSource,
|
||||
"public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)"
|
||||
);
|
||||
|
||||
assertTrue(preparation.contains("return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer)"));
|
||||
assertFalse(preparation.substring(preparation.indexOf("CompletableFuture<Location> safeEntry"))
|
||||
.contains("completeOverworldEntry("));
|
||||
assertBefore(listener, "event.setSpawnLocation(location)",
|
||||
"manager.expectReplacementEntryAcknowledgement(playerId, redirect.transactionId())");
|
||||
assertTrue(acknowledgement.contains("pendingEntryAcknowledgements.put(playerId, transactionId)"));
|
||||
assertBefore(join, "event.getPlayer().saveData()", "completeOverworldEntry(playerId, transactionId)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replacementSpawnIsPersistedBeforeFinalMarkerRetirement() throws Exception {
|
||||
String managerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java"));
|
||||
String listenerSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java"));
|
||||
String preparation = method(managerSource, "private void prepareOverworldEntry(World world)");
|
||||
String persistence = method(managerSource, "private CompletableFuture<Location> persistOverworldSpawn(Location safeEntry)");
|
||||
String retirement = method(
|
||||
managerSource,
|
||||
"private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId)"
|
||||
);
|
||||
String listener = method(
|
||||
listenerSource,
|
||||
"public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)"
|
||||
);
|
||||
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
|
||||
|
||||
assertBefore(preparation, ".thenCompose(this::applyOverworldSpawn)",
|
||||
".thenCompose(this::persistOverworldSpawn)");
|
||||
assertBefore(preparation, "targetFuture.complete(safeEntry.clone())",
|
||||
"retireOverworldEntryIfComplete(guard.transactionId())");
|
||||
assertTrue(persistence.contains("world.save()"));
|
||||
assertTrue(retirement.contains("!current.pendingPlayers().isEmpty()"));
|
||||
assertTrue(retirement.contains("!safeEntry.isDone()"));
|
||||
assertTrue(retirement.contains("safeEntry.isCompletedExceptionally()"));
|
||||
assertTrue(listener.contains("event.isNewPlayer()"));
|
||||
assertTrue(generatorSource.contains("world.getHighestBlockYAt(initialSpawn) + 1"));
|
||||
}
|
||||
|
||||
private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState(
|
||||
WorldSlotKey worldKey,
|
||||
long seed,
|
||||
|
||||
Reference in New Issue
Block a user