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
@@ -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);
}
}