Command Corrections.

This commit is contained in:
Brian Neumann-Fopiano
2026-09-17 04:48:35 -04:00
parent 1e1eaa96aa
commit b18681a3e2
43 changed files with 1227 additions and 251 deletions
@@ -122,7 +122,7 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
}
@Director(description = "Find an accepted hydrology feature")
@Director(description = "Find an accepted hydrology feature", sync = true)
public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
String type,
@@ -147,6 +147,7 @@ public class CommandFind implements DirectorExecutor {
return;
}
Location origin = target.getLocation();
World originWorld = origin.getWorld();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
@@ -179,9 +180,9 @@ public class CommandFind implements DirectorExecutor {
return;
}
Location destination = new Location(
target.getWorld(), feature.x(), feature.y(), feature.z());
originWorld, feature.x(), feature.y(), feature.z());
prepareStructureTeleport(
target, target.getWorld(), commandSender, label, destination, true);
target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
@@ -737,8 +738,23 @@ public class CommandFind implements DirectorExecutor {
private void prepareStructureTeleport(Player target, World world, VolmitSender commandSender, String structure,
Location at, boolean useLocatedY) {
J.runEntity(target, () -> {
if (!target.isOnline()) {
return;
}
if (!world.equals(target.getWorld())) {
commandSender.sendMessage(C.YELLOW + "Cancelled teleport to " + structure + " because your world changed.");
return;
}
loadStructureDestination(target, world, commandSender, structure, at, useLocatedY);
});
}
private void loadStructureDestination(Player target, World world, VolmitSender commandSender, String structure,
Location at, boolean useLocatedY) {
int chunkX = at.getBlockX() >> 4;
int chunkZ = at.getBlockZ() >> 4;
try {
BukkitPlatform.chunkAtAsync(world, chunkX, chunkZ, true).whenComplete((chunk, error) -> {
if (error != null) {
sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + ".");
@@ -751,6 +767,10 @@ public class CommandFind implements DirectorExecutor {
sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + ".");
}
});
} catch (Throwable error) {
sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + ".");
Iris.reportError("Could not load structure destination '" + structure + "'.", error);
}
}
private void teleportToStructure(Player target, World world, VolmitSender commandSender, String structure,
@@ -760,16 +780,41 @@ public class CommandFind implements DirectorExecutor {
? Math.max(world.getMinHeight() + 1, Math.min(world.getMaxHeight() - 1, at.getBlockY() + 2))
: world.getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2;
Location destination = new Location(world, at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5);
J.runEntity(target, () -> {
BukkitPlatform.teleportAsync(target, destination);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_TELEPORTED, MessageArgument.untrusted("structure", structure), MessageArgument.untrusted("value", at.getBlockX()), MessageArgument.untrusted("y", y), MessageArgument.untrusted("value2", at.getBlockZ())));
});
J.runEntity(target, () -> completeStructureTeleport(target, world, commandSender, structure, destination));
} catch (Throwable t) {
sendStructureMessage(target, commandSender, C.RED + "Could not prepare the destination for " + structure + ".");
Iris.reportError("Could not prepare structure destination '" + structure + "'.", t);
}
}
private void completeStructureTeleport(Player target, World world, VolmitSender commandSender, String structure,
Location destination) {
if (!target.isOnline()) {
return;
}
if (!world.equals(target.getWorld())) {
commandSender.sendMessage(C.YELLOW + "Cancelled teleport to " + structure + " because your world changed.");
return;
}
try {
BukkitPlatform.teleportAsync(target, destination).whenComplete((teleported, error) -> {
if (error != null) {
Iris.reportError("Could not teleport to '" + structure + "'.", error);
}
sendStructureMessage(target, commandSender, error == null && Boolean.TRUE.equals(teleported)
? IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_TELEPORTED,
MessageArgument.untrusted("structure", structure),
MessageArgument.untrusted("value", destination.getBlockX()),
MessageArgument.untrusted("y", destination.getBlockY()),
MessageArgument.untrusted("value2", destination.getBlockZ()))
: C.RED + "Could not teleport to " + structure + ".");
});
} catch (Throwable error) {
commandSender.sendMessage(C.RED + "Could not teleport to " + structure + ".");
Iris.reportError("Could not teleport to '" + structure + "'.", error);
}
}
private void sendStructureMessage(Player target, VolmitSender commandSender, String message) {
J.runEntity(target, () -> commandSender.sendMessage(message));
}
@@ -21,6 +21,7 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
@@ -274,10 +275,6 @@ public class CommandStudio implements DirectorExecutor {
if (noGUI()) {
return;
}
if (!ImageMapStudioGUI.isAvailable()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS));
return;
}
if (!IrisToolbelt.isIrisWorld(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
@@ -883,6 +880,10 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS));
return true;
}
if (!GuiHost.isAvailable()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DESKTOP_UNAVAILABLE));
return true;
}
return false;
}
@@ -0,0 +1,178 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.director.annotations.Director;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
public class CommandFindHydrologyTest {
@Test
public void riverCapturesPlayerStateOnTheOwningThread() throws Exception {
assertTrue(CommandFind.class.getMethod("river", String.class, boolean.class)
.getAnnotation(Director.class).sync());
}
@Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run();
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
fixture.platform.verify(() -> BukkitPlatform.teleportAsync(any(Player.class), any(Location.class)), never());
verify(fixture.sender).sendMessage(contains("world changed"));
}
}
@Test
public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run();
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
}
}
@Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class));
fixture.platform.verify(() -> BukkitPlatform.teleportAsync(any(Player.class), any(Location.class)), never());
verify(fixture.sender).sendMessage(contains("world changed"));
}
}
@Test
public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
verify(fixture.sender, never()).sendMessage("teleported");
fixture.teleport.complete(true);
verify(fixture.sender).sendMessage("teleported");
}
}
@Test
public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false);
verify(fixture.sender, never()).sendMessage("teleported");
verify(fixture.sender).sendMessage(contains("Could not teleport"));
}
}
@Test
public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.completeExceptionally(failure);
verify(fixture.sender, never()).sendMessage("teleported");
verify(fixture.sender).sendMessage(contains("Could not teleport"));
fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure)));
}
}
private static final class Fixture implements AutoCloseable {
private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
private final IrisComplex complex = mock(IrisComplex.class);
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final VolmitSender sender = mock(VolmitSender.class);
private final AtomicReference<Runnable> search = new AtomicReference<>();
private final CompletableFuture<Chunk> chunk = new CompletableFuture<>();
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> logging = mockStatic(Iris.class);
private Fixture() {
doReturn(engine).when(command).engine();
doReturn(player).when(command).player();
doReturn(sender).when(command).sender();
when(engine.getComplex()).thenReturn(complex);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 0, 70, 0));
when(player.isOnline()).thenReturn(true);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
search.set(invocation.getArgument(0));
return null;
});
scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> {
invocation.<Runnable>getArgument(1).run();
return true;
});
scheduler.when(() -> J.runRegion(any(World.class), anyInt(), anyInt(), any(Runnable.class))).thenAnswer(invocation -> {
invocation.<Runnable>getArgument(3).run();
return true;
});
platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk);
platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport);
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any()))
.thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty())));
}
@Override
public void close() {
logging.close();
language.close();
queries.close();
platform.close();
scheduler.close();
}
}
}
@@ -0,0 +1,83 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.studio.view.GuiHost;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class CommandStudioDesktopTest {
private IrisSettings previousSettings;
private boolean previousSuppressed;
private RecordingSender sender;
private CommandStudio command;
@Before
public void setup() {
previousSettings = IrisSettings.settings;
previousSuppressed = GuiHost.isDesktopSuppressed();
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
GuiHost.suppressDesktop(true);
sender = new RecordingSender();
command = new TestCommand(sender);
}
@After
public void restore() {
IrisSettings.settings = previousSettings;
GuiHost.suppressDesktop(previousSuppressed);
}
@Test
public void mapReportsMissingDisplayBeforeAccessingWorld() {
command.map(null);
assertDisplayMessage();
}
@Test
public void imageMapReportsMissingDisplayInsteadOfDisabledSetting() {
command.imagemap(null);
assertDisplayMessage();
}
private void assertDisplayMessage() {
assertEquals(1, sender.messages.size());
assertTrue(sender.messages.getFirst(), sender.messages.getFirst().contains("display"));
}
private static final class TestCommand extends CommandStudio {
private final VolmitSender sender;
private TestCommand(VolmitSender sender) {
this.sender = sender;
}
@Override
public VolmitSender sender() {
return sender;
}
}
private static final class RecordingSender extends VolmitSender {
private final List<String> messages = new ArrayList<>();
private RecordingSender() {
super(null);
}
@Override
public void sendMessage(String message) {
messages.add(message);
}
}
}
@@ -60,10 +60,11 @@ final class EngineHotloader {
hotloadSilently();
return;
}
engine.awaitNativeStructureBootstrap("complex hotload");
synchronized (engine.lifecycleLock) {
engine.requireRunning("rebuild the biome complex");
requireMutableGenerationRuntime("rebuild the biome complex");
engine.awaitNativeStructureBootstrap("complex hotload");
engine.requireNativeStructureBootstrapComplete("complex hotload");
engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previous = engine.runtime;
GenerationRuntime previousGeneration = previous.generation();
@@ -133,10 +134,11 @@ final class EngineHotloader {
hotloadStudio(router);
return;
}
engine.awaitNativeStructureBootstrap("hotload");
synchronized (engine.lifecycleLock) {
engine.requireRunning("hotload");
requireMutableGenerationRuntime("hotload");
engine.awaitNativeStructureBootstrap("hotload");
engine.requireNativeStructureBootstrapComplete("hotload");
engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previousRuntime = engine.runtime;
IrisDimension previousDimension = engine.getDimension();
@@ -70,7 +70,10 @@ final class EngineShutdownSequence {
if (!engine.beginShutdown()) {
return;
}
Throwable routerFailure = stopWorldManager();
Throwable routerFailure = runCleanup(null, engine::drainNativeStructureBootstrap);
if (routerFailure == null) {
routerFailure = stopWorldManager();
}
if (routerFailure == null) {
routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter);
}
@@ -86,7 +89,6 @@ final class EngineShutdownSequence {
if (engine.closed) {
return;
}
engine.awaitNativeStructureBootstrap("close");
engine.lifecycleState = LifecycleState.CLOSING;
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
@@ -515,6 +515,21 @@ public class IrisEngine implements Engine {
nativeStructureBootstrapBarrier.await(transition);
}
void requireNativeStructureBootstrapComplete(String transition) {
if (nativeStructureBootstrapBarrier.isActive()) {
throw new IllegalStateException("Native structure bootstrap started while preparing "
+ transition + "; retry after structure initialization completes.");
}
}
void drainNativeStructureBootstrap() {
Throwable failure = nativeStructureBootstrapBarrier.awaitForClose();
if (failure != null) {
IrisLogging.reportError("Native structure bootstrap failed for " + getWorld().name()
+ "; its workers have stopped and engine cleanup will continue.", failure);
}
}
@Override
public void generateMatter(int x, int z, boolean multicore, ChunkContext context) {
awaitGenerationCacheWarm();
@@ -31,7 +31,7 @@ final class NativeStructureBootstrapBarrier {
starter.get(),
"Native structure bootstrap completion");
completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure));
return bridge;
return bridge.copy();
} catch (Throwable failure) {
poisoned = bridge;
bridge.completeExceptionally(failure);
@@ -45,30 +45,16 @@ final class NativeStructureBootstrapBarrier {
}
}
synchronized void await(String transition) {
CompletableFuture<Void> completion = active;
if (completion == null) {
return;
}
try {
completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
active = null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
void await(String transition) {
Throwable failure = awaitCompletion(transition);
if (failure != null) {
throw new IllegalStateException(
"Interrupted while waiting for native structure bootstrap before " + transition + ".", e);
} catch (ExecutionException e) {
if (poisoned != completion) {
active = null;
"Native structure bootstrap failed before " + transition + ".", failure);
}
Throwable cause = e.getCause() == null ? e : e.getCause();
throw new IllegalStateException(
"Native structure bootstrap failed before " + transition + ".", cause);
} catch (TimeoutException e) {
throw new IllegalStateException(
"Native structure bootstrap did not finish before " + transition + " within "
+ TRANSITION_TIMEOUT_SECONDS + " seconds.", e);
}
Throwable awaitForClose() {
return awaitCompletion("close");
}
synchronized boolean isActive() {
@@ -79,6 +65,45 @@ final class NativeStructureBootstrapBarrier {
return poisoned != null;
}
private Throwable awaitCompletion(String transition) {
CompletableFuture<Void> completion;
synchronized (this) {
completion = active;
}
if (completion == null) {
return null;
}
try {
completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
clearCompleted(completion);
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Interrupted while waiting for native structure bootstrap before " + transition + ".", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
synchronized (this) {
if (poisoned == completion) {
throw new IllegalStateException(
"Native structure bootstrap could not be tracked before " + transition + ".", cause);
}
clearCompleted(completion);
}
return cause;
} catch (TimeoutException e) {
throw new IllegalStateException(
"Native structure bootstrap did not finish before " + transition + " within "
+ TRANSITION_TIMEOUT_SECONDS + " seconds.", e);
}
}
private synchronized void clearCompleted(CompletableFuture<Void> completion) {
if (active == completion) {
active = null;
}
}
private void completeBridge(CompletableFuture<Void> bridge, Throwable failure) {
if (failure == null) {
bridge.complete(null);
@@ -699,6 +699,10 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandstudio.you_must_have_server_launched_guis_enabled_settings",
C.RED + "You must have server launched GUIs enabled in the settings!"
);
public static final TextKey COMMAND_STUDIO_DESKTOP_UNAVAILABLE = TextKey.of(
"iris.bukkit.commandstudio.desktop_unavailable",
C.RED + "This server has no available desktop display. Studio GUIs open on the server, so run Iris in a graphical desktop session."
);
public static final TextKey COMMAND_STUDIO_PLAYERS_ONLY = TextKey.of(
"iris.bukkit.commandstudio.players_only",
C.RED + "Players only!"
@@ -973,6 +977,7 @@ public final class BukkitCommandMessagesExtended {
COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS,
COMMAND_STUDIO_REPORTED,
COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS,
COMMAND_STUDIO_DESKTOP_UNAVAILABLE,
COMMAND_STUDIO_PLAYERS_ONLY,
COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN_2,
COMMAND_STUDIO_YOU_MUST_BE_STUDIO_WORLD,
@@ -2,10 +2,12 @@ package art.arcane.iris.studio.jigsaw;
import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.event.ProtectionProbe;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
@@ -161,8 +163,13 @@ final class JigsawStudioProtectionListener implements Listener {
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
@EventHandler(priority = EventPriority.LOWEST)
public void onUseTool(PlayerInteractEvent event) {
if (ProtectionProbe.isProbe(event)
|| event.useItemInHand() == Event.Result.DENY
|| (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) {
return;
}
if (event.getHand() != EquipmentSlot.HAND
|| (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)) {
@@ -299,6 +306,9 @@ final class JigsawStudioProtectionListener implements Listener {
return;
}
event.setCancelled(true);
if (ProtectionProbe.isProbe(event)) {
return;
}
ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID());
if (!authorizeOwner(event.getPlayer(), studio)) {
return;
@@ -501,6 +511,9 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (ProtectionProbe.isProbe(event)) {
return;
}
if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) {
return;
}
@@ -18,6 +18,7 @@
package art.arcane.iris.studio.object;
import art.arcane.volmlib.util.event.ProtectionProbe;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
@@ -180,6 +181,9 @@ public class ObjectStudioSaveService implements IrisService {
@EventHandler(ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (ProtectionProbe.isProbe(event)) {
return;
}
if (event.getHand() != EquipmentSlot.HAND) return;
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_BLOCK && action != Action.LEFT_CLICK_BLOCK) return;
@@ -47,16 +47,14 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Supplier;
public final class StudioOpenCoordinator {
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L;
private static final long STUDIO_CLOSE_WARNING_SECONDS = 120L;
private static volatile StudioOpenCoordinator instance;
private StudioOpenCoordinator() {
@@ -136,6 +134,7 @@ public final class StudioOpenCoordinator {
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
World world = null;
PlatformChunkGenerator provider = null;
CompletableFuture<Void> entryBootstrap = null;
try {
long openStart = System.nanoTime();
long t = openStart;
@@ -166,15 +165,14 @@ public final class StudioOpenCoordinator {
}
World entryWorld = world;
PlatformChunkGenerator entryProvider = provider;
CompletableFuture<Void> entryBootstrap = J.afut(
() -> endStudioEntryBootstrap(entryWorld, entryProvider));
entryBootstrap = endStudioEntryBootstrap(entryWorld, entryProvider);
updateStage(request, "apply_world_rules", 0.72D);
final World rulesWorld = world;
CompletableFuture<Boolean> rulesApplied =
J.sfut(() -> WorldRuntimeControlService.get().applyStudioWorldRules(rulesWorld));
if (rulesApplied != null) {
rulesApplied.get(15L, TimeUnit.SECONDS);
rulesApplied.get();
}
t = logStudioPhase(request, "apply_world_rules", t, openStart);
@@ -191,7 +189,7 @@ public final class StudioOpenCoordinator {
t = logStudioPhase(request, "resolve_entry_anchor", t, openStart);
updateStage(request, "prepare_structure_rings", 0.79D);
entryBootstrap.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
entryBootstrap.get();
t = logStudioPhase(request, "prepare_structure_rings", t, openStart);
updateStage(request, "prepare_generation_caches", 0.88D);
@@ -251,6 +249,9 @@ public final class StudioOpenCoordinator {
+ elapsedMillis(openStart) + "ms");
future.complete(new StudioOpenResult(world, entryLocation));
} catch (Throwable e) {
if (entryBootstrap != null) {
entryBootstrap.handle((ignored, failure) -> null).join();
}
abandonStudioEntryBootstrap(world, e);
IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e);
if (!request.retainOnFailure()) {
@@ -269,7 +270,7 @@ public final class StudioOpenCoordinator {
request.worldName(),
world,
request.project());
cleanup.get(45L, TimeUnit.SECONDS);
cleanup.get();
} catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \""
+ request.worldName() + "\".", unwrapFailure(cleanupError));
@@ -317,7 +318,7 @@ public final class StudioOpenCoordinator {
}
private void runOpenFinalizer(Consumer<World> finalizer, World world)
throws InterruptedException, ExecutionException, TimeoutException {
throws InterruptedException, ExecutionException {
if (finalizer == null) {
return;
}
@@ -327,7 +328,7 @@ public final class StudioOpenCoordinator {
}
CompletableFuture<Void> completion = J.sfut(() -> finalizer.accept(world));
completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
completion.get();
}
private long elapsedMillis(long startedAtNanos) {
@@ -342,60 +343,23 @@ public final class StudioOpenCoordinator {
duration);
}
private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) {
private CompletableFuture<Void> endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) {
if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) {
throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap.");
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio runtime provider cannot finish its entry bootstrap."));
}
AtomicBoolean activationClaim = new AtomicBoolean(true);
CompletableFuture<CompletableFuture<Void>> scheduledActivation = J.sfut(() -> {
if (!activationClaim.compareAndSet(true, false)) {
INMS.get().abandonStudioStructureBootstrap(world);
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio native structure activation was cancelled before it began."));
}
try {
CompletableFuture<Void> nativeActivation =
INMS.get().completeStudioStructureBootstrap(world);
if (nativeActivation == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio native structure activation returned no completion future."));
}
return nativeActivation;
return Objects.requireNonNull(INMS.get().completeStudioStructureBootstrap(world),
"Studio native structure activation completion");
} catch (ReflectiveOperationException e) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio native structure state could not be activated after entry bootstrap.", e));
}
});
CompletableFuture<Void> activation = scheduledActivation
return scheduledActivation
.thenCompose(nativeActivation -> nativeActivation)
.thenCompose(ignored -> J.sfut(bukkitGenerator::endStudioEntryBootstrap));
try {
activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
activationClaim.compareAndSet(true, false);
Thread.currentThread().interrupt();
throw new IllegalStateException("Studio native structure activation was interrupted.", e);
} catch (ExecutionException e) {
activationClaim.compareAndSet(true, false);
throw new IllegalStateException("Studio native structure activation did not complete.",
unwrapFailure(e));
} catch (TimeoutException e) {
if (!activationClaim.compareAndSet(true, false)) {
try {
activation.get(5L, TimeUnit.SECONDS);
return;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Studio native structure activation was interrupted.", interrupted);
} catch (ExecutionException | TimeoutException settlementFailure) {
throw new IllegalStateException(
"Studio native structure activation did not settle after claiming completion.",
unwrapFailure(settlementFailure));
}
}
throw new IllegalStateException("Studio native structure activation did not complete.", e);
}
}
private void abandonStudioEntryBootstrap(World world, Throwable failure) {
@@ -413,12 +377,12 @@ public final class StudioOpenCoordinator {
CompletableFuture<Void> abandonment = J.sfut(
() -> INMS.get().abandonStudioStructureBootstrap(world));
try {
abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
abandonment.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment was interrupted.", e));
} catch (ExecutionException | TimeoutException e) {
} catch (ExecutionException e) {
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment did not complete.", unwrapFailure(e)));
}
@@ -475,7 +439,15 @@ public final class StudioOpenCoordinator {
failure
));
}
return closeFuture.whenComplete((result, throwable) -> lease.close());
return closeFuture.whenComplete((result, throwable) -> {
if (throwable == null && result.failureCause() == null) {
lease.close();
} else {
IrisLogging.error("Studio cleanup remains incomplete for \"" + operationTarget
+ "\". Further Iris world operations are blocked. Resolve the reported failure "
+ "and restart the server manually before opening another world.");
}
}).copy();
}
private CompletableFuture<StudioCloseResult> closeWorldReserved(
@@ -486,145 +458,66 @@ public final class StudioOpenCoordinator {
IrisProject project
) {
AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
AtomicBoolean unloadStarted = new AtomicBoolean(false);
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
if (world != null) {
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
}
CompletableFuture<Void> sequence = sequenceStudioClose(
() -> evacuateWorldFamily(worldName, world),
() -> {
unloadStarted.set(true);
return unloadWorldFamily(worldName, world).thenRun(() -> {
if (terminalTimeout.get()) {
throw new CompletionException(new TimeoutException(
"Studio close stopped after its terminal timeout."));
}
unloadConfirmed.set(true);
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
() -> deleteFolder
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
: CompletableFuture.completedFuture(null)
);
warnOnSlowClose(sequence, worldName,
CompletableFuture.delayedExecutor(STUDIO_CLOSE_WARNING_SECONDS, TimeUnit.SECONDS));
return sequence.thenApply(ignored -> {
if (project != null) {
project.setActiveProvider(null);
project.setActiveOpenKind(null);
}
});
},
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
() -> deleteFolder
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
: CompletableFuture.completedFuture(null),
terminalTimeout::get
);
CompletableFuture<StudioCloseResult> operation = guardCloseCompletion(
sequence,
terminalTimeout,
worldName)
.thenApply(ignored -> new StudioCloseResult(
worldName,
true,
folderDeleted.get(),
false,
null
))
.exceptionally(throwable -> {
Throwable failure = unwrapFailure(throwable);
requestRestartAfterPartialClose(worldName, unloadStarted.get());
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
return new StudioCloseResult(
worldName,
unloadConfirmed.get(),
folderDeleted.get(),
queued,
failure
);
});
return operation.whenComplete((result, throwable) -> {
if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
}
return new StudioCloseResult(worldName, true, folderDeleted.get(), false, null);
})
.exceptionally(throwable -> {
Throwable failure = unwrapFailure(throwable);
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
return new StudioCloseResult(
worldName, unloadConfirmed.get(), folderDeleted.get(), queued, failure);
});
}
static void requestRestartAfterPartialClose(String worldName, boolean unloadStarted) {
if (unloadStarted) {
ServerConfigurator.restart("Studio close failed after world unload began for \"" + worldName + "\".");
}
}
static CompletableFuture<Void> sequenceStudioClose(
Supplier<CompletableFuture<Void>> evacuate,
Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders
) {
return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false);
}
static CompletableFuture<Void> sequenceStudioClose(
Supplier<CompletableFuture<Void>> evacuate,
Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders,
BooleanSupplier terminalTimeout
) {
return invokePhase(evacuate)
.thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout));
.thenCompose(ignored -> invokePhase(unload))
.thenCompose(ignored -> invokePhase(closeGenerator))
.thenCompose(ignored -> invokePhase(deleteFolders));
}
private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) {
try {
CompletableFuture<Void> future = phase.get();
if (future == null) {
return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future."));
}
return future;
return Objects.requireNonNull(phase.get(), "Studio close phase completion");
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
private static CompletableFuture<Void> invokePhaseUnlessTimedOut(
Supplier<CompletableFuture<Void>> phase,
BooleanSupplier terminalTimeout
) {
if (terminalTimeout.getAsBoolean()) {
return CompletableFuture.failedFuture(new TimeoutException(
"Studio close stopped after its terminal timeout."));
}
return invokePhase(phase);
}
private CompletableFuture<Void> guardCloseCompletion(
CompletableFuture<Void> source,
AtomicBoolean terminalTimeout,
String worldName
) {
CompletableFuture<Void> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((ignored, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(null);
} else {
guarded.completeExceptionally(throwable);
static void warnOnSlowClose(CompletableFuture<Void> completion, String worldName, Executor warningExecutor) {
warningExecutor.execute(() -> {
if (!completion.isDone()) {
IrisLogging.warn("Studio cleanup is still running for \"" + worldName
+ "\". Iris is waiting for active work before releasing world resources.");
}
});
CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
terminalTimeout.set(true);
TimeoutException timeout = new TimeoutException(
"Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS
+ " seconds for \"" + worldName + "\".");
ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\".");
guarded.completeExceptionally(timeout);
});
return guarded;
}
private CompletableFuture<Void> evacuateWorldFamily(String worldName, World primaryWorld) {
@@ -653,7 +546,7 @@ public final class StudioOpenCoordinator {
CompletableFuture<Boolean> unload = J.sfut(() ->
IrisServices.get(MultiverseCoreLink.class)
.removeIfPresent(loadedWorld))
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false));
.thenCompose(ignored -> WorldLifecycleService.get().unloadSettledAsync(loadedWorld, false));
unloads.add(unload);
}
@@ -743,7 +636,7 @@ public final class StudioOpenCoordinator {
null,
true,
null
).get(30L, TimeUnit.SECONDS);
).get();
if (cleanupResult.failureCause() != null) {
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause());
}
@@ -159,7 +159,14 @@ public final class ImageMapStudioGUI {
}
public static void launch(Engine engine) {
EventQueue.invokeLater(() -> new ImageMapStudioGUI(engine).show());
if (!GuiHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> {
if (GuiHost.isAvailable()) {
new ImageMapStudioGUI(engine).show();
}
});
}
static void reloadActiveEngine(Engine engine) {
@@ -216,17 +216,29 @@ public final class NoiseExplorerGUI extends JPanel {
}
public static void launch() {
if (!GuiHost.isAvailable()) {
return;
}
Engine engine = GuiHost.get().findActiveEngine();
EventQueue.invokeLater(() -> {
if (!GuiHost.isAvailable()) {
return;
}
NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED, null, null);
buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), explorer);
});
}
public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) {
if (!GuiHost.isAvailable()) {
return;
}
Engine engine = GuiHost.get().findActiveEngine();
String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey;
EventQueue.invokeLater(() -> {
if (!GuiHost.isAvailable()) {
return;
}
NoiseExplorerGUI explorer = new NoiseExplorerGUI(
engine,
generatorKey,
@@ -209,10 +209,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
}
public static void launch(Engine engine, UUID openerId) {
if (!GuiHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId));
}
private static void createAndShowGUI(Engine engine, UUID openerId) {
if (!GuiHost.isAvailable()) {
return;
}
JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE));
GuiHost.prepareFrame(frame);
VisionGUI vision = new VisionGUI(frame, engine, openerId);
@@ -123,6 +123,11 @@ public final class WorldLifecycleService {
}
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
World requiredWorld = Objects.requireNonNull(world, "world");
return guardUnloadCompletion(requiredWorld.getName(), unloadSettledAsync(requiredWorld, save));
}
public CompletableFuture<Boolean> unloadSettledAsync(World world, boolean save) {
World requiredWorld = Objects.requireNonNull(world, "world");
String worldIdentity = WorldIdentity.serialize(requiredWorld);
String worldName = requiredWorld.getName();
@@ -150,8 +155,7 @@ public final class WorldLifecycleService {
unloadFuture.whenComplete((unloaded, throwable) ->
WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable));
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
return guardedFuture.whenComplete((unloaded, throwable) -> {
return unloadFuture.whenComplete((unloaded, throwable) -> {
if (throwable != null) {
Throwable cause = WorldLifecycleSupport.unwrap(throwable);
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName
@@ -162,7 +166,7 @@ public final class WorldLifecycleService {
if (Boolean.TRUE.equals(unloaded)) {
worldBackendByKey.remove(worldIdentity, backend.backendName());
}
});
}).copy();
}
private CompletableFuture<Boolean> guardUnloadCompletion(
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Du musst in einer Iris-Welt sein."
capturing_igendata_from_nearby_chunks = "IGenData wird aus {value} nahe gelegenen Chunks erfasst."
reported = "Gemeldet an: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cIn den Einstellungen müssen vom Server gestartete GUIs aktiviert sein!"
desktop_unavailable = "§cDieser Server hat keine verfügbare Desktop-Anzeige. Die Studio-Fenster öffnen sich auf dem Server. Starte Iris in einer grafischen Desktop-Sitzung."
players_only = "§cNur Spieler!"
no_studio_world_is_open_2 = "§cKeine Studio-Welt ist offen!"
you_must_be_studio_world = "§cDu musst in einer Studiowelt sein!"
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Debes estar en un mundo de Iris."
capturing_igendata_from_nearby_chunks = "Capturando IGenData de {value} chunks cercanos."
reported = "Informe escrito en: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c¡Debes habilitar en los ajustes las GUI iniciadas por el servidor!"
desktop_unavailable = "§cEste servidor no dispone de una pantalla de escritorio. Las ventanas de Studio se abren en el servidor. Ejecuta Iris en una sesión de escritorio gráfica."
players_only = "§c¡Solo jugadores!"
no_studio_world_is_open_2 = "§c¡No hay ningún mundo de Studio abierto!"
you_must_be_studio_world = "§c¡Debes estar en un mundo de Studio!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Olet varmaan iris-maailmassa."
capturing_igendata_from_nearby_chunks = "Kaappaus IGenData alkaen {value} Lähellä on chunkia."
reported = "Raportoitu: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cSinulla täytyy olla palvelin käynnistetty GUIs käytössä asetuksissa!"
desktop_unavailable = "§cTällä palvelimella ei ole käytettävissä työpöytänäyttöä. Studion ikkunat avautuvat palvelimella. Käynnistä Iris graafisessa työpöytäistunnossa."
players_only = "§cVain pelaajille!"
no_studio_world_is_open_2 = "§cYksikään studiomaailma ei ole auki!"
you_must_be_studio_world = "§cOlet varmaan studiomaailmassa!"
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Vous devez être dans un monde Iris."
capturing_igendata_from_nearby_chunks = "Capture d'IGenData depuis {value} chunks à proximité."
reported = "Rapport écrit dans : {value}"
you_must_have_server_launched_guis_enabled_settings = "§cVous devez activer les GUI lancées par le serveur dans les paramètres !"
desktop_unavailable = "§cCe serveur ne dispose pas d’un affichage de bureau. Les fenêtres de Studio s’ouvrent sur le serveur. Lancez Iris dans une session de bureau graphique."
players_only = "§cJoueurs uniquement !"
no_studio_world_is_open_2 = "§cAucun monde Studio n'est ouvert !"
you_must_be_studio_world = "§cVous devez être dans un monde Studio !"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "אתה חייב להיות בעולם חג המול
capturing_igendata_from_nearby_chunks = "איסוף הנתונים של {value} הסמוך."
reported = "דווח על: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cאתה חייב לפתוח את השרת GUIs אפשרו בהגדרות!"
desktop_unavailable = "§cאין לשרת הזה תצוגת שולחן עבודה זמינה. חלונות Studio נפתחים בשרת. יש להפעיל את Iris בסביבת שולחן עבודה גרפית."
players_only = "§cשחקנים בלבד!"
no_studio_world_is_open_2 = "§cאין עולם סטודיו פתוח!"
you_must_be_studio_world = "§cאתה חייב להיות בעולם סטודיו!"
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Devi essere in un mondo iris."
capturing_igendata_from_nearby_chunks = "Acquisizione di IGenData da {value} chunk vicini."
reported = "Segnalato a: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cDevi abilitare nelle impostazioni le GUI avviate dal server!"
desktop_unavailable = "§cQuesto server non dispone di uno schermo desktop. Le finestre di Studio si aprono sul server. Avvia Iris in una sessione desktop grafica."
players_only = "§cSolo i giocatori!"
no_studio_world_is_open_2 = "§cNessun mondo di studio è aperto!"
you_must_be_studio_world = "§cDevi essere in uno studio!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "Iris ワールド内にいる必要があります
capturing_igendata_from_nearby_chunks = "周辺の {value} チャンクから IGenData をキャプチャしています。"
reported = "報告: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c設定でサーバー起動 GUI を有効にする必要があります!"
desktop_unavailable = "§cこのサーバーには利用可能なデスクトップ画面がありません。Studio のウィンドウはサーバー上で開くため、グラフィカルなデスクトップセッションで Iris を実行してください。"
players_only = "§cプレイヤーのみ!"
no_studio_world_is_open_2 = "§c開いているスタジオワールドはありません!"
you_must_be_studio_world = "§cスタジオワールドでなければいけません!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "당신은 아이리스 세계에 있어야합니다.
capturing_igendata_from_nearby_chunks = "주변 {value}개 청크에서 IGenData를 캡처하는 중입니다."
reported = "보고 위치: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c서버가 시작되어야 합니다. GUIs 설정에서 활성화!"
desktop_unavailable = "§c이 서버에는 사용 가능한 데스크톱 화면이 없습니다. Studio 창은 서버에서 열리므로 그래픽 데스크톱 세션에서 Iris를 실행하세요."
players_only = "§c플레이어 만!"
no_studio_world_is_open_2 = "§c스튜디오 세계가 오픈!"
you_must_be_studio_world = "§c스튜디오 세계에 있어야합니다!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Turbūt esi rainelės pasaulyje."
capturing_igendata_from_nearby_chunks = "Gaudymas IGenData nuo {value} Šalia šakutės."
reported = "Pranešama apie: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cJūs turite paleisti serverį GUIs įjungta nustatymuose!"
desktop_unavailable = "§cŠiame serveryje nėra prieinamo darbalaukio ekrano. Studio langai atidaromi serveryje. Paleiskite Iris grafinėje darbalaukio sesijoje."
players_only = "§cTik žaidėjai!"
no_studio_world_is_open_2 = "§cJokio studijos pasaulio nėra!"
you_must_be_studio_world = "§cJūs turbūt studijuojat!"
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Je moet in een iriswereld zijn."
capturing_igendata_from_nearby_chunks = "Vangen IGenData van {value} In de buurt."
reported = "Gerapporteerd aan: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cU moet de server gestart hebben GUIs ingeschakeld in de instellingen!"
desktop_unavailable = "§cDeze server heeft geen beschikbaar bureaubladscherm. Studio-vensters openen op de server. Start Iris in een grafische bureaubladsessie."
players_only = "§cAlleen spelers!"
no_studio_world_is_open_2 = "§cGeen enkele studiowereld is open!"
you_must_be_studio_world = "§cJe moet in een studio wereld zijn!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Musisz być w świecie tęczówki."
capturing_igendata_from_nearby_chunks = "Chwytanie IGenData od {value} Pobliskie chunki."
reported = "Zgłoszone do: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cMusisz mieć serwer uruchomiony GUIs włączone w ustawieniach!"
desktop_unavailable = "§cTen serwer nie ma dostępnego ekranu pulpitu. Okna Studio otwierają się na serwerze. Uruchom Iris w graficznej sesji pulpitu."
players_only = "§cTylko gracze!"
no_studio_world_is_open_2 = "§cŻadne studio nie jest otwarte!"
you_must_be_studio_world = "§cMusisz być w świecie studia!"
@@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Deves estar num mundo íris."
capturing_igendata_from_nearby_chunks = "Captura IGenData de {value} Pedaços próximos."
reported = "Comunicado em: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cVocê deve ter lançado o servidor GUIs habilitado nas configurações!"
desktop_unavailable = "§cEste servidor não dispõe de um ecrã de ambiente de trabalho. As janelas do Studio abrem no servidor. Execute o Iris numa sessão gráfica de ambiente de trabalho."
players_only = "§cSó jogadores!"
no_studio_world_is_open_2 = "§cNenhum mundo de estúdio está aberto!"
you_must_be_studio_world = "§cDeves estar num mundo de estúdio!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Вы должны быть в мире радужн
capturing_igendata_from_nearby_chunks = "захватывать IGenData из {value} близлежащих кусков."
reported = "Сообщалось: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cВы должны запустить сервер GUIs Включено в настройках!"
desktop_unavailable = "§cНа этом сервере недоступен графический рабочий стол. Окна Studio открываются на сервере. Запустите Iris в графическом сеансе рабочего стола."
players_only = "§cТолько игроки!"
no_studio_world_is_open_2 = "§cНи один студийный мир не открыт!"
you_must_be_studio_world = "§cВы должны быть в студии!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Bir iris dünyasında olmalısınız."
capturing_igendata_from_nearby_chunks = "Sheturing IGenData -den {value} Yakındaki chunks."
reported = "Rapor: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cSunucu başlatmış olmalısın GUIs ayarlarda etkinleştirin!"
desktop_unavailable = "§cBu sunucuda kullanılabilir bir masaüstü ekranı yok. Studio pencereleri sunucuda açılır. Iris’i grafik masaüstü oturumunda çalıştırın."
players_only = "§cOyuncular sadece!"
no_studio_world_is_open_2 = "§cStüdyo dünyası açık değil!"
you_must_be_studio_world = "§cBir stüdyo dünyasında olmalısın!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "Chắc cô đang ở thế giới của Iris."
capturing_igendata_from_nearby_chunks = "Bắt buộc IGenData từ {value} Gần đây thôi."
reported = "Báo cáo: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cBạn phải có máy chủ đã chạy GUIs đã bật trong thiết lập."
desktop_unavailable = "§cMáy chủ này không có màn hình đồ họa khả dụng. Cửa sổ Studio mở trên máy chủ. Hãy chạy Iris trong phiên làm việc có giao diện đồ họa."
players_only = "§cChỉ người chơi thôi!"
no_studio_world_is_open_2 = "§cKhông phòng thu nào mở cửa cả!"
you_must_be_studio_world = "§cCậu chắc đang ở trong phòng thu!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界里"
capturing_igendata_from_nearby_chunks = "隐藏 IGenData 从 {value} 附近块。"
reported = "向: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c您必须已启动服务器 GUIs 在设置中启用 !"
desktop_unavailable = "§c此服务器没有可用的桌面显示环境。Studio 窗口会在服务器上打开,请在图形桌面会话中运行 Iris。"
players_only = "§c只有球员!"
no_studio_world_is_open_2 = "§c没有工作室的世界是开放的!"
you_must_be_studio_world = "§c你一定在一个工作室的世界!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界裡"
capturing_igendata_from_nearby_chunks = "隱藏 IGenData 從 {value} 附近塊。"
reported = "向: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c您必須已啟動伺服器 GUIs 在設定中啟用 !"
desktop_unavailable = "§c此伺服器沒有可用的桌面顯示環境。Studio 視窗會在伺服器上開啟,請在圖形桌面工作階段中執行 Iris。"
players_only = "§c只有球員!"
no_studio_world_is_open_2 = "§c沒有工作室的世界是開放的!"
you_must_be_studio_world = "§c你一定在一個工作室的世界!"
@@ -97,6 +97,59 @@ public class EngineShutdownDrainTest {
}
}
@Test
public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
setField(fixture.engine, "nativeStructureBootstrapBarrier", barrier);
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(new IllegalStateException("Ring worker failed"));
doCallRealMethod().when(fixture.engine).drainNativeStructureBootstrap();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle).close();
assertTrue(fixture.engine.closed);
}
@Test
public void untrackedStructureBootstrapKeepsItsRuntimeAndRouterOpen() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
doThrow(new IllegalStateException("Bootstrap could not be tracked"))
.when(fixture.engine).drainNativeStructureBootstrap();
assertThrows(IllegalStateException.class, fixture.shutdown::close);
verify(fixture.engine, never()).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle, never()).close();
assertFalse(fixture.engine.closed);
}
@Test
public void structureBootstrapDrainsBeforeItsHistoryRouterCloses() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
AtomicBoolean ringsRunning = new AtomicBoolean(true);
doAnswer(invocation -> {
assertFalse(Thread.holdsLock(fixture.engine.lifecycleLock));
ringsRunning.set(false);
return null;
}).when(fixture.engine).drainNativeStructureBootstrap();
doAnswer(invocation -> {
assertFalse("Ring workers still need their generation-history router", ringsRunning.get());
return null;
}).when(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
assertTrue(fixture.engine.closed);
}
@Test
public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
@@ -541,6 +594,11 @@ public class EngineShutdownDrainTest {
ShutdownFixture fixture = new ShutdownFixture();
IrisComplex replacement = mock(IrisComplex.class);
fixture.prepareComplexHotload(replacement);
doAnswer(invocation -> {
assertFalse("Ring workers need the lifecycle lock while hotload waits",
Thread.holdsLock(fixture.engine.lifecycleLock));
return null;
}).when(fixture.engine).awaitNativeStructureBootstrap("complex hotload");
new EngineHotloader(fixture.engine).hotloadComplex();
@@ -880,6 +880,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet<IntConsumer>());
setField(engine, "shutdownSequence", new EngineShutdownSequence(engine));
setField(engine, "hotloader", new EngineHotloader(engine));
setField(engine, "nativeStructureBootstrapBarrier", new NativeStructureBootstrapBarrier());
setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo());
setField(engine, "closing", new AtomicBoolean(false));
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
@@ -10,10 +10,72 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class NativeStructureBootstrapBarrierTest {
@Test
public void failedRingWorkersCanBeReleasedOnceTheirCompletionSettles() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("ring failure");
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(failure);
assertSame(failure, barrier.awaitForClose());
assertFalse(barrier.isActive());
}
@Test
public void untrackedStarterFailureDoesNotAllowResourceRelease() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
assertThrows(IllegalStateException.class, () -> barrier.start(() -> {
}, () -> {
throw new IllegalStateException("partial scheduling");
}));
assertThrows(IllegalStateException.class, barrier::awaitForClose);
assertTrue(barrier.isActive());
}
@Test
public void exposedCompletionCannotReleaseRunningRingWork() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
CompletableFuture<Void> completion = barrier.start(() -> {
}, () -> rings);
completion.complete(null);
assertTrue(barrier.isActive());
rings.complete(null);
assertFalse(barrier.isActive());
}
@Test
public void waitingDoesNotHoldTheBarrierMonitor() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> close = executor.submit(() -> barrier.await("close"));
assertThrows(TimeoutException.class, () -> close.get(50L, TimeUnit.MILLISECONDS));
Future<?> worker = executor.submit(() -> {
assertTrue(barrier.isActive());
rings.complete(null);
});
worker.get(1L, TimeUnit.SECONDS);
close.get(1L, TimeUnit.SECONDS);
} finally {
rings.complete(null);
executor.shutdownNow();
}
}
@Test
public void immediateCloseWaitsForExactRingCompletion() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
@@ -0,0 +1,74 @@
package art.arcane.iris.studio.jigsaw;
import art.arcane.volmlib.util.event.ProtectionProbe;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.PlayerInventory;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.UUID;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class JigsawStudioProtectionProbeTest {
@Test
public void previewDoesNotMarkStudioDirty() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioDirtyTracker tracker = mock(JigsawStudioDirtyTracker.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
setField(service, "dirtyTracker", tracker);
setField(service, "tileWatcher", watcher);
PlayerInteractEvent event = probe();
service.protectionListener.onPlayerInteract(event);
verify(tracker, never()).markDirty(event.getClickedBlock());
verify(watcher, never()).finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
}
@Test
public void controlChestProbeRemainsDeniedWithoutOpeningItsMenu() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioProtection protection = mock(JigsawStudioProtection.class);
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
setField(service, "protection", protection);
setField(service, "toolbelt", toolbelt);
PlayerInteractEvent event = probe();
when(protection.isControlChest(event.getClickedBlock())).thenReturn(true);
try (MockedStatic<JigsawStudioProtection> authorization = mockStatic(JigsawStudioProtection.class)) {
authorization.when(() -> JigsawStudioProtection.authorizeOwner(event.getPlayer(), null)).thenReturn(true);
service.protectionListener.onControlChest(event);
assertTrue(event.isCancelled());
verify(toolbelt, never()).openControlMenu(any(Player.class));
}
}
private static PlayerInteractEvent probe() {
Player player = mock(Player.class);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
return ProtectionProbe.blockInteract(player, mock(Block.class), EquipmentSlot.HAND);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,92 @@
package art.arcane.iris.studio.jigsaw;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class JigsawStudioToolInteractionTest {
@Test
public void airClickReachesTheToolDespiteBukkitsDefaultCancellation() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent event = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
assertTrue(event.isCancelled());
dispatch(fixture.listener(), event);
verify(fixture.toolbelt()).useTool(fixture.player(), fixture.payload(), null, false);
verify(fixture.watcher()).finalizeJigsawTileWatchesForPlayer(fixture.player().getUniqueId());
}
@Test
public void deniedBlockAndItemInteractionsDoNotUseTheTool() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent block = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
block.setUseInteractedBlock(Event.Result.DENY);
dispatch(fixture.listener(), block);
PlayerInteractEvent air = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
air.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), air);
PlayerInteractEvent item = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
item.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), item);
verifyNoInteractions(fixture.codec(), fixture.toolbelt(), fixture.watcher());
}
private static Fixture fixture() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
JigsawStudioToolCodec codec = mock(JigsawStudioToolCodec.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
JigsawStudioToolPayload payload = mock(JigsawStudioToolPayload.class);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(codec.decode((ItemStack) null)).thenReturn(Optional.of(payload));
setField(service, "toolbelt", toolbelt);
setField(service, "tileWatcher", watcher);
setField(toolbelt, "toolCodec", codec);
return new Fixture(service.protectionListener, toolbelt, codec, watcher, payload, player);
}
private static PlayerInteractEvent event(Player player, Action action, Block block) {
return new PlayerInteractEvent(player, action, null, block, BlockFace.UP, EquipmentSlot.HAND);
}
private static void dispatch(JigsawStudioProtectionListener listener, PlayerInteractEvent event) throws Exception {
EventHandler annotation = JigsawStudioProtectionListener.class
.getMethod("onUseTool", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener registered = new RegisteredListener(listener,
(ignored, dispatched) -> listener.onUseTool((PlayerInteractEvent) dispatched),
EventPriority.LOWEST, mock(Plugin.class), annotation.ignoreCancelled());
registered.callEvent(event);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private record Fixture(JigsawStudioProtectionListener listener, JigsawStudioToolbelt toolbelt,
JigsawStudioToolCodec codec, JigsawStudioTileWatcher watcher,
JigsawStudioToolPayload payload, Player player) {
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.world.task.J;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class StudioBootstrapActivationTest {
@Test(timeout = 5000L)
public void activationReturnsPendingFutureAndFinishesOnlyAfterNativeRings() throws Exception {
verifyActivation(false);
}
@Test(timeout = 5000L)
public void failedNativeRingsPreserveCauseAndDoNotEndBootstrap() throws Exception {
verifyActivation(true);
}
private void verifyActivation(boolean fail) throws Exception {
BukkitChunkGenerator provider = mock(BukkitChunkGenerator.class);
World world = mock(World.class);
INMSBinding nms = mock(INMSBinding.class);
CompletableFuture<Void> rings = new CompletableFuture<>();
when(nms.completeStudioStructureBootstrap(world)).thenReturn(rings);
try (MockedStatic<INMS> binding = mockStatic(INMS.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
binding.when(INMS::get).thenReturn(nms);
scheduling.when(() -> J.sfut(any(Supplier.class))).thenAnswer(invocation -> {
Supplier<?> supplier = invocation.getArgument(0);
return CompletableFuture.completedFuture(supplier.get());
});
scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return CompletableFuture.completedFuture(null);
});
CompletableFuture<Void> activation = activate(world, provider);
assertFalse(activation.isDone());
verify(provider, never()).endStudioEntryBootstrap();
if (fail) {
IllegalStateException failure = new IllegalStateException("ring calculation failed");
rings.completeExceptionally(failure);
CompletionException reported = assertThrows(CompletionException.class, activation::join);
assertSame(failure, reported.getCause());
verify(provider, never()).endStudioEntryBootstrap();
} else {
rings.complete(null);
activation.join();
verify(provider).endStudioEntryBootstrap();
}
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<Void> activate(World world, PlatformChunkGenerator provider) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod(
"endStudioEntryBootstrap", World.class, PlatformChunkGenerator.class);
method.setAccessible(true);
return (CompletableFuture<Void>) method.invoke(StudioOpenCoordinator.get(), world, provider);
}
}
@@ -0,0 +1,122 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class StudioCloseRecoveryTest {
@Test
public void failedGeneratorCloseKeepsMutationReservationWithoutRestartingServer() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
IllegalStateException failure = new IllegalStateException("generator cleanup failed");
when(provider.closeAsync()).thenReturn(CompletableFuture.failedFuture(failure));
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
StudioOpenCoordinator.StudioCloseResult result = close(provider, null).join();
assertSame(failure, result.failureCause());
configurator.verifyNoInteractions();
verify(lease, never()).close();
}
}
@Test
public void pendingGeneratorRetainsProjectAndMutationReservationUntilCleanupFinishes() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close = close(provider, project);
assertFalse(close.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
close.join();
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@Test
public void cancellingCloseResultDoesNotSkipCleanupOrLeaseRelease() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> result = close(provider, project);
assertTrue(result.cancel(false));
assertFalse(generatorClose.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
assertTrue(result.isCancelled());
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close(
PlatformChunkGenerator provider,
IrisProject project
) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod("closeWorldCoordinated",
PlatformChunkGenerator.class, String.class, World.class, boolean.class, IrisProject.class);
method.setAccessible(true);
return (CompletableFuture<StudioOpenCoordinator.StudioCloseResult>) method.invoke(
StudioOpenCoordinator.get(), provider, null, null, false, project);
}
}
@@ -8,9 +8,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import art.arcane.iris.spi.IrisLogging;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic;
@@ -77,11 +79,12 @@ public class StudioOpenCoordinatorCloseSequenceTest {
}
@Test
public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() {
public void slowCloseKeepsWaitingAndFinishesRemainingPhasesWithoutRestart() {
ArrayList<String> phases = new ArrayList<>();
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Void> unload = new CompletableFuture<>();
AtomicReference<Runnable> warning = new AtomicReference<>();
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"),
() -> {
@@ -89,35 +92,19 @@ public class StudioOpenCoordinatorCloseSequenceTest {
return unload;
},
() -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders"),
terminalTimeout::get);
() -> phase(phases, "delete-folders"));
StudioOpenCoordinator.warnOnSlowClose(close, "studio-world", warning::set);
terminalTimeout.set(true);
unload.complete(null);
try {
close.join();
fail("Expected terminal timeout");
} catch (CompletionException exception) {
assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage());
}
warning.get().run();
assertFalse(close.isDone());
assertFalse(unload.isDone());
assertEquals(List.of("evacuate", "unload"), phases);
}
configurator.verifyNoInteractions();
@Test
public void closeFailureAfterUnloadBeginsRequestsTerminalRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", true);
configurator.verify(() -> ServerConfigurator.restart(
"Studio close failed after world unload began for \"studio-world\"."));
}
}
@Test
public void closeFailureBeforeUnloadDoesNotRequestRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", false);
unload.complete(null);
close.join();
assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases);
configurator.verifyNoInteractions();
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.awt.AWTEvent;
import java.awt.EventQueue;
import java.awt.Toolkit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
public class DesktopGuiLaunchTest {
private IrisSettings previousSettings;
private boolean previousSuppressed;
private RecordingEventQueue events;
@Before
public void suppressDesktop() throws Exception {
previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
previousSuppressed = GuiHost.isDesktopSuppressed();
GuiHost.suppressDesktop(true);
events = new RecordingEventQueue();
Toolkit.getDefaultToolkit().getSystemEventQueue().push(events);
EventQueue.invokeAndWait(() -> {
});
}
@After
public void restoreDesktop() throws Exception {
EventQueue.invokeAndWait(events::remove);
GuiHost.suppressDesktop(previousSuppressed);
IrisSettings.settings = previousSettings;
}
@Test
public void mapLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
VisionGUI.launch(null, null);
assertNoDesktopFailure();
}
@Test
public void noiseLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launch();
assertNoDesktopFailure();
}
@Test
public void generatorLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launchGeneratorKey("test", null, 12345L);
assertNoDesktopFailure();
}
@Test
public void imageMapLaunchDoesNotReadEngineWithoutDesktop() throws Exception {
ImageMapStudioGUI.launch(null);
assertNoDesktopFailure();
}
private void assertNoDesktopFailure() throws Exception {
EventQueue.invokeAndWait(() -> {
});
assertNull("Desktop launch raised an exception on the AWT event thread", events.failure.get());
}
private static final class RecordingEventQueue extends EventQueue {
private final AtomicReference<Throwable> failure = new AtomicReference<>();
@Override
protected void dispatchEvent(AWTEvent event) {
try {
super.dispatchEvent(event);
} catch (Throwable error) {
failure.compareAndSet(null, error);
}
}
private void remove() {
pop();
}
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.world.lifecycle;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
@@ -10,9 +12,13 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
@@ -22,6 +28,7 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@@ -237,6 +244,96 @@ public class WorldLifecycleUnloadAsyncTest {
assertEquals("remembered", service.backendNameForWorld(worldKey));
}
@Test
public void settledUnloadRetainsTheBoundaryUntilBackendCompletionWithoutATerminalTimer() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "settled_boundary");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertFalse(boundary.toCompletableFuture().isDone());
assertFalse(result.isDone());
assertTrue(timeouts.isEmpty());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.join());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
@Test
public void cancellingSettledUnloadResultPreservesBackendCompletionBookkeeping() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "cancelled_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertTrue(result.cancel(false));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.isCancelled());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
@Test
public void guardedUnloadKeepsItsTimeoutAndTracksLateBackendSuccess() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "late_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertEquals(1, timeouts.size());
timeouts.getFirst().run();
CompletionException failure = assertThrows(CompletionException.class, result::join);
assertTrue(failure.getCause() instanceof TimeoutException);
configurator.verify(() -> ServerConfigurator.restart("World unload timed out for \"late_settlement\"."));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(boundary.toCompletableFuture().join());
assertTrue(result.isCompletedExceptionally());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
private static WorldLifecycleService service(
ControlledBackend remembered,
ControlledBackend fallback