Fix Iris replacement

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 15:30:42 -04:00
parent 4568b3fff1
commit 8297e49ed2
52 changed files with 4748 additions and 198 deletions
@@ -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);
@@ -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");
}
}
}
@@ -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() {
@@ -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(
@@ -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);
}
}
@@ -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));
}
}
@@ -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);
}
}