diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 3c791cd44..3771034dd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -67,6 +67,9 @@ import art.arcane.volmlib.util.localization.MessageArgument; public final class IrisModdedCommands { private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; + // The first chunk of a cold dimension also loads the pack and binds the engine; the deadline only bounds a + // destination whose chunk never completes (a refusing generator or a removed dimension). + private static final long TELEPORT_DEADLINE_SECONDS = 120L; private static final Object DOWNLOAD_MONITOR = new Object(); static final SuggestionProvider PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES; @@ -133,7 +136,8 @@ public final class IrisModdedCommands { String dimensionId = level.name(); NativeModdedServer server = source.server(); CompletableFuture teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination( - server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, 0L)); + server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, + System.nanoTime() + TimeUnit.SECONDS.toNanos(TELEPORT_DEADLINE_SECONDS))); teleport.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success) && failure == null) { return; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java index 5cfc72fe4..a6abc2183 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java @@ -1,18 +1,115 @@ package art.arcane.iris.modded.command; import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.localization.ModdedCommandMessages; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.util.localization.MessageArgument; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import org.junit.BeforeClass; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public class IrisModdedCommandsTeleportTest { private static final String DIMENSION = "irisworldgen:1"; + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void teleportWaitsForAColdDestinationWithinABoundedDeadline() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeCommandSource source = mock(NativeCommandSource.class); + when(source.server()).thenReturn(server); + NativeWorld level = mock(NativeWorld.class); + when(level.name()).thenReturn(DIMENSION); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + when(player.name()).thenReturn("Tester"); + AtomicReference requested = new AtomicReference<>(); + + long before = System.nanoTime(); + try (MockedStatic generators = mockStatic(NativeWorldGenerators.class); + MockedStatic dimensions = mockStatic(ModdedDimensionManager.class); + MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) { + generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)) + .thenReturn(mock(IrisModdedChunkGenerator.class)); + dimensions.when(() -> ModdedDimensionManager.level(server, DIMENSION)).thenReturn(level); + teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> { + requested.set(invocation.getArgument(1)); + return new CompletableFuture(); + }); + + assertEquals(1, IrisModdedCommands.tp(source, level, player)); + } + long after = System.nanoTime(); + + long deadline = requested.get().deadlineNanos(); + assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L)); + assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L)); + } + + @Test(timeout = 15_000L) + public void expiredTeleportReportsTheCallersDeadline() throws Exception { + MinecraftServer server = mock(MinecraftServer.class); + when(server.isSameThread()).thenReturn(true); + ResourceKey dimension = ResourceKey.create(Registries.DIMENSION, Identifier.parse(DIMENSION)); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(chunks.addTicketAndLoadWithRadius(any(), any(), anyInt())).thenReturn(new CompletableFuture<>()); + ServerLevel serverLevel = mock(ServerLevel.class); + when(serverLevel.getServer()).thenReturn(server); + when(serverLevel.dimension()).thenReturn(dimension); + when(serverLevel.getChunkSource()).thenReturn(chunks); + when(server.getLevel(dimension)).thenReturn(serverLevel); + ServerPlayer serverPlayer = mock(ServerPlayer.class); + when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID()); + NativeWorld level = mock(NativeWorld.class); + when(level.nativeHandle()).thenReturn(serverLevel); + + CompletableFuture teleport = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(serverPlayer), + new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), level, 8.5D, + Double.MIN_VALUE, 8.5D, System.nanoTime() + TimeUnit.SECONDS.toNanos(2L))); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> teleport.get(10L, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof TimeoutException); + assertTrue(failure.getCause().getMessage(), failure.getCause().getMessage().contains("within 2 seconds")); + } + @Test public void failureReportsItsRootCauseInsteadOfAnUnloadedDimension() { String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester",