This commit is contained in:
Brian Neumann-Fopiano
2026-08-25 15:49:54 -04:00
parent 07cc76614f
commit bdaa84975a
14 changed files with 200 additions and 29 deletions
@@ -60,6 +60,7 @@ import java.nio.file.Files;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@@ -134,6 +135,12 @@ public class CommandIris implements DirectorExecutor {
return;
}
Optional<String> startupDenial = ServerConfigurator.worldCreationDenialReason(false);
if (startupDenial.isPresent()) {
sender().sendMessage(C.YELLOW + startupDenial.get());
return;
}
String resolvedType = type.equalsIgnoreCase("default")
? IrisSettings.get().getGenerator().getDefaultWorldType()
: type;
@@ -120,9 +120,11 @@ public class CommandStudio implements DirectorExecutor {
@Param(description = "The dimension pack to open a studio for", descriptionKey = "iris.director.commandstudio.param.dimension_pack_open_studio", aliases = "dim", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(defaultValue = "1337", description = "The seed to generate the studio with", descriptionKey = "iris.director.commandstudio.param.seed_generate_studio_with", aliases = "s")
long seed) {
long seed,
@Param(defaultValue = "false", description = "Attempt to open Studio before a required registry restart", aliases = "f")
boolean force) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_STUDIO_PACK_SEED, MessageArgument.untrusted("value", dimension.getName()), MessageArgument.untrusted("seed", seed)));
Iris.service(StudioSVC.class).open(sender(), seed, dimension.getLoadKey());
Iris.service(StudioSVC.class).open(sender(), seed, dimension.getLoadKey(), force);
}
@Director(description = "Import vanilla trees, mushrooms & objects (and structures/jigsaw) from the server into a pack's objects/vanilla folder", descriptionKey = "iris.director.commandstudio.director.import_vanilla_trees_mushrooms_objects_structures_jigsaw_from_server_into_pack_s", aliases = {"importv", "iv"}, origin = DirectorOrigin.BOTH)
@@ -8,9 +8,11 @@ import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandStudioCreationContractTest {
@Test
@@ -24,4 +26,18 @@ public class CommandStudioCreationContractTest {
assertEquals("null", template.defaultValue());
assertEquals(NullableDimensionHandler.class, template.customHandler());
}
@Test
public void openExposesAnOptInForceFlag() throws NoSuchMethodException {
Method command = CommandStudio.class.getDeclaredMethod(
"open",
IrisDimension.class,
long.class,
boolean.class
);
Param force = command.getParameters()[2].getAnnotation(Param.class);
assertEquals("false", force.defaultValue());
assertTrue(List.of(force.aliases()).contains("f"));
}
}
@@ -85,7 +85,21 @@ public final class IrisStartupValidation {
}
public static Optional<String> denialReason() {
return denialReason(snapshot);
}
public static Optional<String> studioDenialReason(boolean force) {
Snapshot current = snapshot;
if (force
&& current.enforced()
&& current.datapacks() == ValidationState.RESTART_REQUIRED
&& current.packs() == ValidationState.READY) {
return Optional.empty();
}
return denialReason(current);
}
private static Optional<String> denialReason(Snapshot current) {
if (!current.enforced() || isReady(current)) {
return Optional.empty();
}
@@ -77,6 +77,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@@ -171,12 +172,34 @@ public class ServerConfigurator {
}
public static void requireDatapackRestart() {
requireWorldCreationRestart();
IrisStartupValidation.requireRestart(
"Iris datapack changes require a restart before player admission or world creation.");
}
public static void requireWorldCreationRestart() {
synchronized (DATAPACK_INSTALL_LOCK) {
invalidateLoadedDatapackRuntime();
loadedDatapackRestartRequired = true;
}
IrisStartupValidation.requireRestart(
"Iris datapack changes require a restart before player admission or world creation.");
}
public static Optional<String> worldCreationDenialReason(boolean forceStudio) {
Optional<String> startupDenial = IrisStartupValidation.studioDenialReason(forceStudio);
if (startupDenial.isPresent()) {
return startupDenial;
}
if (!forceStudio && loadedDatapackRestartRequired) {
return Optional.of("Iris installed or updated a dimension pack that requires a server restart before world creation or Studio open.");
}
return Optional.empty();
}
public static void requireWorldCreationReady(boolean forceStudio) {
Optional<String> denial = worldCreationDenialReason(forceStudio);
if (denial.isPresent()) {
throw new IllegalStateException("Iris world creation is locked: " + denial.get());
}
}
public static void restoreLoadedDatapackRuntimeIfUnchanged(
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
public enum WorldLifecycleCaller {
STUDIO,
FORCED_STUDIO,
CREATE,
BENCHMARK
}
@@ -1,6 +1,5 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -77,7 +76,11 @@ public final class WorldLifecycleService {
public CompletableFuture<World> create(WorldLifecycleRequest request) {
WorldLifecycleBackend backend;
try {
IrisStartupValidation.requireWorldCreationReady();
if (request.callerKind() == WorldLifecycleCaller.FORCED_STUDIO) {
ServerConfigurator.requireWorldCreationReady(true);
} else {
ServerConfigurator.requireWorldCreationReady(false);
}
backend = selectCreateBackend(request);
} catch (Throwable e) {
IrisLogging.reportError("WorldLifecycle create backend selection failed for world=\"" + request.worldName()
@@ -119,7 +119,7 @@ public final class StudioOpenCoordinator {
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio entry point could not be resolved."));
}
CompletableFuture<Boolean> teleport = project.getActiveOpenKind() == StudioOpenKind.STANDARD
CompletableFuture<Boolean> teleport = project.getActiveOpenKind().teleportThroughStandardEntry()
? WorldRuntimeControlService.get().teleportInMode(player, entry, GameMode.SPECTATOR)
: WorldRuntimeControlService.get().teleport(player, entry);
if (teleport == null) {
@@ -914,6 +914,10 @@ public final class StudioOpenCoordinator {
true,
true,
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY),
FORCED_STANDARD(
true,
true,
IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME),
OBJECT(
false,
true,
@@ -420,6 +420,7 @@ public class StudioSVC implements IrisService {
reporter.fail(null);
return;
}
retainPackRestartRequirement(result);
reporter.succeed(result);
}, "Failed to download built-in Iris pack '" + key + "'.");
}
@@ -447,6 +448,7 @@ public class StudioSVC implements IrisService {
reporter.fail(null);
return;
}
retainPackRestartRequirement(result);
reporter.succeed(result);
}, "Failed to download Iris pack.");
}
@@ -491,8 +493,15 @@ public class StudioSVC implements IrisService {
}
public void open(VolmitSender sender, long seed, String dimm) {
open(sender, seed, dimm, false);
}
public void open(VolmitSender sender, long seed, String dimm, boolean force) {
try {
open(sender, seed, dimm, (w) -> {
StudioOpenCoordinator.StudioOpenKind openKind = force
? StudioOpenCoordinator.StudioOpenKind.FORCED_STANDARD
: StudioOpenCoordinator.StudioOpenKind.STANDARD;
open(sender, seed, dimm, openKind, (w) -> {
});
} catch (Exception e) {
IrisLogging.reportError("Failed to open studio world \"" + dimm + "\".", e);
@@ -501,8 +510,13 @@ public class StudioSVC implements IrisService {
}
private static BrokenPackException reportPackAdmissionFailure(
VolmitSender sender, String dimm) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
VolmitSender sender,
String dimm,
StudioOpenCoordinator.StudioOpenKind openKind
) {
boolean force = openKind == StudioOpenCoordinator.StudioOpenKind.FORCED_STANDARD;
Optional<String> unforcedStartupDenial = ServerConfigurator.worldCreationDenialReason(false);
Optional<String> startupDenial = ServerConfigurator.worldCreationDenialReason(force);
IrisDimension dimension = IrisToolbelt.getDimension(dimm);
String packName = dimension == null || dimension.getLoader() == null
? dimm
@@ -511,6 +525,9 @@ public class StudioSVC implements IrisService {
BrokenPackException failure = resolvePackAdmissionFailure(
packName, startupDenial, validation);
if (failure == null) {
if (force && unforcedStartupDenial.isPresent()) {
sender.sendMessage("Force-opening Studio with the currently loaded registry state. The open may fail; restart remains required.");
}
return null;
}
if (startupDenial.isPresent()) {
@@ -525,6 +542,12 @@ public class StudioSVC implements IrisService {
return failure;
}
static void retainPackRestartRequirement(PackDownloader.PackInstallResult result) {
if (result != null && result.restartRequired()) {
ServerConfigurator.requireWorldCreationRestart();
}
}
static BrokenPackException resolvePackAdmissionFailure(
String packName,
Optional<String> startupDenial,
@@ -554,7 +577,7 @@ public class StudioSVC implements IrisService {
Consumer<World> onDone
) throws IrisException {
long requestedAtNanos = System.nanoTime();
if (reportPackAdmissionFailure(sender, dimm) != null) {
if (reportPackAdmissionFailure(sender, dimm, openKind) != null) {
return;
}
StudioOpenCoordinator.StudioOpenKind requiredOpenKind = Objects.requireNonNull(
@@ -587,7 +610,7 @@ public class StudioSVC implements IrisService {
Consumer<World> onDone
) {
long requestedAtNanos = System.nanoTime();
BrokenPackException failure = reportPackAdmissionFailure(sender, dimension);
BrokenPackException failure = reportPackAdmissionFailure(sender, dimension, openKind);
if (failure != null) {
return CompletableFuture.failedFuture(failure);
}
@@ -1027,9 +1050,7 @@ public class StudioSVC implements IrisService {
closeLease(lease);
}
if (outcome == CreationOutcome.RESTART) {
ServerConfigurator.restart();
} else if (outcome == CreationOutcome.OPEN) {
if (outcome == CreationOutcome.OPEN) {
String completedProjectName = projectName;
LifecycleOperationCoordinator.get().whenIdle(() -> open(sender, completedProjectName));
}
@@ -26,7 +26,6 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
@@ -182,7 +181,7 @@ public class IrisCreator {
throw new IrisException("Dimension cannot be found for id " + dimension());
}
reportCreationProgress(creationReporter, 0.06D, "validate_pack");
IrisStartupValidation.requireWorldCreationReady();
ServerConfigurator.requireWorldCreationReady(datapackPreparation.forcesLoadedRuntime());
PackValidationRegistry.requireLoadable(
resolvedDimension.getLoader().getDataFolder().getName());
worldLease = coordinator.acquire(
@@ -253,10 +252,11 @@ public class IrisCreator {
if (!datapackResult.succeeded()) {
throw new IrisException("Failed to compile datapacks for dimension \"" + dimension() + "\".");
}
if (datapackResult.restartRequired() || !ServerConfigurator.verifyDataPackInstalled(resolvedDimension)) {
ServerConfigurator.restart();
if (!datapackPreparation.forcesLoadedRuntime()
&& (datapackResult.restartRequired()
|| !ServerConfigurator.verifyDataPackInstalled(resolvedDimension))) {
throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded yet. "
+ "Iris queued a restart; run the command again after the server returns.");
+ "Restart the server, then run the command again.");
}
IrisDimension installedDimension = resolvedDimension;
@@ -301,7 +301,13 @@ public class IrisCreator {
reportCreationProgress(creationReporter, 0.44D, "create_world");
long nmsStartNanos = System.nanoTime();
try {
WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE;
WorldLifecycleCaller callerKind = benchmark
? WorldLifecycleCaller.BENCHMARK
: studio() && datapackPreparation.forcesLoadedRuntime()
? WorldLifecycleCaller.FORCED_STUDIO
: studio()
? WorldLifecycleCaller.STUDIO
: WorldLifecycleCaller.CREATE;
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind);
world = J.sfut(() -> INMS.get().createWorldAsync(wc, request))
.thenCompose(Function.identity())
@@ -319,9 +325,8 @@ public class IrisCreator {
throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e);
}
if (containsMissingDimensionTypes(e)) {
ServerConfigurator.restart();
throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded on this server yet. "
+ "Iris queued a restart; run the command again after the server returns.", e);
+ "Restart the server, then run the command again.", e);
}
throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e);
} finally {
@@ -856,17 +861,24 @@ public class IrisCreator {
}
public enum DatapackPreparation {
INSTALL_IF_CHANGED(false),
REUSE_LOADED_RUNTIME_IF_READY(true);
INSTALL_IF_CHANGED(false, false),
REUSE_LOADED_RUNTIME_IF_READY(true, false),
FORCE_REUSE_LOADED_RUNTIME(true, true);
private final boolean reusesLoadedRuntime;
private final boolean forcesLoadedRuntime;
DatapackPreparation(boolean reusesLoadedRuntime) {
DatapackPreparation(boolean reusesLoadedRuntime, boolean forcesLoadedRuntime) {
this.reusesLoadedRuntime = reusesLoadedRuntime;
this.forcesLoadedRuntime = forcesLoadedRuntime;
}
boolean requiresInstall(boolean runtimeReady) {
return !reusesLoadedRuntime || !runtimeReady;
return !forcesLoadedRuntime && (!reusesLoadedRuntime || !runtimeReady);
}
public boolean forcesLoadedRuntime() {
return forcesLoadedRuntime;
}
}
}
@@ -107,6 +107,20 @@ public class IrisStartupValidationTest {
);
}
@Test
public void forceAllowsOnlyStudioAtACompletedRestartBoundary() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
IrisStartupValidation.requireRestart("restart boundary");
assertTrue(IrisStartupValidation.studioDenialReason(true).isEmpty());
assertThrows(IllegalStateException.class, IrisStartupValidation::requireWorldCreationReady);
IrisStartupValidation.markPacksInvalid(List.of("pack validation failed"));
assertFalse(IrisStartupValidation.studioDenialReason(true).isEmpty());
}
@Test
public void packValidationInfrastructureFailureDeniesCreation() {
IrisStartupValidation.begin();
@@ -27,14 +27,29 @@ public class StudioOpenCoordinatorOpenKindTest {
}
@Test
public void allStudioKindsReuseOnlyAnUnchangedLoadedRuntime() {
public void ordinaryStudioKindsReuseOnlyAnUnchangedLoadedRuntime() {
for (StudioOpenCoordinator.StudioOpenKind kind : StudioOpenCoordinator.StudioOpenKind.values()) {
if (kind == StudioOpenCoordinator.StudioOpenKind.FORCED_STANDARD) {
continue;
}
assertEquals(
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY,
kind.datapackPreparation());
}
}
@Test
public void forcedStandardStudioAttemptsTheLoadedRuntime() {
StudioOpenCoordinator.StudioOpenKind kind = StudioOpenCoordinator.StudioOpenKind.FORCED_STANDARD;
assertTrue(kind.openWorkspace());
assertTrue(kind.teleportThroughStandardEntry());
assertTrue(kind.prepareGeneratorState());
assertEquals(
IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME,
kind.datapackPreparation());
}
@Test
public void objectStudioOwnsWorkspaceWithoutUsingTheStandardEntry() {
StudioOpenCoordinator.StudioOpenKind kind = StudioOpenCoordinator.StudioOpenKind.OBJECT;
@@ -115,7 +130,7 @@ public class StudioOpenCoordinatorOpenKindTest {
assertTrue(coordinatorMethod.contains(
"WorldRuntimeControlService.get().resolveEntryAnchor(world, provider)"));
assertTrue(coordinatorMethod.contains(
"project.getActiveOpenKind() == StudioOpenKind.STANDARD"));
"project.getActiveOpenKind().teleportThroughStandardEntry()"));
assertFalse(coordinatorMethod.contains("requestChunkAsync("));
assertFalse(coordinatorMethod.contains("getHighestBlockYAt("));
assertTrue(nativeDelegation >= 0);
@@ -1,16 +1,27 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationResult;
import org.junit.After;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class StudioSVCPackAdmissionTest {
@After
public void disableStartupValidation() {
IrisStartupValidation.disable();
}
@Test
public void loadablePackIsAdmitted() {
PackValidationResult validation = new PackValidationResult(
@@ -57,4 +68,25 @@ public class StudioSVCPackAdmissionTest {
assertEquals(List.of(denial), failure.getReasons());
}
@Test
public void completedPackDownloadRetainsTheCreationGateWithoutDenyingLogin() throws Exception {
Field restartRequired = ServerConfigurator.class.getDeclaredField("loadedDatapackRestartRequired");
restartRequired.setAccessible(true);
boolean previous = restartRequired.getBoolean(null);
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
try {
StudioSVC.retainPackRestartRequirement(
new PackDownloader.PackInstallResult("overworld", true, true));
assertTrue(ServerConfigurator.worldCreationDenialReason(false).isPresent());
assertTrue(ServerConfigurator.worldCreationDenialReason(true).isEmpty());
assertTrue(IrisStartupValidation.denialReason().isEmpty());
} finally {
restartRequired.setBoolean(null, previous);
}
}
}
@@ -17,4 +17,11 @@ public class IrisCreatorDatapackPreparationTest {
assertTrue(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(false));
assertFalse(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(true));
}
@Test
public void forcedStudioCreationNeverInstallsDatapacks() {
assertFalse(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.requiresInstall(false));
assertFalse(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.requiresInstall(true));
assertTrue(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.forcesLoadedRuntime());
}
}