Added Studio hotload command

- /iris studio hotload and its refresh alias reload the open Studio pack on Bukkit
- Bukkit hotload refuses while Studio entry, pause or close is pending
- Modded servers register the same hotload and refresh commands
- Modded hotload reports busy while that Studio is already hotloading
- Hotload messages and help entries are localized in every language file
- Bukkit artifact verification reads the jar task output
- Multiplexor takes the Bukkit artifact from the packedJar task
- BukkitTestServer shares one-time registry setup across tests
- Tests and a gameplay scenario cover both hotload commands
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-28 13:32:33 -04:00
1 parent 300ecbfc8b
commit b3579e623a
37 files changed
+902 -52

No files matched your search

@@ -150,6 +150,7 @@ final class ModdedCommandHelp {
Entry.command("close", "", ModdedHelpMessages.COMMAND_CLOSE_CLOSE_THE_OPEN_STUDIO_DIMENSION_AND_DISCARD_ITS_WORLD, "x"),
Entry.command("tpstudio", "", ModdedHelpMessages.COMMAND_TPSTUDIO_TELEPORT_INTO_THE_OPEN_STUDIO_DIMENSION, "stp"),
Entry.command("status", "", ModdedHelpMessages.COMMAND_STATUS_SHOW_THE_OPEN_STUDIO_DIMENSION_AND_ITS_PACK),
Entry.command("hotload", "", ModdedHelpMessages.COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO, "refresh"),
Entry.command("noise", "[generator] [seed]", ModdedHelpMessages.COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY, "nmap"),
Entry.command("map", "", ModdedHelpMessages.COMMAND_MAP_OPEN_THE_VISION_MAP_GUI_ON_THE_SERVER_DISPLAY, "render"),
Entry.command("vscode", "[pack]", ModdedHelpMessages.COMMAND_VSCODE_REGENERATE_THE_CODE_WORKSPACE_FOR_A_PACK_AND_OPEN_IT_IN_YOUR, "vsc"),
@@ -49,6 +49,7 @@ import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.iris.modded.ModdedWorkspaceGenerator;
import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.generation.concurrent.BurstExecutor;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.volmlib.util.collection.KMap;
@@ -85,6 +86,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@@ -92,6 +94,7 @@ import java.util.stream.Stream;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.modded.localization.ModdedCommandMessages;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.MessageArgument;
public final class ModdedStudioCommands {
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
@@ -152,6 +155,10 @@ public final class ModdedStudioCommands {
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> tpStudio(context.getSource()))));
root.then(NativeCommandRegistration.literal("status")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> status(context.getSource()))));
root.then(NativeCommandRegistration.literal("hotload")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> hotload(context.getSource()))));
root.then(NativeCommandRegistration.literal("refresh")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> hotload(context.getSource()))));
root.then(workspaceTree("vscode", true));
root.then(workspaceTree("vsc", true));
root.then(workspaceTree("update", false));
@@ -653,6 +660,72 @@ public final class ModdedStudioCommands {
return transition;
}
private static int hotload(NativeCommandSource source) {
NativeProtocolPlayer player = source.player();
NativeModdedServer server = source.server();
UUID owner = player == null ? CONSOLE_OWNER : player.id();
UUID audience = source.playerId();
Executor feedback = task -> server.execute(() -> LanguageAudience.run(audience, task));
CompletableFuture<Void> transition = TRANSITIONS.submit(owner, () -> hotloadTransition(source, owner, feedback));
transition.whenComplete((ignored, failure) -> {
if (failure == null) {
return;
}
Throwable cause = unwrapFailure(failure);
ModdedIrisLog.error("Iris Studio hotload command failed", cause);
feedback.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED,
MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))));
});
return 1;
}
private static CompletableFuture<Void> hotloadTransition(
NativeCommandSource source,
UUID owner,
Executor feedback
) {
return CompletableFuture.supplyAsync(() -> hotloadStudio(source, owner, feedback), feedback)
.thenCompose(request -> request);
}
private static CompletableFuture<Void> hotloadStudio(
NativeCommandSource source,
UUID owner,
Executor feedback
) {
String dimensionId = STUDIOS.get(owner);
if (dimensionId == null) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
return CompletableFuture.completedFuture(null);
}
NativeWorld studio = ModdedDimensionManager.level(source.server(), dimensionId);
if (studio == null) {
STUDIOS.remove(owner, dimensionId);
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO));
return CompletableFuture.completedFuture(null);
}
Engine engine = IrisModdedCommands.engineFor(studio);
ModdedStudioHotloadService service = ModdedEngineBootstrap.services().service(ModdedStudioHotloadService.class);
if (engine == null || service == null) {
throw new IllegalStateException("The Studio engine or hotload service is unavailable.");
}
return service.requestHotload(dimensionId, engine)
.thenAcceptAsync(completed -> sendHotloadResult(source, completed), feedback);
}
private static void sendHotloadResult(NativeCommandSource source, boolean completed) {
if (completed) {
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE));
} else {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY));
}
}
private static int status(NativeCommandSource source) {
NativeModdedServer server = source.server();
List<ModdedDimensionManager.Handle> handles = ModdedDimensionManager.handles();
@@ -1016,6 +1016,18 @@ public final class ModdedCommandMessages {
"iris.modded.moddedstudiocommands.studio_close_failed",
"Studio close failed: " + "{value}" + "{errorMessage}"
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_complete",
"Studio hotloaded."
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_busy",
"Studio is already checking or hotloading its pack. Try again after it finishes."
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_failed",
"Studio hotload failed{errorMessage}"
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED = TextKey.of(
"iris.modded.moddedstudiocommands.studio_closed_was_evacuated_unloaded_its_region_data_deleted",
"Studio closed: " + "{dimensionId}" + " was evacuated, unloaded and its region data deleted."
@@ -1530,6 +1542,9 @@ public final class ModdedCommandMessages {
MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN,
MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN,
MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED,
MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED,
MODDED_STUDIO_COMMANDS_NO_STUDIO_DIMENSIONS_ARE_CURRENTLY_OPEN,
MODDED_STUDIO_COMMANDS_ACTIVE_STUDIO_DIMENSION_S,
@@ -242,6 +242,10 @@ public final class ModdedHelpMessages {
"iris.modded.help.entry.command.status_studio",
"Show the open studio dimension and its pack"
);
public static final TextKey COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO = TextKey.of(
"iris.modded.help.entry.command.hotload_studio",
"Reload the open Studio pack for newly generated terrain"
);
public static final TextKey COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY = TextKey.of(
"iris.modded.help.entry.command.noise",
"Open the Noise Explorer GUI on the server display"
@@ -479,6 +483,7 @@ public final class ModdedHelpMessages {
COMMAND_CLOSE_CLOSE_THE_OPEN_STUDIO_DIMENSION_AND_DISCARD_ITS_WORLD,
COMMAND_TPSTUDIO_TELEPORT_INTO_THE_OPEN_STUDIO_DIMENSION,
COMMAND_STATUS_SHOW_THE_OPEN_STUDIO_DIMENSION_AND_ITS_PACK,
COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO,
COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY,
COMMAND_MAP_OPEN_THE_VISION_MAP_GUI_ON_THE_SERVER_DISPLAY,
COMMAND_VSCODE_REGENERATE_THE_CODE_WORKSPACE_FOR_A_PACK_AND_OPEN_IT_IN_YOUR,
@@ -123,6 +123,33 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
watches.clear();
}
public CompletableFuture<Boolean> requestHotload(String dimensionId, Engine engine) {
if (!dimensionId.startsWith(STUDIO_DIMENSION_PREFIX) || !engine.isStudio()) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Only Studio worlds can be hotloaded."));
}
ExecutorService active = executor;
if (active == null || active.isShutdown()) {
return CompletableFuture.failedFuture(new IllegalStateException("Iris Studio hotload is unavailable."));
}
Watch watch = watches.computeIfAbsent(dimensionId, (String key) -> new Watch());
if (!watch.busy.compareAndSet(false, true)) {
return CompletableFuture.completedFuture(false);
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
hotload(dimensionId, engine);
return true;
} finally {
watch.busy.set(false);
}
}, active);
} catch (RejectedExecutionException rejected) {
watch.busy.set(false);
return CompletableFuture.failedFuture(rejected);
}
}
@Override
public void refreshWorkspace(Engine engine) {
writeWorkspace(engine, "workspace refresh");
@@ -254,7 +281,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
try {
active.execute(() -> {
try {
poll(dimensionId, watch, generator, engine);
poll(dimensionId, watch, engine);
} finally {
watch.busy.set(false);
}
@@ -332,14 +359,14 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
return isPregeneratorActive(engine);
}
private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) {
private void poll(String dimensionId, Watch watch, Engine engine) {
try {
if (watch.engine != engine) {
watch.close();
watch.engine = engine;
watch.folder = new ReactiveFolder(
engine.getData().getDataFolder(),
(KList<File> created, KList<File> changed, KList<File> deleted) -> hotload(dimensionId, generator, engine),
(KList<File> created, KList<File> changed, KList<File> deleted) -> hotload(dimensionId, engine),
new KList<>(".iob", ".json"),
new KList<>(".iris"),
new KList<>());
@@ -354,12 +381,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
}
}
private void hotload(String dimensionId, IrisModdedChunkGenerator generator, Engine engine) {
if (engine.isClosed()) {
return;
}
private void hotload(String dimensionId, Engine engine) {
long start = System.currentTimeMillis();
try {
if (engine.isClosed() || engine.isClosing()) {
throw new IllegalStateException("The Studio world is closing or closed.");
}
engine.hotloadSilently();
ModdedIrisLog.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start);
} catch (Throwable e) {
@@ -0,0 +1,113 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandText;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.util.localization.LanguageAudience;
import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
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.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadLanguageTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private final UUID playerId = UUID.randomUUID();
private File dataFolder;
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Before
public void setUp() throws Exception {
dataFolder = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
Files.writeString(dataFolder.toPath().resolve("languages/de_DE.toml"),
"\"iris.modded.moddedstudiocommands.you_do_not_have_open_studio_use_iris_studio_open\" = \"Kein Studio geöffnet.\"\n");
IrisLanguage.start();
IrisLanguage.selections().selectPlayer(playerId, "de_DE").get(5L, TimeUnit.SECONDS);
ModdedStudioCommands.clear();
}
@After
public void tearDown() {
ModdedStudioCommands.clear();
IrisLanguage.shutdown();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
}
@Test
public void delayedHotloadFeedbackKeepsThePlayersLanguage() throws Exception {
assertEquals(List.of("Kein Studio geöffnet."), runCommand("studio hotload", true));
}
@Test
public void delayedConsoleRefreshUsesTheServerLanguage() throws Exception {
assertEquals(List.of("You do not have an open studio. Use /iris studio open <pack> first."),
runCommand("studio refresh", false));
}
private List<String> runCommand(String command, boolean playerCommand) throws Exception {
NativeModdedServer server = mock(NativeModdedServer.class);
List<Runnable> tasks = new ArrayList<>();
doAnswer(invocation -> {
tasks.add(invocation.getArgument(0));
return null;
}).when(server).execute(any(Runnable.class));
NativeCommandSource source = mock(NativeCommandSource.class);
when(source.server()).thenReturn(server);
when(source.isGameMaster()).thenReturn(true);
if (playerCommand) {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(playerId);
when(source.player()).thenReturn(player);
when(source.playerId()).thenReturn(playerId);
}
List<String> messages = new ArrayList<>();
doAnswer(invocation -> {
NativeCommandText text = invocation.getArgument(0);
messages.add(text.getString());
return null;
}).when(source).sendFailure(any());
CommandDispatcher<NativeCommandSource> dispatcher = new CommandDispatcher<>();
dispatcher.register(ModdedStudioCommands.tree("studio"));
assertEquals(1, dispatcher.execute(command, source));
assertTrue(messages.isEmpty());
assertNull(LanguageAudience.current());
UUID ambientAudience = playerCommand ? UUID.randomUUID() : playerId;
try (LanguageAudience.Scope audience = LanguageAudience.open(ambientAudience)) {
while (!tasks.isEmpty()) {
tasks.removeFirst().run();
assertEquals(ambientAudience, LanguageAudience.current());
}
}
return messages;
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList;
@@ -8,19 +9,130 @@ import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadServiceTest {
@Test
public void manualHotloadRunsWithoutAFileChangeOnTheWorker() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
Thread caller = Thread.currentThread();
doAnswer(invocation -> {
assertNotSame(caller, Thread.currentThread());
return null;
}).when(engine).hotloadSilently();
service.onEnable();
try {
assertTrue(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
verify(engine).hotloadSilently();
} finally {
service.onDisable();
}
}
@Test
public void overlappingManualHotloadIsRejected() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
doAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return null;
}).when(engine).hotloadSilently();
service.onEnable();
try {
CompletableFuture<Boolean> first = service.requestHotload("irisworldgen:studio_test", engine);
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertFalse(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
release.countDown();
assertTrue(first.get(5L, TimeUnit.SECONDS));
verify(engine).hotloadSilently();
} finally {
release.countDown();
service.onDisable();
}
}
@Test
public void closingStudioIsNotHotloadedAfterWaitingInTheQueue() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
ExecutorService executor = Executors.newSingleThreadExecutor();
CountDownLatch release = new CountDownLatch(1);
executor.submit(() -> {
try {
assertTrue(release.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
});
setExecutor(service, executor);
try {
CompletableFuture<Boolean> requested = service.requestHotload("irisworldgen:studio_test", engine);
when(engine.isClosing()).thenReturn(true);
release.countDown();
assertThrows(ExecutionException.class, () -> requested.get(5L, TimeUnit.SECONDS));
verify(engine, never()).hotloadSilently();
} finally {
release.countDown();
service.onDisable();
}
}
@Test
public void manualHotloadRejectsProductionWorlds() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
service.onEnable();
try {
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:production", engine)
.get(5L, TimeUnit.SECONDS));
when(engine.isStudio()).thenReturn(false);
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:studio_test", engine)
.get(5L, TimeUnit.SECONDS));
verify(engine, never()).hotloadSilently();
} finally {
service.onDisable();
}
}
@Test
public void failedManualHotloadReleasesTheWatchForRetry() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
doThrow(new IllegalStateException("Invalid pack")).doNothing().when(engine).hotloadSilently();
service.onEnable();
try {
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:studio_test", engine)
.get(5L, TimeUnit.SECONDS));
assertTrue(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
} finally {
service.onDisable();
}
}
@Test
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
@@ -102,4 +214,11 @@ public class ModdedStudioHotloadServiceTest {
field.set(service, executor);
}
private static Engine studioEngine() {
Engine engine = mock(Engine.class);
when(engine.isStudio()).thenReturn(true);
when(engine.getDimension()).thenReturn(new IrisDimension());
return engine;
}
}