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

@@ -10,6 +10,7 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.volmlib.nativelib.terrain.NativeBiomeRegistry;
@@ -17,6 +18,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourcePolicy;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.Field;
@@ -35,6 +37,11 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitBiomeLocateTest {
@BeforeClass
public static void initializeBukkit() {
BukkitTestServer.initializeRegistries();
}
@Test
public void locateResolvesVisibleBiomesOnlyInColumnsThatCanHoldTheTarget() throws Exception {
IrisBiome isles = new IrisBiome().setCustomDerivitives(new KList<>(new IrisBiomeCustom().setId("mirage")));
@@ -0,0 +1,106 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.BukkitChunkGenerator.GenerationStageGate;
import art.arcane.iris.world.task.J;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
public class BukkitChunkGeneratorManualHotloadTest {
@Test
public void manualHotloadCompletesAfterExclusiveEngineRefresh() throws Exception {
Fixture fixture = new Fixture();
try (MockedStatic<J> scheduling = fixture.captureScheduling()) {
CompletableFuture<Void> result = fixture.generator.hotloadAsync();
assertFalse(result.isDone());
verify(fixture.engine, never()).hotload();
fixture.queued.get().run();
result.join();
verify(fixture.engine).hotload();
}
}
@Test
public void manualHotloadRejectsUnavailableStudios() throws Exception {
for (String state : new String[]{"initialEntryPending", "studioUpdatesPaused", "closing", "jigsawStudioActive"}) {
Fixture fixture = new Fixture();
setField(fixture.generator, state, true);
assertTrue(state, fixture.generator.hotloadAsync().isCompletedExceptionally());
verify(fixture.engine, never()).hotload();
}
Fixture fixture = new Fixture();
setField(fixture.generator, "studio", false);
assertTrue(fixture.generator.hotloadAsync().isCompletedExceptionally());
verify(fixture.engine, never()).hotload();
}
@Test
public void queuedHotloadFailsWhenStudioClosesBeforeExecution() throws Exception {
Fixture fixture = new Fixture();
try (MockedStatic<J> scheduling = fixture.captureScheduling()) {
CompletableFuture<Void> result = fixture.generator.hotloadAsync();
setField(fixture.generator, "closing", true);
fixture.queued.get().run();
assertTrue(result.isCompletedExceptionally());
verify(fixture.engine, never()).hotload();
}
}
@Test
public void failedHotloadReleasesControlForTheNextRequest() throws Exception {
Fixture fixture = new Fixture();
doThrow(new IllegalStateException("Invalid pack")).doNothing().when(fixture.engine).hotload();
try (MockedStatic<J> scheduling = fixture.captureScheduling()) {
CompletableFuture<Void> failed = fixture.generator.hotloadAsync();
fixture.queued.get().run();
assertTrue(failed.isCompletedExceptionally());
CompletableFuture<Void> retried = fixture.generator.hotloadAsync();
fixture.queued.get().run();
retried.join();
}
}
private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
field.set(generator, value);
}
private static final class Fixture {
private final Engine engine = mock(Engine.class);
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final AtomicReference<Runnable> queued = new AtomicReference<>();
private Fixture() throws Exception {
generator.setEngine(engine);
setField(generator, "studio", true);
setField(generator, "loadLock", new GenerationStageGate(1, () -> false));
}
private MockedStatic<J> captureScheduling() {
MockedStatic<J> scheduling = mockStatic(J.class);
scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
queued.set(invocation.getArgument(0));
return null;
});
return scheduling;
}
}
}
@@ -14,14 +14,7 @@ import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.block.TileData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.world.storage.matter.PreObjectMatterTest;
import art.arcane.volmlib.util.collection.KMap;
import io.papermc.paper.InternalAPIBridge;
import io.papermc.paper.registry.RegistryAccess;
import net.kyori.adventure.key.Key;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.block.Biome;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
@@ -34,7 +27,6 @@ import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
@@ -48,7 +40,6 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -60,23 +51,8 @@ public class ObjectStudioGeometryTest {
@BeforeClass
public static void initializeBukkit() throws Exception {
PreObjectMatterTest.setUpBukkit();
RegistryAccess access = BukkitTestServer.registryAccess();
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> {
if (invocation.getMethod().getName().equals("constructLegacyCustomBiome")) {
return biome(Key.key("minecraft:custom"));
}
return RETURNS_DEFAULTS.answer(invocation);
});
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
registries.when(RegistryAccess::registryAccess).thenReturn(access);
internals.when(InternalAPIBridge::get).thenReturn(bridge);
ClassLoader loader = ObjectStudioGenerator.class.getClassLoader();
Class.forName(Registry.class.getName(), true, loader);
Class.forName(Biome.class.getName(), true, loader);
Class.forName(ObjectStudioGenerator.class.getName(), true, loader);
}
BukkitTestServer.initializeRegistries();
Class.forName(ObjectStudioGenerator.class.getName(), true, ObjectStudioGenerator.class.getClassLoader());
}
@@ -283,18 +259,6 @@ public class ObjectStudioGeometryTest {
}
}
private static Biome biome(Key key) {
NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value());
return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class<?>[]{Biome.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getKey" -> namespaced;
case "hashCode" -> namespaced.hashCode();
case "equals" -> proxy == arguments[0];
case "toString", "name" -> key.value();
default -> null;
});
}
private record Position(int x, int y, int z) {
}