mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Command Corrections.
This commit is contained in:
@@ -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,22 +738,41 @@ public class CommandFind implements DirectorExecutor {
|
||||
|
||||
private void prepareStructureTeleport(Player target, World world, VolmitSender commandSender, String structure,
|
||||
Location at, boolean useLocatedY) {
|
||||
int chunkX = at.getBlockX() >> 4;
|
||||
int chunkZ = at.getBlockZ() >> 4;
|
||||
BukkitPlatform.chunkAtAsync(world, chunkX, chunkZ, true).whenComplete((chunk, error) -> {
|
||||
if (error != null) {
|
||||
sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + ".");
|
||||
Iris.reportError("Could not load structure destination '" + structure + "'.", error);
|
||||
J.runEntity(target, () -> {
|
||||
if (!target.isOnline()) {
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ,
|
||||
() -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY));
|
||||
if (!scheduled) {
|
||||
sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + ".");
|
||||
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 + ".");
|
||||
Iris.reportError("Could not load structure destination '" + structure + "'.", error);
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ,
|
||||
() -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY));
|
||||
if (!scheduled) {
|
||||
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,
|
||||
Location at, boolean useLocatedY) {
|
||||
try {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+178
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
|
||||
+48
-23
@@ -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,32 +45,18 @@ 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;
|
||||
}
|
||||
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);
|
||||
"Native structure bootstrap failed before " + transition + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
Throwable awaitForClose() {
|
||||
return awaitCompletion("close");
|
||||
}
|
||||
|
||||
synchronized boolean isActive() {
|
||||
return active != null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+14
-1
@@ -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,69 +458,37 @@ 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);
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
project.setActiveOpenKind(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
|
||||
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
|
||||
() -> deleteFolder
|
||||
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
|
||||
: CompletableFuture.completedFuture(null),
|
||||
terminalTimeout::get
|
||||
: CompletableFuture.completedFuture(null)
|
||||
);
|
||||
CompletableFuture<StudioCloseResult> operation = guardCloseCompletion(
|
||||
sequence,
|
||||
terminalTimeout,
|
||||
worldName)
|
||||
.thenApply(ignored -> new StudioCloseResult(
|
||||
worldName,
|
||||
true,
|
||||
folderDeleted.get(),
|
||||
false,
|
||||
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);
|
||||
}
|
||||
if (world != null) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
|
||||
}
|
||||
return 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
|
||||
);
|
||||
worldName, unloadConfirmed.get(), folderDeleted.get(), queued, failure);
|
||||
});
|
||||
return operation.whenComplete((result, throwable) -> {
|
||||
if (world != null) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void requestRestartAfterPartialClose(String worldName, boolean unloadStarted) {
|
||||
if (unloadStarted) {
|
||||
ServerConfigurator.restart("Studio close failed after world unload began for \"" + worldName + "\".");
|
||||
}
|
||||
}
|
||||
|
||||
static CompletableFuture<Void> sequenceStudioClose(
|
||||
@@ -556,75 +496,28 @@ public final class StudioOpenCoordinator {
|
||||
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();
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+62
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
-36
@@ -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,47 +79,32 @@ 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"),
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return unload;
|
||||
},
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folders"));
|
||||
StudioOpenCoordinator.warnOnSlowClose(close, "studio-world", warning::set);
|
||||
|
||||
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
|
||||
() -> phase(phases, "evacuate"),
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return unload;
|
||||
},
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folders"),
|
||||
terminalTimeout::get);
|
||||
warning.get().run();
|
||||
assertFalse(close.isDone());
|
||||
assertFalse(unload.isDone());
|
||||
assertEquals(List.of("evacuate", "unload"), phases);
|
||||
configurator.verifyNoInteractions();
|
||||
|
||||
terminalTimeout.set(true);
|
||||
unload.complete(null);
|
||||
try {
|
||||
unload.complete(null);
|
||||
close.join();
|
||||
fail("Expected terminal timeout");
|
||||
} catch (CompletionException exception) {
|
||||
assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage());
|
||||
}
|
||||
assertEquals(List.of("evacuate", "unload"), phases);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user