mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
Fix Iris replacement
This commit is contained in:
@@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
J.s(() -> {
|
||||
pendingWorldReplacements.captureVanillaLevelContext();
|
||||
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes
|
||||
// whole pack trees; neither belongs on the tick thread.
|
||||
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
|
||||
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
||||
J.a(this::bstats);
|
||||
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
|
||||
J.sr(this::tickQueue, 0);
|
||||
|
||||
+194
-45
@@ -40,6 +40,7 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException;
|
||||
public final class PendingWorldReplacementManager implements Listener {
|
||||
private final Iris plugin;
|
||||
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
||||
private final Set<UUID> verificationInFlight = new HashSet<>();
|
||||
|
||||
public PendingWorldReplacementManager(Iris plugin) {
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
@@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
public synchronized StagedReplacement stageReplacement(
|
||||
VolmitSender sender,
|
||||
NamespacedKey worldKey,
|
||||
IrisDimension dimension
|
||||
IrisDimension dimension,
|
||||
Long requestedSeed
|
||||
) throws IOException {
|
||||
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
OptionalLong seedSelection = requestedSeed == null
|
||||
? OptionalLong.empty()
|
||||
: OptionalLong.of(requestedSeed.longValue());
|
||||
IrisStartupValidation.requireWorldReplacementStagingReady();
|
||||
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
|
||||
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
|
||||
@@ -93,7 +99,6 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
UUID transactionId = UUID.randomUUID();
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
|
||||
WorldReplacementFilesystem.requireExistingTarget(paths);
|
||||
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
|
||||
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
|
||||
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
||||
if (!datapacks.succeeded()) {
|
||||
@@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
throw new IOException("Iris could not stage the dimension pack.");
|
||||
}
|
||||
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
|
||||
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
|
||||
paths.target(),
|
||||
paths.stage(),
|
||||
seedSelection
|
||||
);
|
||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||
@@ -227,68 +237,160 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void verifyLoadedPublishedWorlds() {
|
||||
public void verifyLoadedPublishedWorlds() {
|
||||
J.a(this::discoverLoadedPublishedWorlds);
|
||||
}
|
||||
|
||||
private void discoverLoadedPublishedWorlds() {
|
||||
List<Transaction> transactions;
|
||||
try {
|
||||
for (Transaction transaction : loadTransactions()) {
|
||||
if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||
scheduleCommittedCleanup(transaction);
|
||||
} else if (transaction.phase() == Phase.PUBLISHED) {
|
||||
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
|
||||
.ifPresent(world -> verifyPublishedWorld(world, transaction));
|
||||
}
|
||||
synchronized (this) {
|
||||
transactions = loadTransactions();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
|
||||
return;
|
||||
}
|
||||
for (Transaction transaction : transactions) {
|
||||
if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||
scheduleCommittedCleanup(transaction);
|
||||
} else if (transaction.phase() == Phase.PUBLISHED) {
|
||||
scheduleRuntimeCapture(transaction, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
World world = event.getWorld();
|
||||
// One tick to let the load settle, then verify off-main: the body takes the manager
|
||||
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack
|
||||
// tree — blocking the main thread on either froze the server.
|
||||
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1);
|
||||
WorldSlotKey worldKey;
|
||||
try {
|
||||
worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld()));
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure);
|
||||
return;
|
||||
}
|
||||
J.a(() -> discoverLoadedWorldTransaction(worldKey));
|
||||
}
|
||||
|
||||
private synchronized void verifyPublishedWorldIfPending(World world) {
|
||||
private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) {
|
||||
Transaction transaction;
|
||||
try {
|
||||
Transaction transaction = findTransaction(WorldIdentity.key(world));
|
||||
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
|
||||
verifyPublishedWorld(world, transaction);
|
||||
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||
scheduleCommittedCleanup(transaction);
|
||||
synchronized (this) {
|
||||
transaction = findTransaction(worldKey);
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
|
||||
Iris.reportError("Failed to inspect a loaded Iris world replacement.", failure);
|
||||
return;
|
||||
}
|
||||
if (transaction == null) {
|
||||
return;
|
||||
}
|
||||
if (transaction.phase() == Phase.PUBLISHED) {
|
||||
scheduleRuntimeCapture(transaction, 1);
|
||||
} else if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||
scheduleCommittedCleanup(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyPublishedWorld(World world, Transaction transaction) {
|
||||
private void scheduleRuntimeCapture(Transaction transaction, int delayTicks) {
|
||||
synchronized (this) {
|
||||
if (!verificationInFlight.add(transaction.id())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
J.s(() -> captureLoadedPublishedWorldOnGlobal(transaction), delayTicks);
|
||||
} catch (Throwable failure) {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
Iris.reportError("Could not schedule runtime verification for " + transaction.worldKey() + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void captureLoadedPublishedWorldOnGlobal(Transaction transaction) {
|
||||
World world;
|
||||
try {
|
||||
world = WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())).orElse(null);
|
||||
} catch (Throwable failure) {
|
||||
dispatchRuntimeCaptureFailure(transaction, failure);
|
||||
return;
|
||||
}
|
||||
if (world == null) {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
return;
|
||||
}
|
||||
PublishedWorldRuntimeState runtimeState;
|
||||
try {
|
||||
runtimeState = capturePublishedWorldRuntime(world);
|
||||
} catch (Throwable failure) {
|
||||
dispatchRuntimeCaptureFailure(transaction, failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
J.a(() -> runPublishedWorldVerification(runtimeState, transaction));
|
||||
} catch (Throwable failure) {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
Iris.reportError("Could not dispatch runtime verification for " + transaction.worldKey() + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchRuntimeCaptureFailure(Transaction transaction, Throwable failure) {
|
||||
try {
|
||||
J.a(() -> runPublishedWorldCaptureFailure(transaction, failure));
|
||||
} catch (Throwable dispatchFailure) {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
dispatchFailure.addSuppressed(failure);
|
||||
Iris.reportError("Could not dispatch a failed runtime capture for "
|
||||
+ transaction.worldKey() + ".", dispatchFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void runPublishedWorldCaptureFailure(Transaction transaction, Throwable failure) {
|
||||
try {
|
||||
initiateRollback(transaction, failure);
|
||||
} finally {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
}
|
||||
}
|
||||
|
||||
private void runPublishedWorldVerification(
|
||||
PublishedWorldRuntimeState runtimeState,
|
||||
Transaction transaction
|
||||
) {
|
||||
try {
|
||||
verifyPublishedWorld(runtimeState, transaction);
|
||||
} finally {
|
||||
finishRuntimeVerification(transaction.id());
|
||||
}
|
||||
}
|
||||
|
||||
static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world) {
|
||||
World requiredWorld = Objects.requireNonNull(world, "world");
|
||||
WorldSlotKey worldKey = toWorldSlotKey(WorldIdentity.key(requiredWorld));
|
||||
boolean irisWorld = IrisToolbelt.isIrisWorld(requiredWorld);
|
||||
long seed = requiredWorld.getSeed();
|
||||
World.Environment bukkitEnvironment = requiredWorld.getEnvironment();
|
||||
PlatformChunkGenerator generator = irisWorld ? IrisToolbelt.access(requiredWorld) : null;
|
||||
String dimension = null;
|
||||
IrisEnvironment dimensionEnvironment = null;
|
||||
if (generator != null) {
|
||||
IrisDimension runtimeDimension = generator.getTarget().getDimension();
|
||||
dimension = runtimeDimension.getLoadKey();
|
||||
dimensionEnvironment = runtimeDimension.getEnvironment();
|
||||
}
|
||||
return new PublishedWorldRuntimeState(
|
||||
worldKey,
|
||||
irisWorld,
|
||||
seed,
|
||||
bukkitEnvironment,
|
||||
dimension,
|
||||
dimensionEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction) {
|
||||
try {
|
||||
if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) {
|
||||
throw new IOException("Loaded world identity does not match the replacement journal.");
|
||||
}
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IOException("The replaced world did not load with an Iris generator.");
|
||||
}
|
||||
if (world.getSeed() != transaction.seed()) {
|
||||
throw new IOException("The replaced world loaded with an unexpected seed.");
|
||||
}
|
||||
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
|
||||
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
|
||||
throw new IOException("The replaced world loaded with an unexpected environment.");
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator == null || !transaction.dimension().equals(
|
||||
generator.getTarget().getDimension().getLoadKey())) {
|
||||
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
|
||||
requireCompatibleEnvironment(
|
||||
target.slotKind(),
|
||||
generator.getTarget().getDimension().getEnvironment()
|
||||
);
|
||||
validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind());
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
paths.target().resolve("iris/pack"));
|
||||
@@ -318,6 +420,39 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
static void validatePublishedWorldRuntime(
|
||||
PublishedWorldRuntimeState runtimeState,
|
||||
Transaction transaction,
|
||||
SlotKind slotKind
|
||||
) throws IOException {
|
||||
PublishedWorldRuntimeState requiredRuntimeState = Objects.requireNonNull(runtimeState, "runtimeState");
|
||||
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
|
||||
SlotKind requiredSlotKind = Objects.requireNonNull(slotKind, "slotKind");
|
||||
if (!requiredTransaction.worldKey().equals(requiredRuntimeState.worldKey())) {
|
||||
throw new IOException("Loaded world identity does not match the replacement journal.");
|
||||
}
|
||||
if (!requiredRuntimeState.irisWorld()) {
|
||||
throw new IOException("The replaced world did not load with an Iris generator.");
|
||||
}
|
||||
if (requiredRuntimeState.seed() != requiredTransaction.seed()) {
|
||||
throw new IOException("The replaced world loaded with an unexpected seed.");
|
||||
}
|
||||
World.Environment expectedEnvironment = expectedEnvironment(requiredTransaction.worldKey());
|
||||
if (expectedEnvironment != null && requiredRuntimeState.bukkitEnvironment() != expectedEnvironment) {
|
||||
throw new IOException("The replaced world loaded with an unexpected environment.");
|
||||
}
|
||||
if (requiredRuntimeState.dimension() == null
|
||||
|| requiredRuntimeState.dimensionEnvironment() == null
|
||||
|| !requiredTransaction.dimension().equals(requiredRuntimeState.dimension())) {
|
||||
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
|
||||
}
|
||||
requireCompatibleEnvironment(requiredSlotKind, requiredRuntimeState.dimensionEnvironment());
|
||||
}
|
||||
|
||||
private synchronized void finishRuntimeVerification(UUID transactionId) {
|
||||
verificationInFlight.remove(transactionId);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
record PublishedWorldRuntimeState(
|
||||
WorldSlotKey worldKey,
|
||||
boolean irisWorld,
|
||||
long seed,
|
||||
World.Environment bukkitEnvironment,
|
||||
String dimension,
|
||||
IrisEnvironment dimensionEnvironment
|
||||
) {
|
||||
PublishedWorldRuntimeState {
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(bukkitEnvironment, "bukkitEnvironment");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-7
@@ -79,12 +79,14 @@ import static org.bukkit.Bukkit.getServer;
|
||||
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.IrisMessages;
|
||||
import art.arcane.iris.core.localization.PackDownloadMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
|
||||
public class CommandIris implements DirectorExecutor {
|
||||
private static final String NO_DOWNLOAD_SOURCE = "__none__";
|
||||
private static final String PRESERVE_REPLACEMENT_SEED = "preserve";
|
||||
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
|
||||
|
||||
private CommandStudio studio;
|
||||
@@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor {
|
||||
defaultValue = "default",
|
||||
customHandler = PackDimensionTypeHandler.class
|
||||
)
|
||||
String type
|
||||
String type,
|
||||
@Param(
|
||||
name = "seed",
|
||||
aliases = "s",
|
||||
description = "The replacement seed; omit to preserve the target world's seed",
|
||||
defaultValue = PRESERVE_REPLACEMENT_SEED,
|
||||
customHandler = ReplacementSeedHandler.class
|
||||
)
|
||||
Long seed
|
||||
) {
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
@@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor {
|
||||
try {
|
||||
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
|
||||
.pendingWorldReplacements()
|
||||
.stageReplacement(sender(), worldKey, dimension);
|
||||
.stageReplacement(sender(), worldKey, dimension, seed);
|
||||
String seedDetail = seed == null
|
||||
? " preserving seed " + staged.seed()
|
||||
: " using seed " + staged.seed();
|
||||
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
|
||||
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
|
||||
+ seedDetail + ". Restart once to publish it. The current dimension is retained until Iris "
|
||||
+ "verifies the replacement.");
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
|
||||
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
|
||||
@@ -539,19 +553,17 @@ public class CommandIris implements DirectorExecutor {
|
||||
String builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack;
|
||||
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
|
||||
if ((builtInPack == null) == (directLink == null)) {
|
||||
sender().sendMessage("Use exactly one source: /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
|
||||
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_SOURCE));
|
||||
return;
|
||||
}
|
||||
if (builtInPack != null) {
|
||||
sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release.");
|
||||
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
|
||||
return;
|
||||
}
|
||||
if (!PackDownloader.isDirectZipUrl(directLink)) {
|
||||
sender().sendMessage("Iris requires link= to contain a valid HTTP or HTTPS .zip URL.");
|
||||
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage("Downloading Iris pack from " + directLink + ".");
|
||||
Iris.service(StudioSVC.class).downloadUrl(sender(), directLink);
|
||||
}
|
||||
|
||||
@@ -894,6 +906,41 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReplacementSeedHandler implements DirectorParameterHandler<Long> {
|
||||
@Override
|
||||
public KList<Long> getPossibilities() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Long value) {
|
||||
return value == null ? PRESERVE_REPLACEMENT_SEED : Long.toString(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long parse(String in, boolean force) throws DirectorParsingException {
|
||||
String seed = in == null ? "" : in.trim();
|
||||
if (PRESERVE_REPLACEMENT_SEED.equalsIgnoreCase(seed)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(seed);
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new DirectorParsingException("Seed must be a signed 64-bit integer");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> type) {
|
||||
return type == Long.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRandomDefault() {
|
||||
return "1337";
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadPackHandler implements DirectorParameterHandler<String> {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ public class PendingWorldReplacementManagerPolicyTest {
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension)
|
||||
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension, null)
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class PendingWorldReplacementThreadAffinityTest {
|
||||
@Test
|
||||
public void runtimeStateIsCapturedIntoAnImmutableDetachedSnapshot() {
|
||||
World world = mock(World.class);
|
||||
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
|
||||
EngineTarget target = mock(EngineTarget.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(world.getKey()).thenReturn(NamespacedKey.minecraft("the_nether"));
|
||||
when(world.getSeed()).thenReturn(-18273645L);
|
||||
when(world.getEnvironment()).thenReturn(World.Environment.NETHER);
|
||||
when(generator.getTarget()).thenReturn(target);
|
||||
when(target.getDimension()).thenReturn(dimension);
|
||||
when(dimension.getLoadKey()).thenReturn("underworld");
|
||||
when(dimension.getEnvironment()).thenReturn(IrisEnvironment.NETHER);
|
||||
|
||||
PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState;
|
||||
try (MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class)) {
|
||||
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
|
||||
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
|
||||
runtimeState = PendingWorldReplacementManager.capturePublishedWorldRuntime(world);
|
||||
}
|
||||
|
||||
assertEquals(WorldSlotKey.minecraft("the_nether"), runtimeState.worldKey());
|
||||
assertTrue(runtimeState.irisWorld());
|
||||
assertEquals(-18273645L, runtimeState.seed());
|
||||
assertEquals(World.Environment.NETHER, runtimeState.bukkitEnvironment());
|
||||
assertEquals("underworld", runtimeState.dimension());
|
||||
assertEquals(IrisEnvironment.NETHER, runtimeState.dimensionEnvironment());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runtimeValidationRejectsIdentitySeedAndEnvironmentMismatches() throws Exception {
|
||||
Transaction transaction = transaction();
|
||||
PendingWorldReplacementManager.PublishedWorldRuntimeState valid = runtimeState(
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
918273645L,
|
||||
World.Environment.NETHER,
|
||||
IrisEnvironment.NETHER
|
||||
);
|
||||
PendingWorldReplacementManager.validatePublishedWorldRuntime(
|
||||
valid,
|
||||
transaction,
|
||||
SlotKind.VANILLA_NETHER
|
||||
);
|
||||
|
||||
IOException identityFailure = assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
|
||||
runtimeState(
|
||||
WorldSlotKey.minecraft("overworld"),
|
||||
918273645L,
|
||||
World.Environment.NETHER,
|
||||
IrisEnvironment.NETHER
|
||||
),
|
||||
transaction,
|
||||
SlotKind.VANILLA_NETHER
|
||||
)
|
||||
);
|
||||
IOException seedFailure = assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
|
||||
runtimeState(
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
1L,
|
||||
World.Environment.NETHER,
|
||||
IrisEnvironment.NETHER
|
||||
),
|
||||
transaction,
|
||||
SlotKind.VANILLA_NETHER
|
||||
)
|
||||
);
|
||||
IOException bukkitEnvironmentFailure = assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
|
||||
runtimeState(
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
918273645L,
|
||||
World.Environment.NORMAL,
|
||||
IrisEnvironment.NETHER
|
||||
),
|
||||
transaction,
|
||||
SlotKind.VANILLA_NETHER
|
||||
)
|
||||
);
|
||||
IllegalArgumentException irisEnvironmentFailure = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
|
||||
runtimeState(
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
918273645L,
|
||||
World.Environment.NETHER,
|
||||
IrisEnvironment.NORMAL
|
||||
),
|
||||
transaction,
|
||||
SlotKind.VANILLA_NETHER
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Loaded world identity does not match the replacement journal.", identityFailure.getMessage());
|
||||
assertEquals("The replaced world loaded with an unexpected seed.", seedFailure.getMessage());
|
||||
assertEquals("The replaced world loaded with an unexpected environment.",
|
||||
bukkitEnvironmentFailure.getMessage());
|
||||
assertTrue(irisEnvironmentFailure.getMessage().contains("requires a pack environment of NETHER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitAccessIsConfinedToTheGlobalCaptureStage() 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 startup = method(managerSource, "public void verifyLoadedPublishedWorlds()");
|
||||
String worldLoad = method(managerSource, "public void onWorldLoad(WorldLoadEvent event)");
|
||||
String discovery = method(managerSource, "private void discoverLoadedPublishedWorlds()");
|
||||
String capture = method(managerSource,
|
||||
"private void captureLoadedPublishedWorldOnGlobal(Transaction transaction)");
|
||||
String snapshot = method(managerSource, "static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world)");
|
||||
String verification = method(managerSource,
|
||||
"private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction)");
|
||||
|
||||
assertTrue(startup.contains("J.a(this::discoverLoadedPublishedWorlds)"));
|
||||
assertTrue(worldLoad.contains("WorldIdentity.key(event.getWorld())"));
|
||||
assertTrue(worldLoad.contains("J.a(() -> discoverLoadedWorldTransaction(worldKey))"));
|
||||
assertTrue(capture.contains("WorldIdentity.resolve("));
|
||||
assertBefore(capture, "capturePublishedWorldRuntime(world)",
|
||||
"J.a(() -> runPublishedWorldVerification(runtimeState, transaction))");
|
||||
assertTrue(snapshot.contains("WorldIdentity.key(requiredWorld)"));
|
||||
assertTrue(snapshot.contains("IrisToolbelt.isIrisWorld(requiredWorld)"));
|
||||
assertTrue(snapshot.contains("requiredWorld.getSeed()"));
|
||||
assertTrue(snapshot.contains("requiredWorld.getEnvironment()"));
|
||||
assertTrue(snapshot.contains("IrisToolbelt.access(requiredWorld)"));
|
||||
assertNoBukkitRuntimeAccess(discovery);
|
||||
assertNoBukkitRuntimeAccess(verification);
|
||||
assertTrue(verification.contains("WorldReplacementFilesystem.fingerprintPack("));
|
||||
assertTrue(irisSource.contains("pendingWorldReplacements.verifyLoadedPublishedWorlds();"));
|
||||
assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)"));
|
||||
}
|
||||
|
||||
private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState(
|
||||
WorldSlotKey worldKey,
|
||||
long seed,
|
||||
World.Environment bukkitEnvironment,
|
||||
IrisEnvironment irisEnvironment
|
||||
) {
|
||||
return new PendingWorldReplacementManager.PublishedWorldRuntimeState(
|
||||
worldKey,
|
||||
true,
|
||||
seed,
|
||||
bukkitEnvironment,
|
||||
"underworld",
|
||||
irisEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
private static Transaction transaction() {
|
||||
return new Transaction(
|
||||
UUID.fromString("2e488654-c259-4587-a7f2-8a053d59b60f"),
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
"world_nether",
|
||||
Path.of("build", "replacement-thread-test", "world"),
|
||||
"underworld",
|
||||
918273645L,
|
||||
"fingerprint",
|
||||
new WorldGeneratorSnapshot(false, false, false, null, false, null),
|
||||
true,
|
||||
Phase.PUBLISHED
|
||||
);
|
||||
}
|
||||
|
||||
private static void assertNoBukkitRuntimeAccess(String source) {
|
||||
assertFalse(source.contains("WorldIdentity."));
|
||||
assertFalse(source.contains("IrisToolbelt."));
|
||||
assertFalse(source.contains("getSeed()"));
|
||||
assertFalse(source.contains("getEnvironment()"));
|
||||
assertFalse(source.contains("Bukkit."));
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract signature: " + signature, start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -2,6 +2,7 @@ package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -10,6 +11,8 @@ import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisCreateOverwriteContractTest {
|
||||
@@ -27,13 +30,20 @@ public class CommandIrisCreateOverwriteContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception {
|
||||
Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class);
|
||||
public void replaceOwnsOverrideAndOverwriteAliasesWithOptionalSeed() throws Exception {
|
||||
Method command = CommandIris.class.getDeclaredMethod(
|
||||
"replace",
|
||||
String.class,
|
||||
String.class,
|
||||
Long.class
|
||||
);
|
||||
Director director = command.getAnnotation(Director.class);
|
||||
Parameter targetParameter = command.getParameters()[0];
|
||||
Param target = targetParameter.getAnnotation(Param.class);
|
||||
Parameter typeParameter = command.getParameters()[1];
|
||||
Param type = typeParameter.getAnnotation(Param.class);
|
||||
Parameter seedParameter = command.getParameters()[2];
|
||||
Param seed = seedParameter.getAnnotation(Param.class);
|
||||
|
||||
assertTrue(Arrays.asList(director.aliases()).contains("override"));
|
||||
assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
|
||||
@@ -45,6 +55,20 @@ public class CommandIrisCreateOverwriteContractTest {
|
||||
assertEquals(director.descriptionKey(), target.descriptionKey());
|
||||
assertEquals("default", type.defaultValue());
|
||||
assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler());
|
||||
assertEquals("seed", seed.name());
|
||||
assertEquals("preserve", seed.defaultValue());
|
||||
assertEquals(CommandIris.ReplacementSeedHandler.class, seed.customHandler());
|
||||
assertEquals(Long.class, seedParameter.getType());
|
||||
assertFalse(Arrays.stream(command.getParameterTypes()).anyMatch(parameterType -> parameterType == long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replacementSeedHandlerPreservesOrParsesTheFullLongRange() throws Exception {
|
||||
CommandIris.ReplacementSeedHandler handler = new CommandIris.ReplacementSeedHandler();
|
||||
|
||||
assertNull(handler.parse("preserve", false));
|
||||
assertEquals(Long.valueOf(Long.MIN_VALUE), handler.parse(Long.toString(Long.MIN_VALUE), false));
|
||||
assertEquals(Long.valueOf(Long.MAX_VALUE), handler.parse(Long.toString(Long.MAX_VALUE), false));
|
||||
assertThrows(DirectorParsingException.class, () -> handler.parse("9223372036854775808", false));
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -5,12 +5,16 @@ import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisDownloadContractTest {
|
||||
@Test
|
||||
@@ -42,4 +46,37 @@ public class CommandIrisDownloadContractTest {
|
||||
assertNull(handler.parse("__none__", false));
|
||||
assertThrows(Exception.class, () -> handler.parse("custom", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commandDelegatesAcceptedDownloadsWithoutRawPreamble() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
|
||||
));
|
||||
String download = method(source, "public void download(");
|
||||
|
||||
assertTrue(download.contains("downloadBuiltIn(sender(), builtInPack)"));
|
||||
assertTrue(download.contains("downloadUrl(sender(), directLink)"));
|
||||
assertFalse(download.contains("Downloading built-in Iris pack"));
|
||||
assertFalse(download.contains("Downloading Iris pack from"));
|
||||
assertFalse(download.contains("sendMessage(directLink"));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue(start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed method: " + signature);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user