mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
Vwop
This commit is contained in:
@@ -58,6 +58,7 @@ tasks.named('jar', Jar).configure {
|
||||
tasks.named('test').configure {
|
||||
systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath)
|
||||
systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath)
|
||||
systemProperty('iris.commandIrisSource', file('src/main/java/art/arcane/iris/core/commands/CommandIris.java').absolutePath)
|
||||
systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath)
|
||||
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
|
||||
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
|
||||
|
||||
@@ -36,6 +36,7 @@ import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
|
||||
import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.api.terrain.IrisTerrainService;
|
||||
import art.arcane.iris.core.link.IrisPapiInstaller;
|
||||
@@ -571,7 +572,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks());
|
||||
IrisServices.register(EngineWorldManagerProvider.class,
|
||||
(EngineWorldManagerProvider) IrisWorldManager::new);
|
||||
IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) pendingWorldDeletes::queueWorldDeletionOnStartup);
|
||||
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
|
||||
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
|
||||
settingsHotloadWatch = watch;
|
||||
configHotloadEngine = new ConfigHotloadEngine(
|
||||
|
||||
+674
-53
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -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());
|
||||
|
||||
+399
-145
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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"
|
||||
|
||||
+592
-205
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-25
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -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, "
|
||||
|
||||
+4
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BukkitWorldReconcilerTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
FakeBackend backend = new FakeBackend();
|
||||
CompletableFuture<World> creation = new CompletableFuture<>();
|
||||
backend.creation = creation;
|
||||
World exactWorld = world(backend.worldKey);
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
|
||||
|
||||
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler.loadWorld(configuration, backend.worldKey.toString());
|
||||
|
||||
assertFalse(load.isDone());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
|
||||
coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)
|
||||
.orElseThrow()
|
||||
.kind());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
assertThrows(LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
backend.worldKey.toString()));
|
||||
|
||||
backend.loaded = Optional.of(exactWorld);
|
||||
creation.complete(exactWorld);
|
||||
BukkitWorldReconciler.LoadResult result = load.join();
|
||||
|
||||
assertTrue(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.LOADED, result.status());
|
||||
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCreationRollsBackOnlyNewRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.CREATE_FAILED, result.status());
|
||||
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
|
||||
assertTrue(result.rollbackAttempted());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCreationPreservesPreexistingMatchingRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.configuredSeed = 1337L;
|
||||
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, result.registration());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
assertEquals(1337L, YamlConfiguration.loadConfiguration(configuration)
|
||||
.getLong("worlds.probe.seed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackDoesNotRemoveARegistrationChangedDuringCreation() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
CompletableFuture<World> creation = new CompletableFuture<>();
|
||||
backend.creation = creation;
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString());
|
||||
YamlConfiguration changed = YamlConfiguration.loadConfiguration(configuration);
|
||||
changed.set("worlds.probe.generator", "Other:generator");
|
||||
changed.save(configuration);
|
||||
|
||||
creation.completeExceptionally(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler.LoadResult result = load.join();
|
||||
|
||||
assertTrue(result.rollbackAttempted());
|
||||
assertFalse(result.rollbackSucceeded());
|
||||
assertEquals("Other:generator", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mismatchedCreatedWorldIsNotReportedAsLoaded() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.completedFuture(world(new NamespacedKey("iris", "other")));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertFalse(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mismatchedResolvedWorldIsNotReportedAsLoaded() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
World exactCreatedWorld = world(backend.worldKey);
|
||||
backend.creation = CompletableFuture.completedFuture(exactCreatedWorld);
|
||||
backend.loaded = Optional.of(world(new NamespacedKey("iris", "other")));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertFalse(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactNonIrisWorldIsAnIdentityConflict() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.loaded = Optional.of(world(backend.worldKey));
|
||||
backend.irisWorld = false;
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_CONFLICT, result.status());
|
||||
assertEquals(0, backend.createCount.get());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedDimensionAndBusyLifecycleDoNotTouchConfiguration() throws Exception {
|
||||
File unresolvedConfiguration = temporaryFolder.newFile("unresolved.yml");
|
||||
FakeBackend unresolvedBackend = new FakeBackend();
|
||||
unresolvedBackend.dimensionResolution = BukkitWorldReconciler.DimensionResolution.failed(
|
||||
new IllegalStateException("ambiguous"));
|
||||
BukkitWorldReconciler unresolved = new BukkitWorldReconciler(unresolvedBackend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult unresolvedResult = unresolved
|
||||
.loadWorld(unresolvedConfiguration, unresolvedBackend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, unresolvedResult.status());
|
||||
assertEquals(0L, unresolvedConfiguration.length());
|
||||
|
||||
File busyConfiguration = temporaryFolder.newFile("busy.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
LifecycleOperationCoordinator.Lease removal = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"iris:other");
|
||||
FakeBackend busyBackend = new FakeBackend();
|
||||
BukkitWorldReconciler busy = new BukkitWorldReconciler(busyBackend, coordinator);
|
||||
try {
|
||||
BukkitWorldReconciler.LoadResult busyResult = busy.loadWorld(busyConfiguration, busyBackend.worldKey.toString()).join();
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, busyResult.status());
|
||||
assertEquals(0, busyBackend.createCount.get());
|
||||
assertEquals(0L, busyConfiguration.length());
|
||||
} finally {
|
||||
removal.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingRestartRefusesWorldLoadBeforeRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
assertTrue(coordinator.quiesceForRestart(() -> {
|
||||
}));
|
||||
FakeBackend backend = new FakeBackend();
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, result.status());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
|
||||
((LifecycleOperationCoordinator.BusyException) result.failure()).operationKind());
|
||||
assertEquals(0, backend.createCount.get());
|
||||
assertEquals(0L, configuration.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalCreateTimeoutWinsOverLateWorldCompletion() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
|
||||
CompletableFuture<World> source = new CompletableFuture<>();
|
||||
AtomicInteger timeoutActions = new AtomicInteger();
|
||||
|
||||
CompletableFuture<World> guarded = BukkitWorldReconciler.guardCreateCompletion(
|
||||
source,
|
||||
worldKey,
|
||||
1L,
|
||||
timeoutActions::incrementAndGet);
|
||||
|
||||
CompletionException failure = assertThrows(CompletionException.class, guarded::join);
|
||||
assertTrue(failure.getCause() instanceof TimeoutException);
|
||||
assertEquals(1, timeoutActions.get());
|
||||
source.complete(world(worldKey));
|
||||
assertTrue(guarded.isCompletedExceptionally());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedOutCreationPreservesNewRegistrationForRestartReconciliation() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("timeout.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.failedFuture(new TimeoutException("create timed out"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED, result.status());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
}
|
||||
|
||||
private static LifecycleOperationCoordinator coordinator() throws Exception {
|
||||
Constructor<LifecycleOperationCoordinator> constructor = LifecycleOperationCoordinator.class
|
||||
.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance();
|
||||
}
|
||||
|
||||
private static World world(NamespacedKey worldKey) {
|
||||
return (World) Proxy.newProxyInstance(
|
||||
BukkitWorldReconcilerTest.class.getClassLoader(),
|
||||
new Class[]{World.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getKey" -> worldKey;
|
||||
case "getName" -> worldKey.getKey();
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == arguments[0];
|
||||
case "toString" -> worldKey.toString();
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
throw new IllegalStateException("Unsupported primitive type: " + type);
|
||||
}
|
||||
|
||||
private static final class FakeBackend implements BukkitWorldReconciler.Backend {
|
||||
private final NamespacedKey worldKey;
|
||||
private final AtomicInteger createCount;
|
||||
private CompletableFuture<World> creation;
|
||||
private Optional<World> loaded;
|
||||
private boolean irisWorld;
|
||||
private Long configuredSeed;
|
||||
private BukkitWorldReconciler.DimensionResolution dimensionResolution;
|
||||
|
||||
private FakeBackend() {
|
||||
worldKey = new NamespacedKey("iris", "probe");
|
||||
createCount = new AtomicInteger();
|
||||
creation = CompletableFuture.completedFuture(null);
|
||||
loaded = Optional.empty();
|
||||
irisWorld = true;
|
||||
configuredSeed = null;
|
||||
dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> configuredWorlds() {
|
||||
return Map.of("probe", "overworld");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long configuredSeed(String worldName) {
|
||||
return configuredSeed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<World> loadedWorld(NamespacedKey requestedWorldKey) {
|
||||
return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey requestedWorldKey, String dimension, Long seed) {
|
||||
createCount.incrementAndGet();
|
||||
return creation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIrisWorld(World world) {
|
||||
return irisWorld;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) {
|
||||
return dimensionResolution;
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PendingWorldDeleteQueueTest {
|
||||
private static final String QUARANTINE_NAME = ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f45";
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsOnlyCanonicalManagedNamesAndStrictQuarantines() {
|
||||
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("alpha", "world"));
|
||||
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("iris:alpha", "world"));
|
||||
assertEquals(QUARANTINE_NAME, PendingWorldDeleteQueue.normalizeQueueEntry(QUARANTINE_NAME, "world"));
|
||||
|
||||
for (String rejected : List.of(
|
||||
"world",
|
||||
"world_nether",
|
||||
"world_the_end",
|
||||
"overworld",
|
||||
"the_nether",
|
||||
"the_end",
|
||||
"minecraft:overworld",
|
||||
"minecraft:the_nether",
|
||||
"minecraft:the_end",
|
||||
"Alpha",
|
||||
"alpha beta",
|
||||
"../alpha",
|
||||
"alpha/beta",
|
||||
".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f4",
|
||||
".iris-delete-6A4FD7FD-8E75-4F2F-B9FA-523B90C41F45"
|
||||
)) {
|
||||
assertNull(rejected, PendingWorldDeleteQueue.normalizeQueueEntry(rejected, "world"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadFiltersUnsafeEntriesAndCanonicalizesDuplicates() throws IOException {
|
||||
File queueFile = temporaryFolder.newFile("pending-world-deletes.txt");
|
||||
Files.writeString(
|
||||
queueFile.toPath(),
|
||||
String.join("\n", "alpha", "iris:alpha", "world", "../escape", QUARANTINE_NAME),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
LinkedHashMap<String, String> queue = PendingWorldDeleteQueue.loadPendingWorldDeleteMap(queueFile, "world");
|
||||
|
||||
assertEquals(List.of("alpha", QUARANTINE_NAME), List.copyOf(queue.values()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueFileReplacementIsCompleteAndLeavesNoTemporaryFile() throws IOException {
|
||||
File queueFile = new File(temporaryFolder.getRoot(), "state/pending-world-deletes.txt");
|
||||
LinkedHashMap<String, String> first = new LinkedHashMap<>();
|
||||
first.put("alpha", "alpha");
|
||||
first.put(QUARANTINE_NAME, QUARANTINE_NAME);
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, first);
|
||||
|
||||
assertEquals("alpha\n" + QUARANTINE_NAME + "\n", Files.readString(queueFile.toPath()));
|
||||
|
||||
LinkedHashMap<String, String> replacement = new LinkedHashMap<>();
|
||||
replacement.put("beta", "beta");
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, replacement);
|
||||
|
||||
assertEquals("beta\n", Files.readString(queueFile.toPath()));
|
||||
try (Stream<Path> files = Files.list(queueFile.toPath().getParent())) {
|
||||
assertFalse(files.anyMatch(path -> path.getFileName().toString().contains(".tmp-")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyQueueIsDurablyRepresentedByAnEmptyFile() throws IOException {
|
||||
File queueFile = new File(temporaryFolder.getRoot(), "pending-world-deletes.txt");
|
||||
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>());
|
||||
|
||||
assertTrue(queueFile.isFile());
|
||||
assertEquals(0L, queueFile.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writeFailuresPropagateToTheCaller() throws IOException {
|
||||
File parentFile = temporaryFolder.newFile("not-a-directory");
|
||||
File queueFile = new File(parentFile, "pending-world-deletes.txt");
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void discoversOnlyDirectNonSymlinkStartupDirectories() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
Path irisNamespace = levelRoot.toPath().resolve("dimensions/iris");
|
||||
Files.createDirectories(irisNamespace);
|
||||
Files.createDirectory(irisNamespace.resolve(QUARANTINE_NAME));
|
||||
Files.createDirectory(irisNamespace.resolve(".iris-delete-not-a-uuid"));
|
||||
String transientName = "iris-45ba411e-bf7c-493a-bf41-aa020754990b";
|
||||
Files.createDirectory(irisNamespace.resolve(transientName + "_nether"));
|
||||
Path nested = irisNamespace.resolve("ordinary/nested");
|
||||
Files.createDirectories(nested);
|
||||
Files.createDirectory(nested.resolve(".iris-delete-f70b8c21-9174-43a2-b7b7-a84fc0b2fe4a"));
|
||||
Path symlinkTarget = temporaryFolder.newFolder("quarantine-target").toPath();
|
||||
Files.createSymbolicLink(
|
||||
irisNamespace.resolve(".iris-delete-b8c7ff2d-2efd-410d-b228-6da5d5a46c36"),
|
||||
symlinkTarget
|
||||
);
|
||||
|
||||
LinkedHashSet<String> discovered = PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot);
|
||||
|
||||
assertEquals(Set.of(QUARANTINE_NAME, transientName), discovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesSymlinkedIrisNamespace() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
Path dimensions = levelRoot.toPath().resolve("dimensions");
|
||||
Files.createDirectories(dimensions);
|
||||
Path external = temporaryFolder.newFolder("external-iris").toPath();
|
||||
Files.createSymbolicLink(dimensions.resolve("iris"), external);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void quarantineEntriesResolveToOnlyTheirExactDirectory() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
|
||||
List<Path> paths = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, QUARANTINE_NAME);
|
||||
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME).toAbsolutePath()
|
||||
), paths);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactLogicalEntriesDoNotExpandIntoDimensionFamilies() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
|
||||
List<Path> exact = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha");
|
||||
List<Path> family = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "alpha");
|
||||
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath()
|
||||
), exact);
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath(),
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha_nether").toAbsolutePath(),
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath()
|
||||
), family);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.BukkitWorldReconciler;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisLoadWorldContractTest {
|
||||
@Test
|
||||
public void commandUsesTypedAsyncReconciliationBeforeReportingSuccess() throws Exception {
|
||||
Method reconciliation = BukkitWorldReconciler.class.getDeclaredMethod(
|
||||
"loadWorld",
|
||||
File.class,
|
||||
String.class);
|
||||
assertEquals(CompletableFuture.class, reconciliation.getReturnType());
|
||||
Method commandMethod = CommandIris.class.getDeclaredMethod("loadWorld", String.class);
|
||||
Parameter worldParameter = commandMethod.getParameters()[0];
|
||||
assertEquals(CommandIris.ManagedWorldNameHandler.class,
|
||||
worldParameter.getAnnotation(Param.class).customHandler());
|
||||
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
|
||||
String command = source.substring(
|
||||
source.indexOf("public void loadWorld("),
|
||||
source.indexOf("private void reportLoadWorldResult("));
|
||||
String reporter = source.substring(
|
||||
source.indexOf("private void reportLoadWorldResult("),
|
||||
source.indexOf("@Director(description = \"Evacuate an iris world\""));
|
||||
|
||||
assertTrue(command.contains("IrisWorldStorage.managedKeyFromName(world)"));
|
||||
assertTrue(command.contains(".loadWorld(BUKKIT_YML, worldKey.toString())"));
|
||||
assertTrue(command.contains(".whenComplete((result, failure) ->"));
|
||||
assertFalse(command.contains("checkForBukkitWorlds"));
|
||||
assertFalse(command.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
|
||||
assertTrue(reporter.contains("if (result.succeeded())"));
|
||||
assertTrue(reporter.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisMainWorldPromotionTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("existing-target");
|
||||
Files.createDirectories(paths.target());
|
||||
Files.writeString(paths.target().resolve("sentinel.txt"), "keep");
|
||||
|
||||
assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
));
|
||||
|
||||
assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt")));
|
||||
assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("rollback-target");
|
||||
|
||||
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
)) {
|
||||
assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat")));
|
||||
assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
}
|
||||
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("committed-target");
|
||||
|
||||
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
)) {
|
||||
publication.commit();
|
||||
}
|
||||
|
||||
assertEquals("map", Files.readString(paths.target().resolve("data/map.dat")));
|
||||
assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
private PromotionPaths createPromotionPaths(String targetName) throws IOException {
|
||||
Path root = temporaryFolder.newFolder(targetName + "-root").toPath();
|
||||
Path current = root.resolve("world");
|
||||
Path sourceDimension = current.resolve("dimensions/iris/" + targetName);
|
||||
Path target = root.resolve(targetName);
|
||||
Files.createDirectories(current.resolve("data"));
|
||||
Files.writeString(current.resolve("data/map.dat"), "map");
|
||||
Files.createDirectories(sourceDimension.resolve("region"));
|
||||
Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region");
|
||||
return new PromotionPaths(root, current, sourceDimension, target);
|
||||
}
|
||||
|
||||
private boolean hasPromotionStage(Path root, String targetName) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-"));
|
||||
}
|
||||
}
|
||||
|
||||
private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) {
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class CommandIrisRemovalCommandContractTest {
|
||||
@Test
|
||||
public void removalAcceptsDiskOnlyNamesAndUsesManagedSuggestions() throws NoSuchMethodException {
|
||||
Method command = CommandIris.class.getDeclaredMethod("remove", String.class, boolean.class);
|
||||
Parameter worldParameter = command.getParameters()[0];
|
||||
Param world = worldParameter.getAnnotation(Param.class);
|
||||
|
||||
assertEquals(CommandIris.ManagedWorldNameHandler.class, world.customHandler());
|
||||
assertFalse(Arrays.stream(CommandIris.class.getDeclaredMethods())
|
||||
.anyMatch(method -> method.getName().equals("remove")
|
||||
&& method.getParameterCount() == 2
|
||||
&& method.getParameterTypes()[0] == World.class));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisUnloadContractTest {
|
||||
@Test
|
||||
public void unloadAwaitsEvacuationAndHasATerminalLifecycleTimeout() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
|
||||
String command = source.substring(
|
||||
source.indexOf("public void unloadWorld("),
|
||||
source.indexOf("private void reportUnloadResult("));
|
||||
|
||||
assertTrue(command.contains("IrisToolbelt.evacuateAsync(world)"));
|
||||
assertTrue(command.indexOf("IrisToolbelt.evacuateAsync(world)")
|
||||
< command.indexOf("WorldLifecycleService.get().unloadAsync(world, true)"));
|
||||
assertTrue(command.contains("guardUnloadCompletion(sequence, terminalTimeout, world.getName())"));
|
||||
assertTrue(command.contains("ServerConfigurator.restart(\"World unload timed out"));
|
||||
assertTrue(command.indexOf("ServerConfigurator.restart(\"World unload timed out")
|
||||
< command.indexOf("guarded.completeExceptionally(timeout)"));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class CommandStudioCreationContractTest {
|
||||
@Test
|
||||
public void createRunsAsynchronouslyAndTemplateIsOptional() throws NoSuchMethodException {
|
||||
Method command = CommandStudio.class.getDeclaredMethod("create", String.class, IrisDimension.class);
|
||||
Director director = command.getAnnotation(Director.class);
|
||||
Parameter templateParameter = command.getParameters()[1];
|
||||
Param template = templateParameter.getAnnotation(Param.class);
|
||||
|
||||
assertFalse(director.sync());
|
||||
assertEquals("null", template.defaultValue());
|
||||
assertEquals(NullableDimensionHandler.class, template.customHandler());
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -25,7 +25,7 @@ public class IrisStructureLocateCommandContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException {
|
||||
public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource")));
|
||||
int methodStart = source.indexOf("public void structure(");
|
||||
int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart);
|
||||
@@ -34,16 +34,19 @@ public class IrisStructureLocateCommandContractTest {
|
||||
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution);
|
||||
int replacementCheck = method.indexOf(
|
||||
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
|
||||
int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck);
|
||||
int genericIrisLookup = method.indexOf(
|
||||
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementLocate);
|
||||
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck);
|
||||
int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup);
|
||||
int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup);
|
||||
assertTrue(nativeResolution >= 0);
|
||||
assertTrue(policyResolution > nativeResolution);
|
||||
assertTrue(replacementCheck > policyResolution);
|
||||
assertTrue(replacementLocate > replacementCheck);
|
||||
assertTrue(genericIrisLookup > replacementLocate);
|
||||
assertTrue(genericIrisLookup > replacementCheck);
|
||||
assertTrue(replacementLocate > genericIrisLookup);
|
||||
assertTrue(nativeLocate > policyResolution);
|
||||
assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement"));
|
||||
assertTrue(method.contains("!replacementLocate && !explicitNativePlacement"));
|
||||
assertTrue(method.contains("&& !StructureReachability.isReachable"));
|
||||
assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS"));
|
||||
assertFalse(method.contains("NativeStructureLocateCapability"));
|
||||
}
|
||||
@@ -82,7 +85,8 @@ public class IrisStructureLocateCommandContractTest {
|
||||
int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)");
|
||||
int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution);
|
||||
int nativeRequirement = method.indexOf(
|
||||
"requiresNativeReachability |= !IrisStructureLocator.isPlaced", policyResolution);
|
||||
int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement);
|
||||
int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user