Integrate Iris Studio

- Merge the unified Studio interface and command entry points.
- Adapt water views and provisional sampling to basin hydrology.
- Restore diagnostic estimates and the biome water overlay.
- Fix render cancellation, preview closure, and platform position callbacks.
- Fall back to software rendering when native bindings are unavailable.
- Cover renderer, lifecycle, and scheduler regressions.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-25 16:56:49 -04:00
commit 9f49d29e2b
225 files changed
+29666 -4395

No files matched your search

@@ -60,6 +60,7 @@ import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC;
@@ -988,6 +989,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
GuiHost.shutdown();
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
@@ -21,10 +21,9 @@ 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.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
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;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -98,7 +97,9 @@ import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
@@ -255,15 +256,10 @@ public class CommandStudio implements DirectorExecutor {
long seed
) {
if (noGUI()) return;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
if (generator == null) {
NoiseExplorerGUI.launch();
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
}
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -279,8 +275,7 @@ public class CommandStudio implements DirectorExecutor {
return;
}
Engine activeEngine = IrisToolbelt.access(world).getEngine();
ImageMapStudioGUI.launch(activeEngine);
requestMapStart(world, MapWindow::openImageMaps);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
}
@@ -420,11 +415,61 @@ public class CommandStudio implements DirectorExecutor {
return;
}
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
requestMapStart(world, MapWindow::openMap);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
// The map opens where the player stands when they are in that world, otherwise at the world origin.
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
return new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
openerId,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
);
}
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
}
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
World world = opener == null ? null : opener.getWorld();
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
if (engine == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
}
PaletteExplorerStart start = worldGenerator == null
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
: mapStartIn(world, opener, openerId);
MapWindow.openNoise(start, selection);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
}
private void withPlayerContext(Consumer<Player> action) {
Player opener = sender().isPlayer() ? player() : null;
if (opener == null) {
action.accept(null);
return;
}
AtomicBoolean completed = new AtomicBoolean();
Consumer<Player> complete = player -> {
if (completed.compareAndSet(false, true)) {
action.accept(player);
}
};
Runnable retired = () -> complete.accept(null);
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
retired.run();
}
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
GuiHost.set(new BukkitGuiHost());
}
/**
* The engine of the open studio project, which reads the pack folder being edited, or else the first
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
*/
@Override
public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
}
}
} catch (Throwable ignored) {
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
return null;
}
@Override
public Engine findEngine(UUID worldId) {
World world = worldId == null ? null : Bukkit.getWorld(worldId);
return world == null ? null : engineOf(world);
}
private static Engine findStudioEngine() {
try {
StudioSVC studio = Iris.service(StudioSVC.class);
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
Engine engine = provider == null ? null : provider.getEngine();
return engine == null || engine.isClosed() ? null : engine;
} catch (Throwable ignored) {
return null;
}
}
private static Engine engineOf(World world) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
}
return null;
}
@Override
public void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload);
@@ -18,23 +18,19 @@
package art.arcane.iris.studio.view;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong();
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
private volatile List<WorldMarker> playerMarkers = List.of();
private volatile List<GuiMarker> playerMarkers = List.of();
public BukkitVisionOverlay(Engine engine, UUID openerId) {
this.engine = engine;
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread.
*/
@Override
public List<WorldMarker> players() {
public List<GuiMarker> players() {
queuePlayerRefresh();
return playerMarkers;
}
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
boolean scheduled = J.runGlobal(() -> {
try {
List<WorldMarker> markers = new ArrayList<>();
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
Location at = player.getLocation();
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
}
playerMarkers = List.copyOf(markers);
} finally {
playerRefreshQueued.set(false);
}
});
boolean scheduled = J.runGlobal(this::refreshPlayers);
if (!scheduled) {
playerRefreshQueued.set(false);
}
}
private void refreshPlayers() {
World world = BukkitWorldBinding.world(engine.getWorld());
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
if (players.isEmpty() || world == null) {
playerMarkers = List.of();
playerRefreshQueued.set(false);
return;
}
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
AtomicInteger pending = new AtomicInteger(players.size());
for (Player player : players) {
AtomicBoolean completed = new AtomicBoolean();
Runnable complete = () -> {
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
playerMarkers = List.copyOf(markers);
playerRefreshQueued.set(false);
}
};
if (!J.runEntity(player, () -> {
try {
if (player.isOnline() && player.getWorld() == world) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
}
} finally {
complete.run();
}
}, 0, complete)) {
complete.run();
}
}
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
public void requestEntities(Consumer<List<GuiMarker>> sink) {
J.runGlobal(() -> {
IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target);
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
AtomicInteger pending = new AtomicInteger(living.size());
Runnable complete = () -> {
if (pending.decrementAndGet() == 0) {
@@ -144,22 +162,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
});
}
private WorldMarker marker(LivingEntity entity, Location at) {
private GuiMarker marker(LivingEntity entity, Location at) {
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
double maxHealth = 0;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) {
}
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
}
@Override
public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(),
VisionGUI.floorWorldCoordinate(worldX),
VisionGUI.floorWorldCoordinate(worldZ));
(int) StrictMath.floor(worldX),
(int) StrictMath.floor(worldZ));
latestTeleport.set(request);
startTeleport(request);
}
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
}
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
AtomicBoolean completed = new AtomicBoolean();
Consumer<OpenerPosition> complete = position -> {
if (completed.compareAndSet(false, true)) {
sink.accept(position);
}
};
if (openerId == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
boolean scheduled = J.runGlobal(() -> {
Player player = Bukkit.getPlayer(openerId);
if (player == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
retired.run();
}
});
if (!scheduled) {
complete.accept(OpenerPosition.OFFLINE);
}
}
private static OpenerPosition positionOf(Player player, World world) {
if (!player.isOnline()) {
return OpenerPosition.OFFLINE;
}
if (world == null || player.getWorld() != world) {
return OpenerPosition.IN_OTHER_WORLD;
}
Location at = player.getLocation();
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
}
private Player selectPlayer(List<Player> players) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ blockX + "," + blockZ + ".", failure);
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
}
private static final class VisionTeleportRequest {
private final long sequence;
private final int blockX;
@@ -0,0 +1,99 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.task.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class StudioMapPositionTest {
@Test
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
World world = mock(World.class);
Player player = mock(Player.class);
VolmitSender sender = mock(VolmitSender.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
Engine engine = mock(Engine.class);
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
AtomicBoolean owned = new AtomicBoolean();
AtomicReference<Runnable> task = new AtomicReference<>();
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenAnswer(invocation -> {
assertTrue("world read must use the player scheduler", owned.get());
return world;
});
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read must use the player scheduler", owned.get());
return new Location(world, 33.25D, 91D, -72.5D);
});
when(sender.isPlayer()).thenReturn(true);
when(sender.player()).thenReturn(player);
when(generator.getEngine()).thenReturn(engine);
CommandStudio command = new CommandStudio() {
@Override
public VolmitSender sender() {
return sender;
}
};
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
host.when(GuiHost::isAvailable).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task.set(invocation.getArgument(1));
return true;
});
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
start.set(invocation.getArgument(0));
return null;
});
command.map(world);
assertNull(start.get());
owned.set(true);
task.get().run();
assertEquals(engine, start.get().engine());
assertEquals(playerId, start.get().playerId());
assertEquals(worldId, start.get().worldId());
assertEquals(33.25D, start.get().centerX(), 0D);
assertEquals(-72.5D, start.get().centerZ(), 0D);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,165 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class BukkitOpenerPositionTest {
@Test
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
try (Harness harness = new Harness()) {
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
when(harness.player.getName()).thenReturn("Explorer");
assertTrue(harness.overlay.players().isEmpty());
harness.runEntity();
List<GuiMarker> markers = harness.overlay.players();
assertEquals(1, markers.size());
assertEquals("Explorer", markers.getFirst().label());
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
}
}
@Test
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue(harness.results.isEmpty());
harness.runEntity();
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void retiredEntityCompletesTheRequestOnce() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue("retirement callback must be registered", harness.retired != null);
harness.retired.run();
harness.retired.run();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.isOnline()).thenReturn(false);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
when(harness.player.isOnline()).thenReturn(true);
harness.overlay.requestOpenerPosition(harness.results::add);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
try (Harness harness = new Harness()) {
harness.reject = true;
harness.overlay.requestOpenerPosition(harness.results::add);
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void changingWorldBeforeCaptureReportsOtherWorld() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.getWorld()).thenReturn(mock(World.class));
harness.runEntity();
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
}
}
private static final class Harness implements AutoCloseable {
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Engine engine = mock(Engine.class);
private final IrisWorld target = mock(IrisWorld.class);
private final UUID opener = UUID.randomUUID();
private final List<OpenerPosition> results = new ArrayList<>();
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
private Runnable task;
private Runnable retired;
private boolean entityOwned;
private boolean reject;
private Harness() {
when(engine.getWorld()).thenReturn(target);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read outside owning entity task", entityOwned);
return new Location(world, 18.5D, 72D, -4.25D);
});
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(0, Runnable.class).run();
return true;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
task = invocation.getArgument(1);
return !reject;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task = invocation.getArgument(1);
retired = invocation.getArgument(3);
if (reject) {
retired.run();
}
return !reject;
});
}
private void runEntity() {
entityOwned = true;
try {
task.run();
} finally {
entityOwned = false;
}
}
@Override
public void close() {
binding.close();
bukkit.close();
scheduling.close();
}
}
}
+5 -1
View File
@@ -131,6 +131,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -216,7 +218,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle'].each { String configuration ->
+7 -1
View File
@@ -152,12 +152,16 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.named('runBundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -215,7 +219,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
@@ -23,10 +23,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
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;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.StructurePackageClosure;
import art.arcane.iris.pack.ImageMapPackageClosure;
@@ -38,6 +37,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.pack.PackExportClosure;
import art.arcane.iris.world.entity.IrisEntity;
@@ -247,8 +247,9 @@ public final class ModdedStudioCommands {
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
}
if (generatorKey == null || generatorKey.isBlank()) {
NoiseExplorerGUI.launch();
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
return 1;
}
@@ -262,7 +263,7 @@ public final class ModdedStudioCommands {
return 0;
}
String selectedGeneratorKey = generatorKey.trim();
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(selectedGeneratorKey, generator, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
return 1;
}
@@ -280,11 +281,20 @@ public final class ModdedStudioCommands {
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
VisionGUI.launch(engine, player == null ? null : player.id());
MapWindow.openMap(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
return 1;
}
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
IrisWorld world = engine.getWorld();
NativeWorld level = world == null ? null : world.platformWorld();
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
}
private static int imageMap(NativeCommandSource source) {
NativeWorld level = source.world();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -294,13 +304,13 @@ public final class ModdedStudioCommands {
));
return 0;
}
if (!ImageMapStudioGUI.isAvailable()) {
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
IrisModdedCommands.fail(source, guiUnavailableMessage());
return 0;
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
ImageMapStudioGUI.launch(engine);
MapWindow.openImageMaps(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
MessageArgument.untrusted("value", level.name())
@@ -18,21 +18,22 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.studio.view.GuiMarker;
import art.arcane.iris.studio.view.GuiOverlay;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.awt.Desktop;
import java.io.File;
import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -46,14 +47,33 @@ public final class ModdedVisionOverlay implements GuiOverlay {
opener = context.opener();
}
// WorldView doesn't report which way a player faces, so their markers all face south.
@Override
public List<WorldMarker> players() {
return world.players();
public List<GuiMarker> players() {
return world.players().stream()
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
.toList();
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
world.requestEntities(sink);
public void requestEntities(Consumer<List<GuiMarker>> sink) {
world.requestEntities(entities -> sink.accept(entities.stream()
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
entity.health(), entity.maxHealth()))
.toList()));
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
if (opener == null) {
sink.accept(OpenerPosition.OFFLINE);
return;
}
try {
world.execute(() -> sink.accept(openerPosition()));
} catch (RejectedExecutionException failure) {
sink.accept(OpenerPosition.OFFLINE);
}
}
@Override
@@ -80,23 +100,25 @@ public final class ModdedVisionOverlay implements GuiOverlay {
});
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
return null;
private OpenerPosition openerPosition() {
IrisWorld target = engine.getWorld();
if (target == null || engine.isClosing() || engine.isClosed()) {
return OpenerPosition.OFFLINE;
}
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
NativeWorld level = target.platformWorld();
if (level == null) {
return OpenerPosition.OFFLINE;
}
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
if (player == null || !player.connected()) {
return OpenerPosition.OFFLINE;
}
if (!player.inWorld(level)) {
return OpenerPosition.IN_OTHER_WORLD;
}
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
}
public record Context(WorldView world, Engine engine, UUID opener) {
}
@@ -1,25 +1,98 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedVisionOverlayTest {
@Test
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
WorldView world = mock(WorldView.class);
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
mock(Engine.class), UUID.randomUUID()));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
overlay.requestOpenerPosition(position::set);
assertEquals(OpenerPosition.OFFLINE, position.get());
}
@Test
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
WorldView world = mock(WorldView.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
doAnswer(invocation -> {
scheduled.set(invocation.getArgument(0));
return null;
}).when(world).execute(any(Runnable.class));
Engine engine = mock(Engine.class);
IrisWorld irisWorld = mock(IrisWorld.class);
NativeWorld level = mock(NativeWorld.class);
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID opener = UUID.randomUUID();
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.platformWorld()).thenReturn(level);
when(server.player(opener)).thenReturn(player);
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(true);
when(player.x()).thenReturn(21.5D);
when(player.y()).thenReturn(85.25D);
when(player.z()).thenReturn(-39.5D);
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
overlay.requestOpenerPosition(position::set);
assertNull("position reads must wait for the server task", position.get());
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
overlay.requestOpenerPosition(position::set);
when(player.connected()).thenReturn(false);
scheduled.get().run();
assertEquals(OpenerPosition.OFFLINE, position.get());
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(false);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
when(player.inWorld(level)).thenReturn(true);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
}
}
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
+5 -1
View File
@@ -132,6 +132,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -173,7 +175,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
add('bundle', libs.asm)
add('devBundle', 'org.ow2.asm:asm') {