mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-28 23:10:32 +00:00
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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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"),
|
||||
|
||||
+73
@@ -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();
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+34
-7
@@ -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) {
|
||||
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user