This commit is contained in:
Brian Neumann-Fopiano
2026-08-05 01:16:30 -06:00
parent aaccbacf32
commit cd217c05f9
174 changed files with 28830 additions and 2645 deletions
+1
View File
@@ -58,6 +58,7 @@ tasks.named('jar', Jar).configure {
tasks.named('test').configure {
systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath)
systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath)
systemProperty('iris.commandIrisSource', file('src/main/java/art/arcane/iris/core/commands/CommandIris.java').absolutePath)
systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath)
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
@@ -36,6 +36,7 @@ import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.core.link.IrisPapiInstaller;
@@ -571,7 +572,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks());
IrisServices.register(EngineWorldManagerProvider.class,
(EngineWorldManagerProvider) IrisWorldManager::new);
IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) pendingWorldDeletes::queueWorldDeletionOnStartup);
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
settingsHotloadWatch = watch;
configHotloadEngine = new ConfigHotloadEngine(
@@ -19,77 +19,381 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.collection.KList;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.generator.ChunkGenerator;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* Loads Iris worlds that are staged in bukkit.yml but not yet present on the server.
*/
public final class BukkitWorldReconciler {
private final Iris plugin;
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private final Backend backend;
private final LifecycleOperationCoordinator coordinator;
public BukkitWorldReconciler(Iris plugin) {
this.plugin = plugin;
this(new BukkitBackend(plugin), LifecycleOperationCoordinator.get());
}
public void checkForBukkitWorlds(Predicate<String> filter) {
BukkitWorldReconciler(Backend backend, LifecycleOperationCoordinator coordinator) {
this.backend = Objects.requireNonNull(backend, "backend");
this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
}
public CompletableFuture<LoadResult> loadWorld(
File configurationFile,
String worldName
) {
NamespacedKey worldKey;
try {
KList<String> deferredStartupWorlds = new KList<>();
IrisWorlds.readBukkitWorlds().forEach((s, generator) -> {
try {
NamespacedKey worldKey = IrisWorldStorage.keyFromName(s);
if (WorldIdentity.resolve(worldKey).isPresent() || !filter.test(s)) return;
Iris.info("Loading World: %s | Generator: %s", s, generator);
ChunkGenerator gen = plugin.getDefaultWorldGenerator(s, generator);
IrisDimension dim = IrisWorldGeneratorResolver.loadDimension(s, generator);
assert dim != null && gen != null;
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + s + "' using Iris:" + generator + "...");
WorldCreator c = WorldCreatorCompat.ofKey(worldKey)
.generator(gen)
.environment(BukkitEnvironment.from(dim.getEnvironment()));
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s);
if (stagedSeed != null) {
c.seed(stagedSeed);
}
INMS.get().createWorld(c);
Iris.info(C.LIGHT_PURPLE + "Loaded " + s + "!");
} catch (Throwable e) {
if (containsCreateWorldUnsupportedOperation(e)) {
if (J.isFolia()) {
if (!deferredStartupWorlds.contains(s)) {
deferredStartupWorlds.add(s);
}
return;
}
Iris.error("Failed to load world " + s + "!");
Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase.");
Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml.");
Iris.reportError("Failed to load staged startup world \"" + s + "\".", e);
return;
}
Iris.reportError("Failed to load startup world \"" + s + "\".", e);
}
});
if (!deferredStartupWorlds.isEmpty()) {
Iris.warn("Staged Iris worlds could not load on Folia: %s", String.join(", ", deferredStartupWorlds));
Iris.warn("Bukkit.createWorld is unsupported on this server and the Iris runtime world backend is unavailable (%s).", WorldLifecycleService.get().capabilities().paperLikeResolution());
}
} catch (Throwable e) {
Iris.reportError("Failed while loading startup Iris worlds.", e);
worldKey = IrisWorldStorage.managedKeyFromName(worldName);
} catch (Throwable failure) {
return CompletableFuture.completedFuture(LoadResult.validationFailure(worldName, failure));
}
LifecycleOperationCoordinator.Lease lease;
try {
lease = acquireWorldLoad(worldKey);
} catch (LifecycleOperationCoordinator.BusyException failure) {
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
}
DimensionResolution dimensionResolution;
Long configuredSeed;
try {
dimensionResolution = backend.resolveDimension(worldKey);
configuredSeed = dimensionResolution.succeeded()
? backend.configuredSeed(IrisWorldStorage.logicalName(worldKey))
: null;
} catch (Throwable failure) {
dimensionResolution = DimensionResolution.failed(failure);
configuredSeed = null;
}
if (!dimensionResolution.succeeded()) {
lease.close();
return CompletableFuture.completedFuture(LoadResult.dimensionFailure(
worldKey,
dimensionResolution.failure()));
}
return loadWithLease(
configurationFile,
worldKey,
dimensionResolution.dimension(),
configuredSeed,
lease);
}
public CompletableFuture<BatchResult> checkForBukkitWorlds(Predicate<String> filter) {
Predicate<String> requiredFilter = Objects.requireNonNull(filter, "filter");
Map<String, String> configuredWorlds;
try {
configuredWorlds = backend.configuredWorlds();
} catch (Throwable failure) {
Iris.reportError("Failed while reading staged Bukkit worlds.", failure);
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
}
CompletableFuture<List<LoadResult>> chain = CompletableFuture.completedFuture(new ArrayList<>());
for (Map.Entry<String, String> entry : configuredWorlds.entrySet()) {
String worldName = entry.getKey();
boolean selected;
try {
selected = requiredFilter.test(worldName);
} catch (Throwable failure) {
Iris.reportError("Failed while filtering staged Bukkit world \"" + worldName + "\".", failure);
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
}
if (!selected) {
continue;
}
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.keyFromName(worldName);
} catch (Throwable failure) {
chain = chain.thenApply(results -> {
results.add(LoadResult.validationFailure(worldName, failure));
return results;
});
continue;
}
String dimension = entry.getValue();
Long seed;
try {
seed = backend.configuredSeed(worldName);
} catch (Throwable failure) {
chain = chain.thenApply(results -> {
results.add(LoadResult.configurationFailure(worldKey, failure));
return results;
});
continue;
}
chain = chain.thenCompose(results -> loadConfiguredWorld(
ServerProperties.BUKKIT_YML,
worldKey,
dimension,
seed)
.thenApply(result -> {
results.add(result);
return results;
}));
}
return chain.thenApply(results -> {
BatchResult batchResult = new BatchResult(results, null);
reportBatch(batchResult);
return batchResult;
});
}
private CompletableFuture<LoadResult> loadConfiguredWorld(
File configurationFile,
NamespacedKey worldKey,
String dimension,
Long seed
) {
LifecycleOperationCoordinator.Lease lease;
try {
lease = acquireWorldLoad(worldKey);
} catch (LifecycleOperationCoordinator.BusyException failure) {
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
}
return loadWithLease(configurationFile, worldKey, dimension, seed, lease);
}
private LifecycleOperationCoordinator.Lease acquireWorldLoad(NamespacedKey worldKey) {
return coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
worldKey.toString());
}
private CompletableFuture<LoadResult> loadWithLease(
File configurationFile,
NamespacedKey worldKey,
String dimension,
Long seed,
LifecycleOperationCoordinator.Lease lease
) {
BukkitWorldConfiguration.Registration registration;
String worldName = IrisWorldStorage.logicalName(worldKey);
try {
registration = BukkitWorldConfiguration.register(
configurationFile,
worldName,
dimension,
seed);
} catch (Throwable failure) {
lease.close();
return CompletableFuture.completedFuture(LoadResult.configurationFailure(worldKey, failure));
}
CompletableFuture<ReconciliationResult> reconciliation;
try {
reconciliation = reconcile(worldKey, dimension, seed);
} catch (Throwable failure) {
reconciliation = CompletableFuture.completedFuture(ReconciliationResult.createFailure(worldKey, failure));
}
return reconciliation.handle((result, failure) -> {
ReconciliationResult settled = failure == null
? result
: ReconciliationResult.createFailure(worldKey, unwrap(failure));
if (settled == null) {
settled = ReconciliationResult.createFailure(
worldKey,
new IllegalStateException("World reconciliation completed without a result."));
}
if (settled.succeeded()
|| settled.status() == ReconciliationStatus.RESTART_REQUIRED
|| registration != BukkitWorldConfiguration.Registration.CREATED) {
return new LoadResult(settled, registration, false, true, null);
}
try {
boolean rolledBack = BukkitWorldConfiguration.removeIfMatching(
configurationFile,
worldName,
dimension,
seed);
return new LoadResult(settled, registration, true, rolledBack, null);
} catch (Throwable rollbackFailure) {
return new LoadResult(settled, registration, true, false, rollbackFailure);
}
})
.whenComplete((result, failure) -> lease.close());
}
private CompletableFuture<ReconciliationResult> reconcile(
NamespacedKey worldKey,
String dimension,
Long seed
) {
Optional<World> loaded = backend.loadedWorld(worldKey);
if (loaded.isPresent()) {
return CompletableFuture.completedFuture(verifyLoadedWorld(worldKey, loaded.get(), true));
}
CompletableFuture<World> created;
try {
created = Objects.requireNonNull(
backend.createWorld(worldKey, dimension, seed),
"World backend returned no creation future.");
} catch (Throwable failure) {
return CompletableFuture.completedFuture(classifyCreationFailure(worldKey, failure));
}
CompletableFuture<World> guardedCreation = guardCreateCompletion(
created,
worldKey,
TimeUnit.SECONDS.toMillis(WORLD_CREATE_TIMEOUT_SECONDS),
() -> ServerConfigurator.restart("World load timed out for \"" + worldKey + "\"."));
return guardedCreation.handle((createdWorld, failure) -> {
if (failure != null) {
return classifyCreationFailure(worldKey, unwrap(failure));
}
if (createdWorld == null) {
return ReconciliationResult.notLoaded(worldKey);
}
NamespacedKey createdKey;
try {
createdKey = WorldIdentity.key(createdWorld);
} catch (Throwable identityFailure) {
return ReconciliationResult.createFailure(worldKey, identityFailure);
}
if (!worldKey.equals(createdKey)) {
return ReconciliationResult.identityMismatch(worldKey, createdWorld, createdKey);
}
Optional<World> resolved = backend.loadedWorld(worldKey);
if (resolved.isEmpty()) {
return ReconciliationResult.notLoaded(worldKey);
}
return verifyLoadedWorld(worldKey, resolved.get(), false);
});
}
private ReconciliationResult verifyLoadedWorld(NamespacedKey worldKey, World loadedWorld, boolean alreadyLoaded) {
NamespacedKey loadedKey;
try {
loadedKey = WorldIdentity.key(loadedWorld);
} catch (Throwable identityFailure) {
return ReconciliationResult.createFailure(worldKey, identityFailure);
}
if (!worldKey.equals(loadedKey)) {
return ReconciliationResult.identityMismatch(worldKey, loadedWorld, loadedKey);
}
if (!backend.isIrisWorld(loadedWorld)) {
return ReconciliationResult.identityConflict(worldKey, loadedWorld);
}
return alreadyLoaded
? ReconciliationResult.alreadyLoaded(worldKey, loadedWorld)
: ReconciliationResult.loaded(worldKey, loadedWorld);
}
private static ReconciliationResult classifyCreationFailure(NamespacedKey worldKey, Throwable failure) {
Throwable cause = unwrap(failure);
if (cause instanceof TimeoutException || containsCreateWorldUnsupportedOperation(cause)) {
return ReconciliationResult.restartRequired(worldKey, cause);
}
return ReconciliationResult.createFailure(worldKey, cause);
}
static CompletableFuture<World> guardCreateCompletion(
CompletableFuture<World> source,
NamespacedKey worldKey,
long timeoutMillis,
Runnable timeoutAction
) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(timeoutAction, "timeoutAction");
if (timeoutMillis < 1L) {
throw new IllegalArgumentException("timeoutMillis must be positive");
}
CompletableFuture<World> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((world, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(world);
} else {
guarded.completeExceptionally(unwrap(throwable));
}
});
CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
TimeoutException timeout = new TimeoutException(
"World load did not settle within " + timeoutMillis + " milliseconds for \""
+ worldKey + "\".");
try {
timeoutAction.run();
} catch (Throwable failure) {
timeout.addSuppressed(failure);
}
guarded.completeExceptionally(timeout);
});
return guarded;
}
private static void reportBatch(BatchResult batchResult) {
for (LoadResult result : batchResult.results()) {
if (result.succeeded()) {
Iris.info(C.LIGHT_PURPLE + result.message());
continue;
}
if (result.status() == ReconciliationStatus.BUSY) {
Iris.warn(result.message());
continue;
}
Iris.error(result.message());
Throwable failure = result.failure();
if (failure != null) {
Iris.reportError("Failed to reconcile staged world \"" + result.worldKey() + "\".", failure);
}
}
}
private static Throwable unwrap(Throwable failure) {
Throwable current = failure;
while (current instanceof CompletionException && current.getCause() != null) {
current = current.getCause();
}
return current;
}
private static boolean containsCreateWorldUnsupportedOperation(Throwable throwable) {
@@ -107,4 +411,321 @@ public final class BukkitWorldReconciler {
}
return false;
}
interface Backend {
Map<String, String> configuredWorlds();
Long configuredSeed(String worldName);
Optional<World> loadedWorld(NamespacedKey worldKey);
CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed);
boolean isIrisWorld(World world);
DimensionResolution resolveDimension(NamespacedKey worldKey);
}
public enum ReconciliationStatus {
LOADED,
ALREADY_LOADED,
BUSY,
INVALID_WORLD,
DIMENSION_UNRESOLVED,
CONFIGURATION_FAILED,
CREATE_FAILED,
RESTART_REQUIRED,
IDENTITY_MISMATCH,
IDENTITY_CONFLICT,
NOT_LOADED
}
public record ReconciliationResult(
ReconciliationStatus status,
NamespacedKey worldKey,
World world,
Throwable failure,
String message
) {
public ReconciliationResult {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(message, "message");
}
public boolean succeeded() {
return status == ReconciliationStatus.LOADED || status == ReconciliationStatus.ALREADY_LOADED;
}
private static ReconciliationResult loaded(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.LOADED,
worldKey,
world,
null,
"Loaded Iris world \"" + worldKey + "\".");
}
private static ReconciliationResult alreadyLoaded(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.ALREADY_LOADED,
worldKey,
world,
null,
"Iris world \"" + worldKey + "\" is already loaded.");
}
private static ReconciliationResult createFailure(NamespacedKey worldKey, Throwable failure) {
return new ReconciliationResult(
ReconciliationStatus.CREATE_FAILED,
worldKey,
null,
failure,
"Failed to create Iris world \"" + worldKey + "\": " + failure.getMessage());
}
private static ReconciliationResult restartRequired(NamespacedKey worldKey, Throwable failure) {
return new ReconciliationResult(
ReconciliationStatus.RESTART_REQUIRED,
worldKey,
null,
failure,
"The server cannot load exact Iris world \"" + worldKey + "\" at this runtime phase.");
}
private static ReconciliationResult identityMismatch(
NamespacedKey worldKey,
World world,
NamespacedKey actualKey
) {
return new ReconciliationResult(
ReconciliationStatus.IDENTITY_MISMATCH,
worldKey,
world,
null,
"World creation returned \"" + actualKey + "\" instead of \"" + worldKey + "\".");
}
private static ReconciliationResult identityConflict(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.IDENTITY_CONFLICT,
worldKey,
world,
null,
"World \"" + worldKey + "\" is loaded, but it is not an Iris world.");
}
private static ReconciliationResult notLoaded(NamespacedKey worldKey) {
return new ReconciliationResult(
ReconciliationStatus.NOT_LOADED,
worldKey,
null,
null,
"World creation completed without loading exact Iris world \"" + worldKey + "\".");
}
}
public record LoadResult(
ReconciliationResult reconciliation,
BukkitWorldConfiguration.Registration registration,
boolean rollbackAttempted,
boolean rollbackSucceeded,
Throwable rollbackFailure
) {
public LoadResult {
Objects.requireNonNull(reconciliation, "reconciliation");
}
public boolean succeeded() {
return reconciliation.succeeded() && rollbackFailure == null;
}
public ReconciliationStatus status() {
return reconciliation.status();
}
public NamespacedKey worldKey() {
return reconciliation.worldKey();
}
public World world() {
return reconciliation.world();
}
public Throwable failure() {
return rollbackFailure == null ? reconciliation.failure() : rollbackFailure;
}
public String message() {
if (rollbackFailure != null) {
return reconciliation.message() + " Failed to roll back bukkit.yml: " + rollbackFailure.getMessage();
}
if (rollbackAttempted && rollbackSucceeded) {
return reconciliation.message() + " The new bukkit.yml entry was rolled back.";
}
if (rollbackAttempted && !rollbackSucceeded) {
return reconciliation.message() + " The new bukkit.yml entry was no longer an exact match and was not modified.";
}
return reconciliation.message();
}
private static LoadResult validationFailure(String worldName, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.INVALID_WORLD,
null,
null,
failure,
"Invalid Iris world identifier \"" + worldName + "\": " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult busy(NamespacedKey worldKey, LifecycleOperationCoordinator.BusyException failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.BUSY,
worldKey,
null,
failure,
failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult configurationFailure(NamespacedKey worldKey, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.CONFIGURATION_FAILED,
worldKey,
null,
failure,
"Failed to register Iris world \"" + worldKey + "\" in bukkit.yml: " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult dimensionFailure(NamespacedKey worldKey, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.DIMENSION_UNRESOLVED,
worldKey,
null,
failure,
"Could not determine one Iris dimension for world \"" + worldKey + "\": " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
}
record DimensionResolution(String dimension, Throwable failure) {
DimensionResolution {
if ((dimension == null) == (failure == null)) {
throw new IllegalArgumentException("Dimension resolution must contain exactly one outcome.");
}
}
static DimensionResolution resolved(String dimension) {
return new DimensionResolution(Objects.requireNonNull(dimension, "dimension"), null);
}
static DimensionResolution failed(Throwable failure) {
return new DimensionResolution(null, Objects.requireNonNull(failure, "failure"));
}
boolean succeeded() {
return dimension != null;
}
}
public record BatchResult(List<LoadResult> results, Throwable failure) {
public BatchResult {
results = List.copyOf(Objects.requireNonNull(results, "results"));
}
public boolean succeeded() {
return failure == null && results.stream().allMatch(LoadResult::succeeded);
}
}
private static final class BukkitBackend implements Backend {
private final Iris plugin;
private BukkitBackend(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
@Override
public Map<String, String> configuredWorlds() {
return new LinkedHashMap<>(IrisWorlds.readBukkitWorlds());
}
@Override
public Long configuredSeed(String worldName) {
return IrisWorlds.readBukkitWorldSeed(worldName);
}
@Override
public Optional<World> loadedWorld(NamespacedKey worldKey) {
return WorldIdentity.resolve(worldKey);
}
@Override
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
try {
String worldName = IrisWorldStorage.logicalName(worldKey);
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
}
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
.generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
if (seed != null) {
creator.seed(seed);
}
return INMS.get().createWorldAsync(creator);
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
@Override
public boolean isIrisWorld(World world) {
return IrisToolbelt.isIrisWorld(world);
}
@Override
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions");
if (!dimensionsDirectory.isDirectory()) {
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
}
List<String> dimensions = new ArrayList<>();
Path dimensionsRoot = dimensionsDirectory.toPath().toAbsolutePath().normalize();
try (Stream<Path> paths = Files.walk(dimensionsRoot)) {
paths.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(path -> path.getFileName().toString().endsWith(".json"))
.forEach(path -> {
String relative = dimensionsRoot.relativize(path).toString().replace(File.separatorChar, '/');
dimensions.add(relative.substring(0, relative.length() - 5));
});
} catch (IOException failure) {
return DimensionResolution.failed(new IllegalStateException(
"The Iris dimensions directory could not be read.",
failure));
}
Collections.sort(dimensions);
String registeredDimension = IrisWorlds.get().getWorlds().get(worldKey.toString());
if (registeredDimension != null && dimensions.contains(registeredDimension)) {
return DimensionResolution.resolved(registeredDimension);
}
if (dimensions.size() == 1) {
return DimensionResolution.resolved(dimensions.getFirst());
}
if (dimensions.isEmpty()) {
return DimensionResolution.failed(new IllegalStateException("No dimension definitions were found."));
}
return DimensionResolution.failed(new IllegalStateException(
"Multiple dimension definitions were found without an exact registered dimension: "
+ String.join(", ", dimensions)));
}
}
}
@@ -23,6 +23,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
@@ -33,7 +34,6 @@ import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.io.IO;
import lombok.NonNull;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
@@ -42,6 +42,7 @@ import org.bukkit.generator.ChunkGenerator;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.List;
import java.util.function.Supplier;
/**
@@ -57,15 +58,12 @@ public final class IrisWorldGeneratorResolver {
public void validateAllPacks() {
File packsRoot = plugin.getDataFolder("packs");
File[] packDirs = packsRoot.listFiles(File::isDirectory);
if (packDirs == null || packDirs.length == 0) {
List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
PackValidationRegistry.clear();
if (packDirs.isEmpty()) {
return;
}
PackValidationRegistry.clear();
for (File packDir : packDirs) {
if (packDir.getName().contains(".importing-")) {
continue;
}
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
@@ -167,16 +165,16 @@ public final class IrisWorldGeneratorResolver {
Iris.debug("Generator Config: " + w.toString());
File ff = new File(w.worldFolder(), "iris/pack");
File[] files = ff.listFiles();
if (files == null || files.length == 0)
IO.delete(ff);
if (!ff.exists()) {
ff.mkdirs();
dim = Iris.service(StudioSVC.class).installIntoWorld(Iris.getSender(), dim, w.worldFolder());
IrisDimension installedDimension = ff.isDirectory()
? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false)
: null;
if (installedDimension == null) {
dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder());
if (dim == null) {
throw new IllegalStateException("Failed to install dimension pack for " + id);
}
} else {
dim = installedDimension;
}
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
@@ -19,14 +19,11 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
import art.arcane.iris.core.tools.IrisCreator;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.io.IO;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -34,21 +31,38 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Persistent queue of world folders that must be deleted on the next startup, plus the startup
* drain that actually removes them.
*/
public final class PendingWorldDeleteQueue {
public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt";
private static final String EXACT_PREFIX = "exact:";
private static final Pattern SAFE_LOGICAL_NAME = Pattern.compile("^[a-z0-9_-]+$");
private static final Pattern QUARANTINE_NAME = Pattern.compile("^\\.iris-delete-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
private static final Set<String> VANILLA_DIMENSION_ALIASES = Set.of("overworld", "the_nether", "the_end");
private final VolmitPlugin plugin;
@@ -56,173 +70,413 @@ public final class PendingWorldDeleteQueue {
this.plugin = plugin;
}
public synchronized int queueWorldDeletionOnStartup(Collection<String> worldNames) throws IOException {
@Override
public synchronized int queueExactForStartupDeletion(Collection<String> worldNames) throws IOException {
return queueWorldDeletionOnStartup(worldNames, true);
}
@Override
public synchronized int queueFamilyForStartupDeletion(Collection<String> worldNames) throws IOException {
return queueWorldDeletionOnStartup(worldNames, false);
}
private int queueWorldDeletionOnStartup(Collection<String> worldNames, boolean exact) throws IOException {
if (worldNames == null || worldNames.isEmpty()) {
return 0;
}
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap();
int before = queue.size();
File levelRoot = IrisWorldStorage.levelRoot();
ArrayList<String> normalizedNames = new ArrayList<>(worldNames.size());
for (String worldName : worldNames) {
String normalized = normalizeWorldName(worldName);
String normalized = normalizeQueueEntry(worldName, levelRoot.getName());
if (normalized == null) {
continue;
throw new IllegalArgumentException("Unsafe Iris world deletion target: " + worldName);
}
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized);
normalizedNames.add(exact && !QUARANTINE_NAME.matcher(normalized).matches()
? EXACT_PREFIX + normalized
: normalized);
}
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
int before = queue.size();
for (String normalized : normalizedNames) {
mergeQueueEntry(queue, normalized);
}
if (queue.size() != before) {
writePendingWorldDeleteMap(queue);
writePendingWorldDeleteMap(queueFile, queue);
}
return queue.size() - before;
}
public void processPendingStartupWorldDeletes() {
public synchronized void processPendingStartupWorldDeletes() {
try {
try {
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
if (unregistered > 0) {
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
}
} catch (Throwable e) {
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", e);
}
unregisterTransientStudioWorlds();
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap();
for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())) {
queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld);
File levelRoot = IrisWorldStorage.levelRoot();
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
for (String discoveredName : discoverStartupWorldNames(levelRoot)) {
mergeQueueEntry(queue, discoveredName);
}
if (queue.isEmpty()) {
if (queueFile.exists()) {
writePendingWorldDeleteMap(queueFile, queue);
}
return;
}
LinkedHashMap<String, String> remaining = new LinkedHashMap<>();
for (String worldName : queue.values()) {
if (worldName.equalsIgnoreCase(ServerProperties.LEVEL_NAME)) {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is configured as level-name.");
continue;
}
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
World loaded = WorldIdentity.resolve(worldKey).orElse(null);
if (loaded != null) {
if (TransientWorldCleanupSupport.isTransientStudioWorldName(worldName)) {
try {
PlatformChunkGenerator generator = IrisToolbelt.access(loaded);
if (generator != null) {
generator.close();
}
IrisToolbelt.evacuate(loaded);
Bukkit.unloadWorld(loaded, false);
Iris.info("Unloaded leftover studio world \"" + worldName + "\" for deletion.");
} catch (Throwable e) {
Iris.reportError("Failed to unload leftover studio world \"" + worldName + "\".", e);
}
if (WorldIdentity.resolve(worldKey).isPresent()) {
Iris.warn("Studio world \"" + worldName + "\" is still loaded after unload; will retry next startup.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
} else {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
}
boolean foundAny = false;
boolean deletedAll = true;
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
File worldFolder = IrisWorldStorage.dimensionRoot(familyWorldName);
if (!worldFolder.exists()) {
continue;
}
foundAny = true;
IO.delete(worldFolder);
if (worldFolder.exists()) {
deletedAll = false;
Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup.");
} else {
Iris.info("Deleted queued world folder \"" + familyWorldName + "\".");
}
}
if (!foundAny) {
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
continue;
}
if (!deletedAll) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
}
writePendingWorldDeleteMap(remaining);
} catch (Throwable e) {
Iris.error("Failed to process queued startup world deletions.");
Iris.reportError(e);
e.printStackTrace();
}
}
private LinkedHashMap<String, String> loadPendingWorldDeleteMap() throws IOException {
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
if (!queueFile.exists()) {
return queue;
}
try (BufferedReader reader = new BufferedReader(new FileReader(queueFile))) {
String line;
while ((line = reader.readLine()) != null) {
String normalized = normalizeWorldName(line);
if (normalized == null) {
continue;
}
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized);
}
}
return queue;
}
private void writePendingWorldDeleteMap(Map<String, String> queue) throws IOException {
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
if (queue.isEmpty()) {
if (queueFile.exists()) {
IO.delete(queueFile);
}
return;
}
File parent = queueFile.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Failed to create queue directory: " + parent.getAbsolutePath());
}
try (PrintWriter writer = new PrintWriter(new FileWriter(queueFile))) {
for (String worldName : queue.values()) {
writer.println(worldName);
processEntry(levelRoot, worldName, remaining);
}
writePendingWorldDeleteMap(queueFile, remaining);
} catch (Throwable failure) {
Iris.reportError("Failed to process queued startup world deletions.", failure);
}
}
@Nullable
private static String normalizeWorldName(String worldName) {
static String normalizeQueueEntry(String worldName, String levelName) {
if (worldName == null) {
return null;
}
String trimmed = worldName.trim();
if (trimmed.isEmpty()) {
String candidate = worldName.trim();
if (candidate.isEmpty()) {
return null;
}
if (QUARANTINE_NAME.matcher(candidate).matches()) {
return candidate;
}
String logicalName = candidate.startsWith("iris:") ? candidate.substring("iris:".length()) : candidate;
if (!SAFE_LOGICAL_NAME.matcher(logicalName).matches()) {
return null;
}
String normalizedLevelName = Objects.requireNonNull(levelName, "levelName").trim().toLowerCase(Locale.ROOT);
if (VANILLA_DIMENSION_ALIASES.contains(logicalName)
|| logicalName.equals(normalizedLevelName)
|| logicalName.equals(normalizedLevelName + "_nether")
|| logicalName.equals(normalizedLevelName + "_the_end")) {
return null;
}
return trimmed;
try {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(candidate, normalizedLevelName);
return key.getKey().equals(logicalName) ? logicalName : null;
} catch (IllegalArgumentException failure) {
return null;
}
}
static LinkedHashMap<String, String> loadPendingWorldDeleteMap(File queueFile, String levelName) throws IOException {
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath();
if (!Files.exists(queuePath, LinkOption.NOFOLLOW_LINKS)) {
return queue;
}
try (BufferedReader reader = Files.newBufferedReader(queuePath, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
String normalized = normalizeStoredQueueEntry(line, levelName);
if (normalized != null) {
mergeQueueEntry(queue, normalized);
}
}
}
return queue;
}
@Nullable
private static String normalizeStoredQueueEntry(String storedEntry, String levelName) {
if (storedEntry == null) {
return null;
}
String candidate = storedEntry.trim();
boolean exact = candidate.startsWith(EXACT_PREFIX);
String rawName = exact ? candidate.substring(EXACT_PREFIX.length()) : candidate;
String normalized = normalizeQueueEntry(rawName, levelName);
if (normalized == null || QUARANTINE_NAME.matcher(normalized).matches()) {
return normalized;
}
return exact ? EXACT_PREFIX + normalized : normalized;
}
private static void mergeQueueEntry(Map<String, String> queue, String storedEntry) {
String logicalKey = storedEntry.startsWith(EXACT_PREFIX)
? storedEntry.substring(EXACT_PREFIX.length())
: storedEntry;
String key = logicalKey.toLowerCase(Locale.ROOT);
String existing = queue.get(key);
if (existing == null || (existing.startsWith(EXACT_PREFIX) && !storedEntry.startsWith(EXACT_PREFIX))) {
queue.put(key, storedEntry);
}
}
static void writePendingWorldDeleteMap(File queueFile, Map<String, String> queue) throws IOException {
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath().toAbsolutePath().normalize();
Path parent = queuePath.getParent();
if (parent == null) {
throw new IOException("Queue file has no parent directory: " + queuePath);
}
Files.createDirectories(parent);
StringBuilder content = new StringBuilder();
for (String worldName : Objects.requireNonNull(queue, "queue").values()) {
content.append(worldName).append('\n');
}
Path temporary = parent.resolve(queuePath.getFileName() + ".tmp-" + UUID.randomUUID());
IOException failure = null;
try {
byte[] bytes = content.toString().getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(
temporary,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
replaceQueueFile(temporary, queuePath);
forceDirectory(parent);
} catch (IOException writeFailure) {
failure = writeFailure;
throw writeFailure;
} finally {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupFailure) {
if (failure != null) {
failure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
}
static LinkedHashSet<String> discoverStartupWorldNames(File levelRoot) throws IOException {
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
Path dimensions = root.resolve("dimensions");
Path irisNamespace = dimensions.resolve("iris");
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
return worldNames;
}
if (Files.isSymbolicLink(dimensions) || Files.isSymbolicLink(irisNamespace)) {
throw new IOException("Iris dimension storage contains a symbolic link: " + irisNamespace);
}
if (!Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris dimension storage is not a directory: " + irisNamespace);
}
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
for (Path child : children) {
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
String name = child.getFileName().toString();
if (QUARANTINE_NAME.matcher(name).matches()) {
worldNames.add(name);
continue;
}
String transientBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(name);
String normalized = normalizeQueueEntry(transientBaseName, root.getFileName().toString());
if (normalized != null) {
worldNames.add(normalized);
}
}
}
return worldNames;
}
static List<Path> resolveQueueEntryPaths(File levelRoot, String worldName) throws IOException {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
ArrayList<Path> paths = new ArrayList<>(targets.size());
for (DeleteTarget target : targets) {
paths.add(target.path());
}
return List.copyOf(paths);
}
private static void replaceQueueFile(Path temporary, Path queuePath) throws IOException {
try {
Files.move(temporary, queuePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(temporary, queuePath, StandardCopyOption.REPLACE_EXISTING);
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
private static void unregisterTransientStudioWorlds() {
try {
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
if (unregistered > 0) {
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
}
} catch (Throwable failure) {
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", failure);
}
}
private static void processEntry(
File levelRoot,
String worldName,
LinkedHashMap<String, String> remaining
) {
try {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
return;
}
boolean foundAny = false;
boolean deletedAll = true;
for (DeleteTarget target : targets) {
Path worldFolder = target.path();
if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Queued world target is not a safe directory: " + worldFolder);
}
foundAny = true;
try {
deleteTree(worldFolder);
Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\".");
} catch (IOException failure) {
deletedAll = false;
Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure);
}
}
if (!foundAny) {
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
return;
}
if (!deletedAll) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
}
} catch (Throwable failure) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
Iris.reportError("Failed to safely process queued world deletion for \"" + worldName + "\".", failure);
}
}
private static boolean isLoaded(DeleteTarget target) {
if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) {
return true;
}
Path targetPath = target.path().toAbsolutePath().normalize();
for (World world : Bukkit.getWorlds()) {
if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) {
return true;
}
}
return false;
}
private static void deleteTree(Path target) throws IOException {
Files.walkFileTree(target, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
private enum QueueEntryType {
EXACT,
LOGICAL,
QUARANTINE
}
private record QueueEntry(String storedName, QueueEntryType type) {
private static QueueEntry parse(String worldName, String levelName) {
String stored = normalizeStoredQueueEntry(worldName, levelName);
if (stored == null) {
throw new IllegalArgumentException("Unsafe queued Iris world deletion target: " + worldName);
}
boolean exact = stored.startsWith(EXACT_PREFIX);
String normalized = exact ? stored.substring(EXACT_PREFIX.length()) : stored;
QueueEntryType type = QUARANTINE_NAME.matcher(normalized).matches()
? QueueEntryType.QUARANTINE
: exact ? QueueEntryType.EXACT : QueueEntryType.LOGICAL;
return new QueueEntry(normalized, type);
}
private List<DeleteTarget> targets(File levelRoot) throws IOException {
if (type == QueueEntryType.QUARANTINE) {
return List.of(new DeleteTarget(null, requireSafeQuarantinePath(levelRoot, storedName)));
}
if (type == QueueEntryType.EXACT) {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName());
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
return List.of(new DeleteTarget(key, path));
}
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
targets.add(new DeleteTarget(key, path));
}
return targets;
}
}
private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException {
if (!QUARANTINE_NAME.matcher(quarantineName).matches()) {
throw new IOException("Invalid Iris quarantine directory name: " + quarantineName);
}
Path root = levelRoot.toPath().toAbsolutePath().normalize();
Path dimensions = root.resolve("dimensions");
Path irisNamespace = dimensions.resolve("iris");
Path target = irisNamespace.resolve(quarantineName).normalize();
if (!Objects.equals(target.getParent(), irisNamespace)) {
throw new IOException("Iris quarantine target escapes its namespace: " + target);
}
for (Path path : List.of(dimensions, irisNamespace, target)) {
if (Files.isSymbolicLink(path)) {
throw new IOException("Iris quarantine storage contains a symbolic link: " + path);
}
}
return target;
}
private record DeleteTarget(@Nullable NamespacedKey key, Path path) {
}
}
@@ -21,6 +21,7 @@ package art.arcane.iris.core.commands;
import com.google.gson.JsonObject;
import art.arcane.iris.Iris;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.runtime.ChunkClearer;
import art.arcane.iris.core.runtime.GoldenHashScanner;
@@ -216,7 +217,15 @@ public class CommandDeveloper implements DirectorExecutor {
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true);
}
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder);
try (LifecycleOperationCoordinator.Lease lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH,
pack.getLoadKey()
)) {
Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder);
} catch (LifecycleOperationCoordinator.BusyException e) {
sender().sendMessage(C.YELLOW + e.getMessage());
}
}
@Director(description = "Test", descriptionKey = "iris.director.commanddeveloper.director.test")
@@ -125,6 +125,7 @@ public class CommandFind implements DirectorExecutor {
String structureKey = structure == null ? "" : structure.trim();
Structure nativeStructure = resolveNativeStructure(structureKey);
boolean irisReplacement = false;
if (nativeStructure != null) {
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
e, structureKey, false);
@@ -134,7 +135,8 @@ public class CommandFind implements DirectorExecutor {
structureKey, decision.status()));
return;
}
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
irisReplacement = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS;
if (irisReplacement && !IrisStructureLocator.hasNativePlacement(e, structureKey)) {
locateIrisStructure(e, structureKey, commandSender);
return;
}
@@ -149,6 +151,9 @@ public class CommandFind implements DirectorExecutor {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey)));
return;
}
final boolean replacementLocate = irisReplacement;
final boolean explicitNativePlacement = IrisStructureLocator.hasNativePlacement(
e, structureKey);
Player target = player();
if (target == null) {
@@ -161,7 +166,8 @@ public class CommandFind implements DirectorExecutor {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey)));
J.s(() -> {
try {
if (!StructureReachability.isReachable(e, structureKey)) {
if (!replacementLocate && !explicitNativePlacement
&& !StructureReachability.isReachable(e, structureKey)) {
KList<String> miss = StructureReachability.missingBiomeKeys(e, structureKey);
sendStructureMessage(target, commandSender,
C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack"
@@ -19,16 +19,24 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
@@ -41,7 +49,6 @@ import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.io.IO;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
@@ -49,32 +56,44 @@ import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static art.arcane.iris.core.service.EditSVC.deletingWorld;
import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
import static org.bukkit.Bukkit.getServer;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitCommandMessages;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
public class CommandIris implements DirectorExecutor {
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
private CommandStudio studio;
private CommandPregen pregen;
private CommandObject object;
@@ -85,10 +104,7 @@ public class CommandIris implements DirectorExecutor {
private CommandPack pack;
private CommandFind find;
private CommandDatapack datapack;
public static boolean worldCreation = false;
private static final AtomicReference<Thread> mainWorld = new AtomicReference<>();
String WorldEngine;
String worldNameToCheck = "YourWorldName";
VolmitSender sender = Iris.getSender();
@Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"})
@@ -107,7 +123,15 @@ public class CommandIris implements DirectorExecutor {
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
boolean main
) {
String worldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name));
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
String worldName = IrisWorldStorage.logicalName(worldKey);
if (worldName.equalsIgnoreCase("iris")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
@@ -145,7 +169,6 @@ public class CommandIris implements DirectorExecutor {
}
try {
worldCreation = true;
IrisToolbelt.createWorld()
.dimension(resolvedType)
.name(worldName)
@@ -160,22 +183,46 @@ public class CommandIris implements DirectorExecutor {
}));
}
} catch (Throwable e) {
if (reportExpectedCreationInterruption(e)) {
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS));
Iris.reportError("Exception raised during world creation for \"" + worldName + "\".", e);
worldCreation = false;
return;
}
worldCreation = false;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD));
if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN));
}
private boolean updateMainWorld(String newName) {
LifecycleOperationCoordinator.Lease lease;
try {
lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_PROMOTE,
newName
);
} catch (LifecycleOperationCoordinator.BusyException e) {
Iris.error("Could not promote Iris world \"" + newName + "\": " + e.getMessage());
return false;
}
try {
return updateMainWorldUnderLease(newName);
} finally {
lease.close();
}
}
private boolean updateMainWorldUnderLease(String newName) {
try {
File oldLevelRoot = IrisWorldStorage.levelRoot();
File worldContainer = oldLevelRoot.getParentFile();
Properties data = ServerProperties.DATA;
if (worldContainer == null) {
throw new IllegalStateException("Current level folder has no world container.");
}
Properties data = new Properties();
try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) {
data.load(in);
}
@@ -186,22 +233,6 @@ public class CommandIris implements DirectorExecutor {
}
File newLevelRoot = new File(worldContainer, newName);
if (!newLevelRoot.exists() && !newLevelRoot.mkdirs()) {
throw new IllegalStateException("Could not create target level folder: " + newLevelRoot.getAbsolutePath());
}
for (String sub : List.of("data", "datapacks", "players")) {
File source = new File(oldLevelRoot, sub);
if (!source.exists()) {
continue;
}
IO.copyDirectory(source.toPath(), new File(newLevelRoot, sub).toPath());
}
File targetDimensionRoot = IrisWorldStorage.dimensionRoot(newLevelRoot, NamespacedKey.minecraft("overworld"));
IO.copyDirectory(sourceDimensionRoot.toPath(), targetDimensionRoot.toPath());
World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null);
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName);
if (sourceWorld == null && stagedSeed == null) {
@@ -210,8 +241,18 @@ public class CommandIris implements DirectorExecutor {
long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed();
data.setProperty("level-name", newName);
data.setProperty("level-seed", Long.toString(promotedSeed));
try (FileOutputStream out = new FileOutputStream(ServerProperties.SERVER_PROPERTIES)) {
data.store(out, null);
try (MainWorldPublication publication = publishMainWorldFiles(
oldLevelRoot.toPath(),
sourceDimensionRoot.toPath(),
newLevelRoot.toPath()
)) {
writeServerPropertiesAtomically(ServerProperties.SERVER_PROPERTIES.toPath(), data);
publication.commit();
}
synchronized (ServerProperties.DATA) {
ServerProperties.DATA.clear();
ServerProperties.DATA.putAll(data);
}
return true;
} catch (Throwable e) {
@@ -221,57 +262,204 @@ public class CommandIris implements DirectorExecutor {
}
}
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP));
File worldFolder = IrisWorldStorage.dimensionRoot(name);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
if (installed == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
return false;
static MainWorldPublication publishMainWorldFiles(
Path currentLevelRoot,
Path sourceDimensionRoot,
Path targetLevelRoot
) throws IOException {
Path current = Objects.requireNonNull(currentLevelRoot, "currentLevelRoot").toAbsolutePath().normalize();
Path sourceDimension = Objects.requireNonNull(sourceDimensionRoot, "sourceDimensionRoot").toAbsolutePath().normalize();
Path target = Objects.requireNonNull(targetLevelRoot, "targetLevelRoot").toAbsolutePath().normalize();
Path worldContainer = current.getParent();
Path sourceNamespace = current.resolve("dimensions/iris");
if (worldContainer == null || !Objects.equals(target.getParent(), worldContainer)) {
throw new IOException("Promoted main world must be a direct child of the world container.");
}
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
return false;
if (!Objects.equals(sourceDimension.getParent(), sourceNamespace)) {
throw new IOException("Promoted source must be a direct Iris dimension.");
}
if (Objects.equals(current, target)) {
throw new IOException("Promoted main world cannot replace the current main world.");
}
if (Files.isSymbolicLink(worldContainer)
|| Files.isSymbolicLink(current)
|| !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Current world storage is missing or unsafe.");
}
if (Files.isSymbolicLink(sourceDimension)
|| !Files.isDirectory(sourceDimension, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Promoted Iris dimension is missing or unsafe: " + sourceDimension);
}
requireAbsentMainWorldTarget(target);
if (main) {
if (updateMainWorld(name)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
return false;
Path stage = Files.createTempDirectory(worldContainer, "." + target.getFileName() + ".promoting-");
boolean published = false;
try {
for (String subdirectory : List.of("data", "datapacks", "players")) {
Path source = current.resolve(subdirectory);
if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(source)) {
continue;
}
copyWorldTree(source, stage.resolve(subdirectory));
}
Path targetDimension = IrisWorldStorage.dimensionRoot(
stage.toFile(),
NamespacedKey.minecraft("overworld")
).toPath();
copyWorldTree(sourceDimension, targetDimension);
requireAbsentMainWorldTarget(target);
Files.move(stage, target);
published = true;
return new MainWorldPublication(target);
} finally {
if (!published) {
AtomicDirectoryPublisher.deleteTree(stage);
}
}
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
if (main) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
private static void requireAbsentMainWorldTarget(Path target) throws IOException {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new FileAlreadyExistsException("Main-world target already exists: " + target);
}
}
private static void copyWorldTree(Path source, Path target) throws IOException {
if (Files.isSymbolicLink(source)) {
throw new IOException("World data contains a symbolic link: " + source);
}
if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
return;
}
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World data contains an unsupported entry: " + source);
}
try (Stream<Path> entries = Files.walk(source)) {
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
if (Files.isSymbolicLink(entry)) {
throw new IOException("World data contains a symbolic link: " + entry);
}
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("World data escapes its promotion stage: " + entry);
}
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
} else {
throw new IOException("World data contains an unsupported entry: " + entry);
}
}
}
}
private static void writeServerPropertiesAtomically(Path propertiesFile, Properties data) throws IOException {
Path target = propertiesFile.toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) {
throw new IOException("server.properties has no parent directory.");
}
Path stage = Files.createTempFile(parent, ".server.properties.promoting-", ".tmp");
IOException operationFailure = null;
try {
try (FileOutputStream out = new FileOutputStream(stage.toFile())) {
data.store(out, null);
out.getFD().sync();
}
try {
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(stage, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
operationFailure = e;
throw e;
} finally {
try {
Files.deleteIfExists(stage);
} catch (IOException cleanupFailure) {
if (operationFailure != null) {
operationFailure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
}
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name);
LifecycleOperationCoordinator.Lease worldLease = null;
File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
try {
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
worldLease = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
worldKey.toString());
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP));
if (worldFolder.exists()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
return false;
}
DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapackResult.succeeded()) {
sender().sendMessage(C.RED + "Failed to compile the Iris datapack. No world files were staged.");
return false;
}
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
if (installed == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
deleteDirectorySafely(worldFolder);
return false;
}
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
deleteDirectorySafely(worldFolder);
return false;
}
if (main) {
if (updateMainWorldUnderLease(name)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
try {
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
} catch (IOException e) {
Iris.reportError("Failed to roll back bukkit.yml after main-world staging failed.", e);
}
deleteDirectorySafely(worldFolder);
return false;
}
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
if (main) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
}
return true;
} catch (LifecycleOperationCoordinator.BusyException e) {
sender().sendMessage(C.YELLOW + e.getMessage());
return false;
} finally {
if (worldLease != null) {
worldLease.close();
}
}
return true;
}
private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) {
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName));
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
ConfigurationSection worlds = yml.getConfigurationSection("worlds");
if (worlds == null) {
worlds = yml.createSection("worlds");
}
ConfigurationSection worldSection = worlds.getConfigurationSection(logicalWorldName);
if (worldSection == null) {
worldSection = worlds.createSection(logicalWorldName);
}
String generator = "Iris:" + dimension;
worldSection.set("generator", generator);
if (seed != null) {
worldSection.set("seed", seed);
}
try {
yml.save(BUKKIT_YML);
BukkitWorldConfiguration.register(BUKKIT_YML, logicalWorldName, dimension, seed);
Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml");
return true;
} catch (IOException e) {
@@ -282,6 +470,31 @@ public class CommandIris implements DirectorExecutor {
}
}
private void deleteDirectorySafely(File directory) {
try {
AtomicDirectoryPublisher.deleteTree(directory.toPath());
} catch (IOException e) {
Iris.reportError("Failed to roll back staged world folder \"" + directory.getAbsolutePath() + "\".", e);
}
}
private boolean reportExpectedCreationInterruption(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof LifecycleOperationCoordinator.BusyException) {
sender().sendMessage(C.YELLOW + current.getMessage());
return true;
}
current = current.getCause();
}
String message = failure.getMessage();
if (message != null && message.contains("queued a restart")) {
sender().sendMessage(C.YELLOW + message);
return true;
}
return false;
}
@Director(description = "Teleport to another world", descriptionKey = "iris.director.commandiris.director.teleport_another_world", aliases = {"tp"}, sync = true)
public void teleport(
@Param(description = "World to teleport to", descriptionKey = "iris.director.commandiris.param.world_teleport")
@@ -368,78 +581,73 @@ public class CommandIris implements DirectorExecutor {
@Director(description = "Remove an Iris world", descriptionKey = "iris.director.commandiris.director.remove_iris_world", aliases = {"rm"}, sync = true)
public void remove(
@Param(description = "The world to remove", descriptionKey = "iris.director.commandiris.param.world_remove")
World world,
@Param(description = "The loaded or disk-only Iris world to remove", descriptionKey = "iris.director.commandiris.param.world_remove", customHandler = ManagedWorldNameHandler.class)
String world,
@Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", descriptionKey = "iris.director.commandiris.param.whether_also_remove_folder_if_set_false_just_does_not_load_world", defaultValue = "true")
boolean delete
) {
if (!IrisToolbelt.isIrisWorld(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()))));
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_REMOVING_WORLD, MessageArgument.untrusted("value", world.getName())));
if (!IrisToolbelt.evacuate(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_EVACUATE_WORLD, MessageArgument.untrusted("value", world.getName())));
return;
}
if (!WorldLifecycleService.get().unload(world, false)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD, MessageArgument.untrusted("value", world.getName())));
return;
}
try {
if (IrisToolbelt.removeWorld(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_REMOVED_FROM_BUKKIT_YML, MessageArgument.untrusted("value", world.getName())));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOOKS_LIKE_WORLD_WAS_ALREADY_REMOVED_FROM_BUKKIT_YML));
VolmitSender responseSender = sender();
responseSender.sendMessage(C.GRAY + "Removing Iris world '" + world + "'...");
IrisWorldRemovalService.get().remove(world, delete).whenComplete((result, throwable) -> {
Runnable response = () -> reportRemovalResult(responseSender, world, result, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
return;
}
} catch (IOException e) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_SAVE_BUKKIT_YML_BECAUSE, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e);
}
IrisToolbelt.evacuate(world, "Deleting world");
deletingWorld = true;
if (!delete) {
deletingWorld = false;
return;
}
VolmitSender sender = sender();
J.a(() -> {
int retries = 12;
if (deleteDirectory(world.getWorldFolder())) {
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER));
} else {
while(true){
if (deleteDirectory(world.getWorldFolder())){
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER_2));
break;
}
retries--;
if (retries == 0){
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_FAILED_REMOVE_WORLD_FOLDER));
break;
}
J.sleep(3000);
}
}
deletingWorld = false;
J.s(response);
});
}
public static boolean deleteDirectory(File dir) {
if (dir.isDirectory()) {
File[] children = dir.listFiles();
for (int i = 0; i < children.length; i++) {
boolean success = deleteDirectory(children[i]);
if (!success) {
return false;
private void reportRemovalResult(
VolmitSender responseSender,
String requestedWorld,
IrisWorldRemovalService.RemovalResult result,
Throwable throwable
) {
if (throwable != null || result == null) {
Throwable failure = throwable == null
? new IllegalStateException("World removal returned no result.")
: throwable;
responseSender.sendMessage(C.RED + "World removal failed unexpectedly; nothing further was deleted.");
Iris.reportError("Unexpected world removal failure for \"" + requestedWorld + "\".", failure);
return;
}
switch (result.status()) {
case UNREGISTERED -> responseSender.sendMessage(C.GREEN + "Unloaded and unregistered '"
+ result.target().logicalName() + "'; its files were preserved.");
case DELETED -> responseSender.sendMessage(C.GREEN + "Removed Iris world '"
+ result.target().logicalName() + "' and deleted its folder.");
case DELETE_QUEUED -> responseSender.sendMessage(C.YELLOW + "Removed Iris world '"
+ result.target().logicalName() + "'; its quarantined folder will be deleted at startup.");
case BUSY -> responseSender.sendMessage(C.YELLOW + "World changes are busy with "
+ result.blockingOperation().kind().name().toLowerCase(Locale.ROOT) + " for '"
+ result.blockingOperation().target() + "'. Try again when it completes.");
case INVALID_IDENTIFIER, PROTECTED_WORLD, NOT_IRIS_WORLD, UNSAFE_PATH, NOT_FOUND ->
responseSender.sendMessage(C.RED + removalFailureDetail(result));
default -> {
responseSender.sendMessage(C.RED + "World removal stopped at "
+ result.status().name().toLowerCase(Locale.ROOT) + ": " + removalFailureDetail(result));
if (result.quarantineDirectory() != null) {
responseSender.sendMessage(C.YELLOW + "The recoverable world folder is "
+ result.quarantineDirectory().toAbsolutePath() + ".");
} else if (result.configurationChanged() || result.registryChanged()) {
responseSender.sendMessage(C.YELLOW + "Removal changed registration state before stopping; "
+ "the original world folder was not deleted.");
}
if (result.failure() != null) {
Iris.reportError("World removal failed for \"" + requestedWorld + "\" at "
+ result.status().name() + ".", result.failure());
}
}
}
return dir.delete();
}
private String removalFailureDetail(IrisWorldRemovalService.RemovalResult result) {
Throwable failure = result.failure();
if (failure == null || failure.getMessage() == null || failure.getMessage().isBlank()) {
return result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
return failure.getMessage();
}
@Director(description = "Toggle debug", descriptionKey = "iris.director.commandiris.director.toggle_debug")
@@ -466,7 +674,6 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
}
ServerConfigurator.installDataPacksIfChanged(true);
}
@Director(description = "Get metrics for your world", descriptionKey = "iris.director.commandiris.director.get_metrics_your_world", aliases = "measure", origin = DirectorOrigin.PLAYER)
@@ -508,76 +715,187 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS_2, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()))));
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName())));
VolmitSender responseSender = sender();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName())));
LifecycleOperationCoordinator.Lease lease;
try {
IrisToolbelt.evacuate(world);
boolean unloaded = WorldLifecycleService.get().unload(world, false);
if (unloaded) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2));
}
lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_UNLOAD,
WorldIdentity.serialize(world)
);
} catch (LifecycleOperationCoordinator.BusyException e) {
responseSender.sendMessage(C.YELLOW + e.getMessage());
return;
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
try {
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
.thenCompose(evacuated -> {
if (terminalTimeout.get()) {
return CompletableFuture.failedFuture(new TimeoutException(
"World unload stopped after its terminal timeout."));
}
if (!Boolean.TRUE.equals(evacuated)) {
return CompletableFuture.completedFuture(false);
}
return WorldLifecycleService.get().unloadAsync(world, true);
})
.thenCompose(unloaded -> {
if (terminalTimeout.get()) {
return CompletableFuture.failedFuture(new TimeoutException(
"World unload stopped after its terminal timeout."));
}
if (!Boolean.TRUE.equals(unloaded) || generator == null) {
return CompletableFuture.completedFuture(Boolean.TRUE.equals(unloaded));
}
return generator.closeAsync().thenApply(ignored -> true);
});
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "world-unload");
lease.close();
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
return;
}
J.s(response);
});
} catch (Exception e) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
IrisToolbelt.endWorldMaintenance(world, "world-unload");
lease.close();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
}
}
private CompletableFuture<Boolean> guardUnloadCompletion(
CompletableFuture<Boolean> source,
AtomicBoolean terminalTimeout,
String worldName
) {
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((unloaded, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(Boolean.TRUE.equals(unloaded));
} else {
guarded.completeExceptionally(throwable);
}
});
CompletableFuture.delayedExecutor(WORLD_UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
terminalTimeout.set(true);
TimeoutException timeout = new TimeoutException(
"World unload did not settle within " + WORLD_UNLOAD_TIMEOUT_SECONDS
+ " seconds for \"" + worldName + "\".");
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
guarded.completeExceptionally(timeout);
});
return guarded;
}
private void reportUnloadResult(VolmitSender responseSender, World world, Boolean unloaded, Throwable throwable) {
if (throwable != null) {
responseSender.sendMessage(IrisLanguage.text(
BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3,
MessageArgument.untrusted("value", String.valueOf(throwable.getMessage()))
));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", throwable);
return;
}
if (Boolean.TRUE.equals(unloaded)) {
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY));
} else {
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2));
}
}
@Director(description = "Load an Iris World", descriptionKey = "iris.director.commandiris.director.load_iris_world", origin = DirectorOrigin.PLAYER, sync = true, aliases = {"import"})
public void loadWorld(
@Param(description = "The name of the world to load", descriptionKey = "iris.director.commandiris.param.name_world_load")
@Param(
description = "The name of the world to load",
descriptionKey = "iris.director.commandiris.param.name_world_load",
customHandler = ManagedWorldNameHandler.class)
String world
) {
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(world));
worldNameToCheck = logicalWorldName;
boolean worldExists = doesWorldExist(worldNameToCheck);
WorldEngine = logicalWorldName;
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.managedKeyFromName(world);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
} catch (IllegalArgumentException failure) {
sender().sendMessage(C.RED + failure.getMessage());
return;
}
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
boolean worldExists = doesWorldExist(logicalWorldName);
if (!worldExists) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOESNT_EXIST_ON_SERVER, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
File directory = new File(IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(logicalWorldName)), "dimensions");
String dimension = null;
if (directory.exists() && directory.isDirectory()) {
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile()) {
String fileName = file.getName();
if (fileName.endsWith(".json")) {
dimension = fileName.substring(0, fileName.length() - 5);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_GENERATOR, MessageArgument.untrusted("dimension", dimension)));
}
VolmitSender responseSender = sender();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
Iris.instance.worldReconciler()
.loadWorld(BUKKIT_YML, worldKey.toString())
.whenComplete((result, failure) -> {
Runnable response = () -> reportLoadWorldResult(
responseSender,
logicalWorldName,
result,
failure);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
return;
}
}
}
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_IS_NOT_IRIS_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
if (dimension == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_COULD_NOT_DETERMINE_IRIS_DIMENSION, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
if (!registerWorldInBukkitYml(logicalWorldName, dimension, null)) {
return;
}
if (J.isFolia()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FOLIA_CANNOT_LOAD_NEW_WORLDS_AT_RUNTIME_RESTART_SERVER_LOAD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
Iris.instance.worldReconciler().checkForBukkitWorlds(logicalWorldName::equals);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
J.s(response);
});
}
private void reportLoadWorldResult(
VolmitSender responseSender,
String logicalWorldName,
BukkitWorldReconciler.LoadResult result,
Throwable failure
) {
if (failure != null) {
responseSender.sendMessage(C.RED + "Failed to load Iris world \"" + logicalWorldName + "\": " + failure.getMessage());
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", failure);
return;
}
if (result == null) {
IllegalStateException missingResult = new IllegalStateException("World load completed without a result.");
responseSender.sendMessage(C.RED + missingResult.getMessage());
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", missingResult);
return;
}
if (result.succeeded()) {
responseSender.sendMessage(IrisLanguage.text(
BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY,
MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
C color = result.status() == BukkitWorldReconciler.ReconciliationStatus.BUSY
|| result.status() == BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED
? C.YELLOW
: C.RED;
responseSender.sendMessage(color + result.message());
Throwable resultFailure = result.failure();
if (resultFailure != null
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.BUSY
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED) {
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", resultFailure);
}
}
@Director(description = "Evacuate an iris world", descriptionKey = "iris.director.commandiris.director.evacuate_iris_world", origin = DirectorOrigin.PLAYER, sync = true)
public void evacuate(
@Param(description = "Evacuate the world", descriptionKey = "iris.director.commandiris.param.evacuate_world")
@@ -596,6 +914,54 @@ public class CommandIris implements DirectorExecutor {
return worldDirectory.exists() && worldDirectory.isDirectory();
}
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
Set<String> options = new LinkedHashSet<>();
for (World world : Bukkit.getWorlds()) {
if (IrisToolbelt.isIrisWorld(world)) {
options.add(IrisWorldStorage.logicalName(world));
}
}
for (String identity : IrisWorlds.get().getWorlds().keySet()) {
try {
options.add(IrisWorldStorage.logicalName(WorldIdentity.parse(identity)));
} catch (IllegalArgumentException ignored) {
}
}
File namespace = new File(IrisWorldStorage.levelRoot(), "dimensions/iris");
File[] diskWorlds = namespace.listFiles(File::isDirectory);
if (diskWorlds != null) {
for (File diskWorld : diskWorlds) {
if (!Files.isSymbolicLink(diskWorld.toPath())
&& diskWorld.getName().matches("[a-z0-9_-]+")) {
options.add(diskWorld.getName());
}
}
}
return new KList<>(options);
}
@Override
public String toString(String value) {
return value == null ? "" : value;
}
@Override
public String parse(String in, boolean force) throws DirectorParsingException {
if (in == null || in.isBlank()) {
throw new DirectorParsingException("World identifier cannot be empty");
}
return in.trim();
}
@Override
public boolean supports(Class<?> type) {
return type == String.class;
}
}
public static class PackDimensionTypeHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
@@ -603,28 +969,21 @@ public class CommandIris implements DirectorExecutor {
options.add("default");
File packsFolder = Iris.instance.getDataFolder("packs");
File[] packs = packsFolder.listFiles();
if (packs != null) {
for (File pack : packs) {
if (pack == null || !pack.isDirectory()) {
continue;
}
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
options.add(pack.getName());
options.add(pack.getName());
try {
IrisData data = IrisData.get(pack);
for (String key : data.getDimensionLoader().getPossibleKeys()) {
options.add(key);
options.add(pack.getName() + ":" + key);
}
} catch (Throwable ex) {
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
pack.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
try {
IrisData data = IrisData.get(pack);
for (String key : data.getDimensionLoader().getPossibleKeys()) {
options.add(key);
options.add(pack.getName() + ":" + key);
}
} catch (Throwable ex) {
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
pack.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
}
}
@@ -650,4 +1009,32 @@ public class CommandIris implements DirectorExecutor {
return type == String.class;
}
}
static final class MainWorldPublication implements AutoCloseable {
private final Path target;
private boolean committed;
private boolean closed;
MainWorldPublication(Path target) {
this.target = target;
}
void commit() {
if (closed) {
throw new IllegalStateException("Main-world publication is already closed.");
}
committed = true;
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
if (!committed) {
AtomicDirectoryPublisher.deleteTree(target);
}
}
}
}
@@ -22,6 +22,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.link.WorldEditLink;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.runtime.ObjectStudioActivation;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.core.service.ObjectSVC;
@@ -73,7 +74,6 @@ import java.io.File;
import java.io.IOException;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
@@ -109,25 +109,20 @@ public class CommandObject implements DirectorExecutor {
sources.put(data.getDataFolder().getName(), data);
} else {
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
File[] packs = workspace == null ? null : workspace.listFiles();
if (packs != null) {
Arrays.sort(packs, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER));
for (File pack : packs) {
if (!pack.isDirectory()) continue;
File dimensionsDir = new File(pack, "dimensions");
if (!dimensionsDir.isDirectory()) continue;
IrisData data = IrisData.get(pack);
String[] keys = data.getObjectLoader().getPossibleKeys();
if (keys == null || keys.length == 0) continue;
sources.put(pack.getName(), data);
if (hostDimension == null) {
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
if (dimFiles != null && dimFiles.length > 0) {
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
if (loaded != null) {
hostDimension = loaded;
}
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(workspace)) {
File dimensionsDir = new File(pack, "dimensions");
if (!dimensionsDir.isDirectory()) continue;
IrisData data = IrisData.get(pack);
String[] keys = data.getObjectLoader().getPossibleKeys();
if (keys == null || keys.length == 0) continue;
sources.put(pack.getName(), data);
if (hostDimension == null) {
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
if (dimFiles != null && dimFiles.length > 0) {
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
if (loaded != null) {
hostDimension = loaded;
}
}
}
@@ -394,11 +389,7 @@ public class CommandObject implements DirectorExecutor {
private static List<TreePlausibilizeBatch.Target> resolveFromPacks(String target) {
List<TreePlausibilizeBatch.Target> out = new ArrayList<>();
File packsFolder = Iris.instance.getDataFolder("packs");
File[] packs = packsFolder.listFiles(File::isDirectory);
if (packs == null) {
return out;
}
for (File pack : packs) {
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
File objectsRoot = new File(pack, "objects");
if (!objectsRoot.isDirectory()) {
continue;
@@ -52,8 +52,8 @@ public class CommandPack implements DirectorExecutor {
}
if (pack == null || pack.isBlank()) {
File[] dirs = packsRoot.listFiles(File::isDirectory);
if (dirs == null || dirs.length == 0) {
List<File> dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (dirs.isEmpty()) {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE));
return;
}
@@ -64,7 +64,7 @@ public class CommandPack implements DirectorExecutor {
broken++;
}
}
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.length))));
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.size()))));
return;
}
@@ -153,6 +153,11 @@ public class CommandStructure implements DirectorExecutor {
}
}
}
for (String placedKey : IrisStructureLocator.placedKeys(engine)) {
if (!structureKeys.contains(placedKey)) {
structureKeys.add(placedKey);
}
}
VolmitSender commandSender = sender();
Player target = senderIsPlayer ? player() : null;
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STRUCTURE_VERIFYING_STRUCTURES_FROM_WITHIN_CHUNKS, MessageArgument.untrusted("value", world.getName()), MessageArgument.untrusted("value2", center.getBlockX()), MessageArgument.untrusted("value3", center.getBlockZ()), MessageArgument.untrusted("searchRadius", searchRadius)));
@@ -169,7 +174,8 @@ public class CommandStructure implements DirectorExecutor {
for (String keyName : structureKeys) {
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false);
decisions.put(keyName, decision);
requiresNativeReachability |= decision.generate();
requiresNativeReachability |= !IrisStructureLocator.isPlaced(engine, keyName)
&& decision.generate();
}
Set<String> reachable = Set.of();
if (requiresNativeReachability) {
@@ -190,7 +196,7 @@ public class CommandStructure implements DirectorExecutor {
int errors = 0;
for (String keyName : structureKeys) {
IrisNativeStructureDecision decision = decisions.get(keyName);
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
if (IrisStructureLocator.isPlaced(engine, keyName)) {
try {
IrisStructureLocator.LocateResult result =
IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius);
@@ -205,7 +211,7 @@ public class CommandStructure implements DirectorExecutor {
continue;
}
located++;
messages.add(C.AQUA + "[iris] " + C.WHITE + keyName + C.GREEN + " @ "
messages.add(C.AQUA + "[iris-planned] " + C.WHITE + keyName + C.GREEN + " @ "
+ result.originX() + "," + result.baseY() + "," + result.originZ());
} catch (Throwable error) {
errors++;
@@ -230,7 +236,7 @@ public class CommandStructure implements DirectorExecutor {
nativeEligible++;
messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName);
}
messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, "
messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placement plans found, "
+ C.WHITE + nativeEligible + C.GREEN + " native structures eligible, "
+ C.WHITE + disabled + C.GREEN + " disabled by policy, "
+ C.WHITE + unreachable + C.GREEN + " biome-unreachable, "
@@ -229,29 +229,21 @@ public class CommandStudio implements DirectorExecutor {
}
}
@Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+", sync = true)
@Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+")
public void create(
@Param(description = "The name of this new Iris Project.", descriptionKey = "iris.director.commandstudio.param.name_this_new_iris_project", defaultValue = "studio")
String name,
@Param(
description = "Copy the contents of an existing project in your packs folder and use it as a template in this new project.", descriptionKey = "iris.director.commandstudio.param.copy_contents_existing_project_your_packs_folder_use_it_as_template_this",
defaultValue = "null",
contextual = true,
customHandler = NullableDimensionHandler.class
)
IrisDimension template) {
String projectName = name;
if (name.equals("studio")) {
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
int suffix = 2;
while (new File(workspace, projectName).exists()) {
projectName = "studio" + suffix++;
}
}
if (template != null) {
Iris.service(StudioSVC.class).create(sender(), projectName, template.getLoadKey());
Iris.service(StudioSVC.class).create(sender(), name, template);
} else {
Iris.service(StudioSVC.class).create(sender(), projectName);
Iris.service(StudioSVC.class).create(sender(), name);
}
}
@@ -0,0 +1,362 @@
package art.arcane.iris.core;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class BukkitWorldReconcilerTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
LifecycleOperationCoordinator coordinator = coordinator();
FakeBackend backend = new FakeBackend();
CompletableFuture<World> creation = new CompletableFuture<>();
backend.creation = creation;
World exactWorld = world(backend.worldKey);
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler.loadWorld(configuration, backend.worldKey.toString());
assertFalse(load.isDone());
assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)
.orElseThrow()
.kind());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
assertThrows(LifecycleOperationCoordinator.BusyException.class,
() -> coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
backend.worldKey.toString()));
backend.loaded = Optional.of(exactWorld);
creation.complete(exactWorld);
BukkitWorldReconciler.LoadResult result = load.join();
assertTrue(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.LOADED, result.status());
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
assertFalse(result.rollbackAttempted());
assertTrue(coordinator.isIdle());
}
@Test
public void failedCreationRollsBackOnlyNewRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.CREATE_FAILED, result.status());
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
assertTrue(result.rollbackAttempted());
assertTrue(result.rollbackSucceeded());
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
}
@Test
public void failedCreationPreservesPreexistingMatchingRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
FakeBackend backend = new FakeBackend();
backend.configuredSeed = 1337L;
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, result.registration());
assertFalse(result.rollbackAttempted());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
assertEquals(1337L, YamlConfiguration.loadConfiguration(configuration)
.getLong("worlds.probe.seed"));
}
@Test
public void rollbackDoesNotRemoveARegistrationChangedDuringCreation() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
CompletableFuture<World> creation = new CompletableFuture<>();
backend.creation = creation;
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler
.loadWorld(configuration, backend.worldKey.toString());
YamlConfiguration changed = YamlConfiguration.loadConfiguration(configuration);
changed.set("worlds.probe.generator", "Other:generator");
changed.save(configuration);
creation.completeExceptionally(new IllegalStateException("create failed"));
BukkitWorldReconciler.LoadResult result = load.join();
assertTrue(result.rollbackAttempted());
assertFalse(result.rollbackSucceeded());
assertEquals("Other:generator", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
}
@Test
public void mismatchedCreatedWorldIsNotReportedAsLoaded() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.completedFuture(world(new NamespacedKey("iris", "other")));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertFalse(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
assertTrue(result.rollbackSucceeded());
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
}
@Test
public void mismatchedResolvedWorldIsNotReportedAsLoaded() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
World exactCreatedWorld = world(backend.worldKey);
backend.creation = CompletableFuture.completedFuture(exactCreatedWorld);
backend.loaded = Optional.of(world(new NamespacedKey("iris", "other")));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertFalse(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
assertTrue(result.rollbackSucceeded());
}
@Test
public void exactNonIrisWorldIsAnIdentityConflict() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.loaded = Optional.of(world(backend.worldKey));
backend.irisWorld = false;
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_CONFLICT, result.status());
assertEquals(0, backend.createCount.get());
assertTrue(result.rollbackSucceeded());
}
@Test
public void unresolvedDimensionAndBusyLifecycleDoNotTouchConfiguration() throws Exception {
File unresolvedConfiguration = temporaryFolder.newFile("unresolved.yml");
FakeBackend unresolvedBackend = new FakeBackend();
unresolvedBackend.dimensionResolution = BukkitWorldReconciler.DimensionResolution.failed(
new IllegalStateException("ambiguous"));
BukkitWorldReconciler unresolved = new BukkitWorldReconciler(unresolvedBackend, coordinator());
BukkitWorldReconciler.LoadResult unresolvedResult = unresolved
.loadWorld(unresolvedConfiguration, unresolvedBackend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, unresolvedResult.status());
assertEquals(0L, unresolvedConfiguration.length());
File busyConfiguration = temporaryFolder.newFile("busy.yml");
LifecycleOperationCoordinator coordinator = coordinator();
LifecycleOperationCoordinator.Lease removal = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
"iris:other");
FakeBackend busyBackend = new FakeBackend();
BukkitWorldReconciler busy = new BukkitWorldReconciler(busyBackend, coordinator);
try {
BukkitWorldReconciler.LoadResult busyResult = busy.loadWorld(busyConfiguration, busyBackend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, busyResult.status());
assertEquals(0, busyBackend.createCount.get());
assertEquals(0L, busyConfiguration.length());
} finally {
removal.close();
}
}
@Test
public void pendingRestartRefusesWorldLoadBeforeRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
LifecycleOperationCoordinator coordinator = coordinator();
assertTrue(coordinator.quiesceForRestart(() -> {
}));
FakeBackend backend = new FakeBackend();
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, result.status());
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
((LifecycleOperationCoordinator.BusyException) result.failure()).operationKind());
assertEquals(0, backend.createCount.get());
assertEquals(0L, configuration.length());
}
@Test
public void terminalCreateTimeoutWinsOverLateWorldCompletion() {
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
CompletableFuture<World> source = new CompletableFuture<>();
AtomicInteger timeoutActions = new AtomicInteger();
CompletableFuture<World> guarded = BukkitWorldReconciler.guardCreateCompletion(
source,
worldKey,
1L,
timeoutActions::incrementAndGet);
CompletionException failure = assertThrows(CompletionException.class, guarded::join);
assertTrue(failure.getCause() instanceof TimeoutException);
assertEquals(1, timeoutActions.get());
source.complete(world(worldKey));
assertTrue(guarded.isCompletedExceptionally());
}
@Test
public void timedOutCreationPreservesNewRegistrationForRestartReconciliation() throws Exception {
File configuration = temporaryFolder.newFile("timeout.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.failedFuture(new TimeoutException("create timed out"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED, result.status());
assertFalse(result.rollbackAttempted());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
}
private static LifecycleOperationCoordinator coordinator() throws Exception {
Constructor<LifecycleOperationCoordinator> constructor = LifecycleOperationCoordinator.class
.getDeclaredConstructor();
constructor.setAccessible(true);
return constructor.newInstance();
}
private static World world(NamespacedKey worldKey) {
return (World) Proxy.newProxyInstance(
BukkitWorldReconcilerTest.class.getClassLoader(),
new Class[]{World.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getKey" -> worldKey;
case "getName" -> worldKey.getKey();
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
case "toString" -> worldKey.toString();
default -> defaultValue(method.getReturnType());
});
}
private static Object defaultValue(Class<?> type) {
if (!type.isPrimitive()) {
return null;
}
if (type == boolean.class) {
return false;
}
if (type == char.class) {
return '\0';
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
throw new IllegalStateException("Unsupported primitive type: " + type);
}
private static final class FakeBackend implements BukkitWorldReconciler.Backend {
private final NamespacedKey worldKey;
private final AtomicInteger createCount;
private CompletableFuture<World> creation;
private Optional<World> loaded;
private boolean irisWorld;
private Long configuredSeed;
private BukkitWorldReconciler.DimensionResolution dimensionResolution;
private FakeBackend() {
worldKey = new NamespacedKey("iris", "probe");
createCount = new AtomicInteger();
creation = CompletableFuture.completedFuture(null);
loaded = Optional.empty();
irisWorld = true;
configuredSeed = null;
dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld");
}
@Override
public Map<String, String> configuredWorlds() {
return Map.of("probe", "overworld");
}
@Override
public Long configuredSeed(String worldName) {
return configuredSeed;
}
@Override
public Optional<World> loadedWorld(NamespacedKey requestedWorldKey) {
return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty();
}
@Override
public CompletableFuture<World> createWorld(NamespacedKey requestedWorldKey, String dimension, Long seed) {
createCount.incrementAndGet();
return creation;
}
@Override
public boolean isIrisWorld(World world) {
return irisWorld;
}
@Override
public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) {
return dimensionResolution;
}
}
}
@@ -0,0 +1,176 @@
package art.arcane.iris.core;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class PendingWorldDeleteQueueTest {
private static final String QUARANTINE_NAME = ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f45";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void acceptsOnlyCanonicalManagedNamesAndStrictQuarantines() {
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("alpha", "world"));
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("iris:alpha", "world"));
assertEquals(QUARANTINE_NAME, PendingWorldDeleteQueue.normalizeQueueEntry(QUARANTINE_NAME, "world"));
for (String rejected : List.of(
"world",
"world_nether",
"world_the_end",
"overworld",
"the_nether",
"the_end",
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
"Alpha",
"alpha beta",
"../alpha",
"alpha/beta",
".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f4",
".iris-delete-6A4FD7FD-8E75-4F2F-B9FA-523B90C41F45"
)) {
assertNull(rejected, PendingWorldDeleteQueue.normalizeQueueEntry(rejected, "world"));
}
}
@Test
public void loadFiltersUnsafeEntriesAndCanonicalizesDuplicates() throws IOException {
File queueFile = temporaryFolder.newFile("pending-world-deletes.txt");
Files.writeString(
queueFile.toPath(),
String.join("\n", "alpha", "iris:alpha", "world", "../escape", QUARANTINE_NAME),
StandardCharsets.UTF_8
);
LinkedHashMap<String, String> queue = PendingWorldDeleteQueue.loadPendingWorldDeleteMap(queueFile, "world");
assertEquals(List.of("alpha", QUARANTINE_NAME), List.copyOf(queue.values()));
}
@Test
public void queueFileReplacementIsCompleteAndLeavesNoTemporaryFile() throws IOException {
File queueFile = new File(temporaryFolder.getRoot(), "state/pending-world-deletes.txt");
LinkedHashMap<String, String> first = new LinkedHashMap<>();
first.put("alpha", "alpha");
first.put(QUARANTINE_NAME, QUARANTINE_NAME);
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, first);
assertEquals("alpha\n" + QUARANTINE_NAME + "\n", Files.readString(queueFile.toPath()));
LinkedHashMap<String, String> replacement = new LinkedHashMap<>();
replacement.put("beta", "beta");
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, replacement);
assertEquals("beta\n", Files.readString(queueFile.toPath()));
try (Stream<Path> files = Files.list(queueFile.toPath().getParent())) {
assertFalse(files.anyMatch(path -> path.getFileName().toString().contains(".tmp-")));
}
}
@Test
public void emptyQueueIsDurablyRepresentedByAnEmptyFile() throws IOException {
File queueFile = new File(temporaryFolder.getRoot(), "pending-world-deletes.txt");
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>());
assertTrue(queueFile.isFile());
assertEquals(0L, queueFile.length());
}
@Test
public void writeFailuresPropagateToTheCaller() throws IOException {
File parentFile = temporaryFolder.newFile("not-a-directory");
File queueFile = new File(parentFile, "pending-world-deletes.txt");
assertThrows(
IOException.class,
() -> PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>())
);
}
@Test
public void discoversOnlyDirectNonSymlinkStartupDirectories() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
Path irisNamespace = levelRoot.toPath().resolve("dimensions/iris");
Files.createDirectories(irisNamespace);
Files.createDirectory(irisNamespace.resolve(QUARANTINE_NAME));
Files.createDirectory(irisNamespace.resolve(".iris-delete-not-a-uuid"));
String transientName = "iris-45ba411e-bf7c-493a-bf41-aa020754990b";
Files.createDirectory(irisNamespace.resolve(transientName + "_nether"));
Path nested = irisNamespace.resolve("ordinary/nested");
Files.createDirectories(nested);
Files.createDirectory(nested.resolve(".iris-delete-f70b8c21-9174-43a2-b7b7-a84fc0b2fe4a"));
Path symlinkTarget = temporaryFolder.newFolder("quarantine-target").toPath();
Files.createSymbolicLink(
irisNamespace.resolve(".iris-delete-b8c7ff2d-2efd-410d-b228-6da5d5a46c36"),
symlinkTarget
);
LinkedHashSet<String> discovered = PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot);
assertEquals(Set.of(QUARANTINE_NAME, transientName), discovered);
}
@Test
public void refusesSymlinkedIrisNamespace() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
Path dimensions = levelRoot.toPath().resolve("dimensions");
Files.createDirectories(dimensions);
Path external = temporaryFolder.newFolder("external-iris").toPath();
Files.createSymbolicLink(dimensions.resolve("iris"), external);
assertThrows(
IOException.class,
() -> PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot)
);
}
@Test
public void quarantineEntriesResolveToOnlyTheirExactDirectory() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
List<Path> paths = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, QUARANTINE_NAME);
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME).toAbsolutePath()
), paths);
}
@Test
public void exactLogicalEntriesDoNotExpandIntoDimensionFamilies() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
List<Path> exact = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha");
List<Path> family = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "alpha");
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath()
), exact);
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath(),
levelRoot.toPath().resolve("dimensions/iris/alpha_nether").toAbsolutePath(),
levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath()
), family);
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.io.File;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandIrisLoadWorldContractTest {
@Test
public void commandUsesTypedAsyncReconciliationBeforeReportingSuccess() throws Exception {
Method reconciliation = BukkitWorldReconciler.class.getDeclaredMethod(
"loadWorld",
File.class,
String.class);
assertEquals(CompletableFuture.class, reconciliation.getReturnType());
Method commandMethod = CommandIris.class.getDeclaredMethod("loadWorld", String.class);
Parameter worldParameter = commandMethod.getParameters()[0];
assertEquals(CommandIris.ManagedWorldNameHandler.class,
worldParameter.getAnnotation(Param.class).customHandler());
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
String command = source.substring(
source.indexOf("public void loadWorld("),
source.indexOf("private void reportLoadWorldResult("));
String reporter = source.substring(
source.indexOf("private void reportLoadWorldResult("),
source.indexOf("@Director(description = \"Evacuate an iris world\""));
assertTrue(command.contains("IrisWorldStorage.managedKeyFromName(world)"));
assertTrue(command.contains(".loadWorld(BUKKIT_YML, worldKey.toString())"));
assertTrue(command.contains(".whenComplete((result, failure) ->"));
assertFalse(command.contains("checkForBukkitWorlds"));
assertFalse(command.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
assertTrue(reporter.contains("if (result.succeeded())"));
assertTrue(reporter.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
}
}
@@ -0,0 +1,93 @@
package art.arcane.iris.core.commands;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisMainWorldPromotionTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException {
PromotionPaths paths = createPromotionPaths("existing-target");
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("sentinel.txt"), "keep");
assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
));
assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt")));
assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("rollback-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat")));
assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
}
assertFalse(Files.exists(paths.target()));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("committed-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
publication.commit();
}
assertEquals("map", Files.readString(paths.target().resolve("data/map.dat")));
assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
private PromotionPaths createPromotionPaths(String targetName) throws IOException {
Path root = temporaryFolder.newFolder(targetName + "-root").toPath();
Path current = root.resolve("world");
Path sourceDimension = current.resolve("dimensions/iris/" + targetName);
Path target = root.resolve(targetName);
Files.createDirectories(current.resolve("data"));
Files.writeString(current.resolve("data/map.dat"), "map");
Files.createDirectories(sourceDimension.resolve("region"));
Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region");
return new PromotionPaths(root, current, sourceDimension, target);
}
private boolean hasPromotionStage(Path root, String targetName) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-"));
}
}
private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) {
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Param;
import org.bukkit.World;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class CommandIrisRemovalCommandContractTest {
@Test
public void removalAcceptsDiskOnlyNamesAndUsesManagedSuggestions() throws NoSuchMethodException {
Method command = CommandIris.class.getDeclaredMethod("remove", String.class, boolean.class);
Parameter worldParameter = command.getParameters()[0];
Param world = worldParameter.getAnnotation(Param.class);
assertEquals(CommandIris.ManagedWorldNameHandler.class, world.customHandler());
assertFalse(Arrays.stream(CommandIris.class.getDeclaredMethods())
.anyMatch(method -> method.getName().equals("remove")
&& method.getParameterCount() == 2
&& method.getParameterTypes()[0] == World.class));
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.core.commands;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class CommandIrisUnloadContractTest {
@Test
public void unloadAwaitsEvacuationAndHasATerminalLifecycleTimeout() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
String command = source.substring(
source.indexOf("public void unloadWorld("),
source.indexOf("private void reportUnloadResult("));
assertTrue(command.contains("IrisToolbelt.evacuateAsync(world)"));
assertTrue(command.indexOf("IrisToolbelt.evacuateAsync(world)")
< command.indexOf("WorldLifecycleService.get().unloadAsync(world, true)"));
assertTrue(command.contains("guardUnloadCompletion(sequence, terminalTimeout, world.getName())"));
assertTrue(command.contains("ServerConfigurator.restart(\"World unload timed out"));
assertTrue(command.indexOf("ServerConfigurator.restart(\"World unload timed out")
< command.indexOf("guarded.completeExceptionally(timeout)"));
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class CommandStudioCreationContractTest {
@Test
public void createRunsAsynchronouslyAndTemplateIsOptional() throws NoSuchMethodException {
Method command = CommandStudio.class.getDeclaredMethod("create", String.class, IrisDimension.class);
Director director = command.getAnnotation(Director.class);
Parameter templateParameter = command.getParameters()[1];
Param template = templateParameter.getAnnotation(Param.class);
assertFalse(director.sync());
assertEquals("null", template.defaultValue());
assertEquals(NullableDimensionHandler.class, template.customHandler());
}
}
@@ -25,7 +25,7 @@ public class IrisStructureLocateCommandContractTest {
}
@Test
public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException {
public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource")));
int methodStart = source.indexOf("public void structure(");
int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart);
@@ -34,16 +34,19 @@ public class IrisStructureLocateCommandContractTest {
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution);
int replacementCheck = method.indexOf(
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck);
int genericIrisLookup = method.indexOf(
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementLocate);
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck);
int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup);
int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup);
assertTrue(nativeResolution >= 0);
assertTrue(policyResolution > nativeResolution);
assertTrue(replacementCheck > policyResolution);
assertTrue(replacementLocate > replacementCheck);
assertTrue(genericIrisLookup > replacementLocate);
assertTrue(genericIrisLookup > replacementCheck);
assertTrue(replacementLocate > genericIrisLookup);
assertTrue(nativeLocate > policyResolution);
assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement"));
assertTrue(method.contains("!replacementLocate && !explicitNativePlacement"));
assertTrue(method.contains("&& !StructureReachability.isReachable"));
assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS"));
assertFalse(method.contains("NativeStructureLocateCapability"));
}
@@ -82,7 +85,8 @@ public class IrisStructureLocateCommandContractTest {
int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart);
String method = source.substring(methodStart, methodEnd);
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)");
int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution);
int nativeRequirement = method.indexOf(
"requiresNativeReachability |= !IrisStructureLocator.isPlaced", policyResolution);
int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement);
int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard);