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

@@ -104,6 +104,7 @@ import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.iris.localization.BukkitCommandMessagesExtended;
@Director(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", descriptionKey = "iris.director.commandstudio.director.studio_commands")
public class CommandStudio implements DirectorExecutor {
@@ -203,6 +204,22 @@ public class CommandStudio implements DirectorExecutor {
}));
}
@Director(description = "Hotload the active Studio pack", descriptionKey = "iris.director.commandstudio.director.hotload_active_studio_pack", aliases = {"refresh"}, origin = DirectorOrigin.BOTH)
public void hotload() {
VolmitSender commandSender = sender();
IrisProject project = Iris.service(StudioSVC.class).getActiveProject();
PlatformChunkGenerator generator = project == null ? null : project.getActiveProvider();
if (generator == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_NO_OPEN_STUDIO_PROJECTS));
return;
}
UUID audience = LanguageAudience.current();
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADING));
generator.hotloadAsync().whenComplete((ignored, failure) ->
LanguageAudience.run(audience, () -> sendHotloadResult(commandSender, failure)));
}
@Director(description = "Toggle your Studio debug scoreboard", descriptionKey = "iris.director.commandstudio.director.toggle_your_studio_debug_scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER)
public void scoreboard() {
Player target = player();
@@ -642,6 +659,16 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DONE, MessageArgument.untrusted("value", report.getPath())));
}
private void sendHotloadResult(VolmitSender commandSender, Throwable failure) {
if (failure != null) {
Iris.reportError(failure);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOAD_FAILED,
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
return;
}
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADED));
}
private PlatformChunkGenerator resolveProfileGenerator(IrisDimension dimension) {
StudioSVC studioService = Iris.service(StudioSVC.class);
if (studioService != null && studioService.isProjectOpen()) {
@@ -0,0 +1,72 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
async function hotload(context, fixture, active, command) {
const before = await readJson(fixture.manifestPath(active.key))
const response = await context.command(command, /^Studio hotloaded\.$|^Studio hotload failed:/, 90000)
context.expect(response === 'Studio hotloaded.', 'The manual Studio command did not complete successfully', { command, response })
const after = await readJson(fixture.manifestPath(active.key))
context.expect(after.pendingActivationId === null, 'The manual hotload completed with a pending activation')
context.report.iris.manualHotloads.push({ command, response,
before: before.activeActivationId, after: after.activeActivationId })
return after
}
export default {
name: 'iris-studio-manual-hotload',
description: 'Refresh an open Studio through hotload and its refresh alias, verify terrain, and reject commands without a Studio.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
const processId = (await context.observe()).processId
context.report.iris.manualHotloads = []
let active
try {
await context.step('Reject hotload without an open Studio', async () => {
await context.command('/iris studio hotload', /^No open studio projects\.$/, 10000)
})
await context.step('Open Studio and hotload an unchanged pack', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
await hotload(context, fixture, active, '/iris studio hotload')
await sampleTerrain(context, fixture, 4096, fixture.state.material)
})
await context.step('Refresh an edited pack and inspect newly generated terrain', async () => {
const before = await readJson(fixture.manifestPath(active.key))
const biome = await readJson(biomeFile)
biome.name = 'Manual hotload terrain'
biome.layers[0].palette[0].block = 'minecraft:gold_block'
await writeJson(biomeFile, biome)
await hotload(context, fixture, active, '/iris studio refresh')
const after = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.activeActivationId !== before.activeActivationId && manifest.pendingActivationId === null ? manifest : false
}, 'The edited Studio pack did not become active', 90000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material: 'gold_block' })
await sampleTerrain(context, fixture, 16384, 'gold_block')
await sampleTerrain(context, fixture, 0, fixture.state.material)
})
await context.step('Close Studio and reject another refresh', async () => {
await closeStudio(context, fixture, active)
active = undefined
await context.command('/iris studio refresh', /^No open studio projects\.$/, 10000)
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
context.expect((await context.observe()).processId === processId, 'The server restarted during manual Studio hotload')
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Studio hotload failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Manual Studio hotload emitted a generation, snapshot, storage, or watchdog failure')
} finally {
try {
if (active) await closeStudio(context, fixture, active)
} finally {
await writeFile(biomeFile, original)
}
}
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.localization.LanguageAudience;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.lang.reflect.Method;
import java.io.File;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandStudioHotloadTest {
@Test
public void hotloadAndRefreshReportWhenNoStudioIsOpen() throws Exception {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
Method hotload = CommandStudio.class.getDeclaredMethod("hotload");
assertTrue(List.of(hotload.getAnnotation(Director.class).aliases()).contains("refresh"));
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
hotload.invoke(new TestCommand(sender));
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains("No open studio"));
}
}
@Test
public void completionFeedbackWaitsForTheHotload() {
assertCompletionFeedback(null, "Studio hotloaded.");
}
@Test
public void failedHotloadReportsFailureAndLogsTheCause() {
assertCompletionFeedback(new IllegalStateException("Invalid pack"), "Studio hotload failed: Invalid pack");
}
private void assertCompletionFeedback(Throwable failure, String expected) {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
IrisProject project = new IrisProject(new File("tutorial"));
project.setActiveProvider(generator);
CompletableFuture<Void> pending = new CompletableFuture<>();
UUID audience = UUID.randomUUID();
when(studio.getActiveProject()).thenReturn(project);
when(generator.hotloadAsync()).thenReturn(pending);
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
LanguageAudience.run(audience, () -> new TestCommand(sender).hotload());
assertNull(LanguageAudience.current());
verify(generator).hotloadAsync();
assertEquals(1, sender.messages.size());
assertTrue(sender.messages.getFirst().contains("Hotloading Studio"));
if (failure == null) {
pending.complete(null);
} else {
pending.completeExceptionally(failure);
iris.verify(() -> Iris.reportError(failure));
}
assertEquals(2, sender.messages.size());
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains(expected));
assertEquals(List.of(audience, audience), sender.audiences);
assertNull(LanguageAudience.current());
}
}
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 final List<UUID> audiences = new ArrayList<>();
private RecordingSender() {
super(null);
}
@Override
public void sendMessage(String message) {
messages.add(message);
audiences.add(LanguageAudience.current());
}
}
}