mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Fixes
This commit is contained in:
@@ -62,12 +62,12 @@ content selecting on `#minecraft:is_overworld` and friends.
|
||||
## Install
|
||||
|
||||
**Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start
|
||||
the server. On first boot Iris downloads the default `overworld` pack automatically.
|
||||
the server. On first boot Iris downloads the managed `overworld` and `underworld` beta packs automatically.
|
||||
|
||||
**Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is
|
||||
self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris
|
||||
downloads the default `overworld` pack before the worldgen datapack is written, so the default
|
||||
pack is fully active immediately. Packs installed later register their custom dimension types
|
||||
prefetches the managed `overworld` and `underworld` beta packs and rebuilds the worldgen datapack;
|
||||
restart once if startup reports that registry-visible pack data was installed too late for that boot. Packs installed later register their custom dimension types
|
||||
(height ranges) and custom biomes through the forced datapack at server start - restart once after
|
||||
adding a pack so worlds get its full heights and biomes; worlds created before that restart run
|
||||
with fallback heights.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -12,16 +18,75 @@ import java.nio.file.Path;
|
||||
public final class IrisBootstrap implements PluginBootstrap {
|
||||
@Override
|
||||
public void bootstrap(BootstrapContext context) {
|
||||
ProvisionResult provisioned = provision(context);
|
||||
Path datapackRoot = provisioned.datapackRoot();
|
||||
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
|
||||
WorldReplacementBootstrapMarker.markBootstrapped();
|
||||
try {
|
||||
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
|
||||
reconcilePendingWorldReplacements(context, startupPaths);
|
||||
ProvisionResult provisioned = provision(context, startupPaths);
|
||||
Path datapackRoot = provisioned.datapackRoot();
|
||||
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
|
||||
} catch (Throwable failure) {
|
||||
armStartupFailure(context, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static ProvisionResult provision(BootstrapContext context) {
|
||||
private static void reconcilePendingWorldReplacements(
|
||||
BootstrapContext context,
|
||||
BukkitStartupPaths startupPaths
|
||||
) {
|
||||
try {
|
||||
WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile(
|
||||
context.getDataDirectory(),
|
||||
startupPaths.levelRoot(),
|
||||
startupPaths.bukkitConfiguration(),
|
||||
message -> context.getLogger().info(message)
|
||||
);
|
||||
if (result.transactions() > 0) {
|
||||
context.getLogger().info(
|
||||
"Reconciled {} pending Iris world replacement(s): {} published, {} rolled back, {} retained",
|
||||
result.transactions(),
|
||||
result.published(),
|
||||
result.rolledBack(),
|
||||
result.retained()
|
||||
);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to reconcile pending Iris world replacements before registry bootstrap",
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||
armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY);
|
||||
}
|
||||
|
||||
static <E extends LifecycleEvent> void armStartupFailure(
|
||||
BootstrapContext context,
|
||||
Throwable failure,
|
||||
LifecycleEventType<? super BootstrapContext, ? extends E, ?> eventType
|
||||
) {
|
||||
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
||||
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
||||
});
|
||||
try {
|
||||
context.getLogger().error(
|
||||
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
|
||||
failure
|
||||
);
|
||||
} catch (Throwable loggingFailure) {
|
||||
failure.addSuppressed(loggingFailure);
|
||||
failure.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
||||
try {
|
||||
return DefaultPackBootstrapProvisioner.provision(
|
||||
context.getDataDirectory(),
|
||||
message -> context.getLogger().info(message)
|
||||
message -> context.getLogger().info(message),
|
||||
startupPaths
|
||||
);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to provision the Iris startup datapack", e);
|
||||
|
||||
+84
-282
@@ -7,6 +7,7 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacem
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
|
||||
@@ -68,7 +69,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
if (worldKey == null) {
|
||||
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
|
||||
}
|
||||
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
|
||||
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), toWorldSlotKey(worldKey));
|
||||
return worldKey;
|
||||
}
|
||||
|
||||
@@ -80,8 +81,12 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
) throws IOException {
|
||||
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
IrisStartupValidation.requireWorldCreationReady();
|
||||
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
|
||||
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
|
||||
}
|
||||
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
|
||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
|
||||
@@ -89,13 +94,13 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
|
||||
requiredWorldKey.toString()
|
||||
)) {
|
||||
if (findTransaction(requiredWorldKey) != null) {
|
||||
if (findTransaction(requiredWorldSlotKey) != null) {
|
||||
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
|
||||
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldSlotKey);
|
||||
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
|
||||
long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed);
|
||||
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldKey);
|
||||
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
|
||||
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
||||
if (!datapacks.succeeded()) {
|
||||
throw new IOException("Iris could not compile the dimension datapacks.");
|
||||
@@ -103,7 +108,8 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
|
||||
UUID transactionId = UUID.randomUUID();
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
|
||||
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
|
||||
WorldReplacementFilesystem.requireExistingTarget(paths);
|
||||
boolean targetPresent = true;
|
||||
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldName
|
||||
@@ -127,7 +133,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||
transaction = new Transaction(
|
||||
transactionId,
|
||||
requiredWorldKey,
|
||||
requiredWorldSlotKey,
|
||||
worldName,
|
||||
target.levelRoot(),
|
||||
installed.getLoadKey(),
|
||||
@@ -163,7 +169,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
} catch (Throwable failure) {
|
||||
if (configurationApplied && transaction != null) {
|
||||
try {
|
||||
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
|
||||
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
|
||||
if (!BukkitWorldConfiguration.restoreIfMatching(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldName,
|
||||
@@ -236,7 +242,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
||||
scheduleCommittedCleanup(transaction);
|
||||
} else if (transaction.phase() == Phase.PUBLISHED) {
|
||||
WorldIdentity.resolve(transaction.worldKey())
|
||||
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
|
||||
.ifPresent(world -> verifyPublishedWorld(world, transaction));
|
||||
}
|
||||
}
|
||||
@@ -266,7 +272,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
|
||||
private void verifyPublishedWorld(World world, Transaction transaction) {
|
||||
try {
|
||||
if (!transaction.worldKey().equals(WorldIdentity.key(world))) {
|
||||
if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) {
|
||||
throw new IOException("Loaded world identity does not match the replacement journal.");
|
||||
}
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
@@ -295,6 +301,13 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
if (!transaction.packFingerprint().equals(fingerprint)) {
|
||||
throw new IOException("The replacement pack changed before runtime verification.");
|
||||
}
|
||||
WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
transaction.worldName()
|
||||
);
|
||||
if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(transaction))) {
|
||||
throw new IOException("bukkit.yml changed before the replacement could be committed.");
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
initiateRollback(transaction, failure);
|
||||
return;
|
||||
@@ -317,13 +330,14 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
try {
|
||||
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||
writeTransaction(rollback);
|
||||
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
IrisStartupValidation.markPacksInvalid(List.of(
|
||||
"Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure)));
|
||||
Iris.reportError("Failed to arm Iris world replacement rollback for "
|
||||
+ transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure);
|
||||
} finally {
|
||||
ServerConfigurator.restart("An Iris world replacement failed verification and requires a cold restart.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,14 +362,14 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
throw new RestartBoundaryRequired("The world-storage transaction has not reached its cold bootstrap.");
|
||||
}
|
||||
if (transaction.phase() == Phase.PREPARED) {
|
||||
if (current.matchesGeneratorAndSeed(replacement)) {
|
||||
writeTransaction(transaction.withPhase(Phase.ARMED));
|
||||
throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
|
||||
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
WorldReplacementFilesystem.discardStage(paths);
|
||||
deleteJournal(transaction.id());
|
||||
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
|
||||
return;
|
||||
} else if (current.matchesGeneratorAndSeed(replacement)) {
|
||||
writeTransaction(transaction.withPhase(Phase.ARMED));
|
||||
throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
|
||||
} else {
|
||||
throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
|
||||
}
|
||||
@@ -376,16 +390,38 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
if (!cleanupInFlight.add(transaction.id())) {
|
||||
return;
|
||||
}
|
||||
J.a(() -> cleanupCommittedReplacement(transaction));
|
||||
try {
|
||||
J.a(() -> cleanupCommittedReplacement(transaction));
|
||||
} catch (Throwable failure) {
|
||||
cleanupInFlight.remove(transaction.id());
|
||||
Iris.reportError("Could not schedule retained-backup cleanup for " + transaction.worldKey()
|
||||
+ "; cleanup will retry without rolling back the verified world.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupCommittedReplacement(Transaction transaction) {
|
||||
try {
|
||||
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
|
||||
WorldReplacementFilesystem.cleanupBackup(paths);
|
||||
deleteJournal(transaction.id());
|
||||
Iris.success("Removed the retained backup for " + transaction.worldKey() + ".");
|
||||
synchronized (this) {
|
||||
Transaction current = findTransaction(transaction.worldKey());
|
||||
if (current == null
|
||||
|| !current.id().equals(transaction.id())
|
||||
|| current.phase() != Phase.CLEANUP_PENDING) {
|
||||
return;
|
||||
}
|
||||
WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
current.worldName()
|
||||
);
|
||||
if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(current))) {
|
||||
throw new IOException("bukkit.yml changed before the retained backup could be removed.");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(current);
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, current.id());
|
||||
WorldReplacementFilesystem.validateCommittedTarget(paths, current.packFingerprint());
|
||||
WorldReplacementFilesystem.cleanupBackup(paths);
|
||||
deleteJournal(current.id());
|
||||
Iris.success("Removed the retained backup for " + current.worldKey() + ".");
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Could not clean the retained backup for " + transaction.worldKey()
|
||||
+ "; cleanup will retry without rolling back the verified world.", failure);
|
||||
@@ -396,7 +432,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException {
|
||||
private ExactWorldSlotPathPolicy.Target prepareTarget(WorldSlotKey worldKey) throws IOException {
|
||||
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
|
||||
Path dimensions = target.levelRoot().resolve("dimensions");
|
||||
@@ -416,6 +452,10 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
|
||||
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
|
||||
return findTransaction(toWorldSlotKey(worldKey));
|
||||
}
|
||||
|
||||
private Transaction findTransaction(WorldSlotKey worldKey) throws IOException {
|
||||
for (Transaction transaction : loadTransactions()) {
|
||||
if (transaction.worldKey().equals(worldKey)) {
|
||||
return transaction;
|
||||
@@ -425,159 +465,23 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
|
||||
private List<Transaction> loadTransactions() throws IOException {
|
||||
Path directory = journalDirectory(false);
|
||||
if (directory == null) {
|
||||
return List.of();
|
||||
}
|
||||
ArrayList<Transaction> transactions = new ArrayList<>();
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
|
||||
for (Path file : files) {
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement journal entry is unsafe: " + file);
|
||||
}
|
||||
transactions.add(readTransaction(file));
|
||||
}
|
||||
}
|
||||
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
|
||||
return List.copyOf(transactions);
|
||||
}
|
||||
|
||||
private Transaction readTransaction(Path file) throws IOException {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
properties.load(input);
|
||||
}
|
||||
UUID id = UUID.fromString(required(properties, "id"));
|
||||
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
|
||||
throw new IOException("Replacement journal filename does not match its transaction id.");
|
||||
}
|
||||
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
|
||||
if (worldKey == null) {
|
||||
throw new IOException("Replacement journal contains an invalid world key.");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
|
||||
String dimension = required(properties, "dimension");
|
||||
if (!safeDimension(dimension)) {
|
||||
throw new IOException("Replacement journal contains an invalid dimension key.");
|
||||
}
|
||||
long seed = parseLong(properties, "seed");
|
||||
String packFingerprint = required(properties, "packFingerprint");
|
||||
if (!packFingerprint.matches("[0-9a-f]{64}")) {
|
||||
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
|
||||
}
|
||||
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
|
||||
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
|
||||
Phase phase;
|
||||
try {
|
||||
phase = Phase.valueOf(required(properties, "phase"));
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid phase.", failure);
|
||||
}
|
||||
return new Transaction(
|
||||
id,
|
||||
worldKey,
|
||||
dimension,
|
||||
seed,
|
||||
packFingerprint,
|
||||
original,
|
||||
originalTargetPresent,
|
||||
phase
|
||||
);
|
||||
return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath());
|
||||
}
|
||||
|
||||
private void writeTransaction(Transaction transaction) throws IOException {
|
||||
Path directory = Objects.requireNonNull(journalDirectory(true));
|
||||
Path target = directory.resolve(transaction.id() + JOURNAL_SUFFIX);
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("id", transaction.id().toString());
|
||||
properties.setProperty("worldKey", transaction.worldKey().toString());
|
||||
properties.setProperty("dimension", transaction.dimension());
|
||||
properties.setProperty("seed", Long.toString(transaction.seed()));
|
||||
properties.setProperty("packFingerprint", transaction.packFingerprint());
|
||||
properties.setProperty("originalTargetPresent", Boolean.toString(transaction.originalTargetPresent()));
|
||||
properties.setProperty("phase", transaction.phase().name());
|
||||
writeSnapshot(properties, "original.", transaction.originalConfiguration());
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
properties.store(output, null);
|
||||
writeAtomic(target, output.toByteArray());
|
||||
WorldReplacementJournal.write(dataDirectory(), transaction);
|
||||
}
|
||||
|
||||
private void deleteJournal(UUID id) throws IOException {
|
||||
Path directory = journalDirectory(false);
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
|
||||
forceDirectory(directory);
|
||||
WorldReplacementJournal.delete(dataDirectory(), id);
|
||||
}
|
||||
|
||||
private Path journalDirectory(boolean create) throws IOException {
|
||||
Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).toPath().toAbsolutePath().normalize();
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
Files.createDirectories(directory);
|
||||
}
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement journal storage is unsafe: " + directory);
|
||||
}
|
||||
return directory;
|
||||
private Path dataDirectory() {
|
||||
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static void writeAtomic(Path target, byte[] content) throws IOException {
|
||||
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
|
||||
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(content);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException failure) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
forceDirectory(parent);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static ReplacementPaths replacementPaths(
|
||||
ExactWorldSlotPathPolicy.Target target,
|
||||
UUID id
|
||||
) {
|
||||
String artifactBase = ".iris-replace-" + target.worldKey().getKey() + "-" + id;
|
||||
return new ReplacementPaths(
|
||||
target.worldDirectory(),
|
||||
target.namespaceRoot().resolve(artifactBase + ".stage"),
|
||||
target.namespaceRoot().resolve(artifactBase + ".backup")
|
||||
);
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
|
||||
return new WorldGeneratorSnapshot(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"Iris:" + transaction.dimension(),
|
||||
true,
|
||||
transaction.seed()
|
||||
);
|
||||
private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException {
|
||||
return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
|
||||
}
|
||||
|
||||
private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) {
|
||||
@@ -633,91 +537,27 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private static World.Environment expectedEnvironment(NamespacedKey worldKey) {
|
||||
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
|
||||
private static World.Environment expectedEnvironment(WorldSlotKey worldKey) {
|
||||
if (WorldSlotKey.minecraft("overworld").equals(worldKey)) {
|
||||
return World.Environment.NORMAL;
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
|
||||
if (WorldSlotKey.minecraft("the_nether").equals(worldKey)) {
|
||||
return World.Environment.NETHER;
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_end").equals(worldKey)) {
|
||||
if (WorldSlotKey.minecraft("the_end").equals(worldKey)) {
|
||||
return World.Environment.THE_END;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
|
||||
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
|
||||
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
|
||||
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
|
||||
if (snapshot.generatorPresent()) {
|
||||
properties.setProperty(prefix + "generator", snapshot.generator());
|
||||
}
|
||||
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
|
||||
if (snapshot.seedPresent()) {
|
||||
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
|
||||
}
|
||||
private static WorldSlotKey toWorldSlotKey(NamespacedKey worldKey) {
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
return new WorldSlotKey(requiredWorldKey.getNamespace(), requiredWorldKey.getKey());
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
|
||||
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
|
||||
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
|
||||
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
|
||||
String generator = generatorPresent ? required(properties, prefix + "generator") : null;
|
||||
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
|
||||
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
|
||||
try {
|
||||
return new WorldGeneratorSnapshot(
|
||||
worldsPresent,
|
||||
worldPresent,
|
||||
generatorPresent,
|
||||
generator,
|
||||
seedPresent,
|
||||
seed
|
||||
);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean safeDimension(String value) {
|
||||
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
|
||||
return false;
|
||||
}
|
||||
String[] segments = value.split("/", -1);
|
||||
if (segments.length > 16) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String key) throws IOException {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Replacement journal is missing " + key + ".");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static boolean parseBoolean(Properties properties, String key) throws IOException {
|
||||
String value = required(properties, key);
|
||||
if (!"true".equals(value) && !"false".equals(value)) {
|
||||
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
|
||||
}
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
private static long parseLong(Properties properties, String key) throws IOException {
|
||||
try {
|
||||
return Long.parseLong(required(properties, key));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
|
||||
}
|
||||
private static NamespacedKey toNamespacedKey(WorldSlotKey worldKey) {
|
||||
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
return new NamespacedKey(requiredWorldKey.namespace(), requiredWorldKey.key());
|
||||
}
|
||||
|
||||
private static String detail(Throwable failure) {
|
||||
@@ -740,50 +580,12 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private record Transaction(
|
||||
UUID id,
|
||||
NamespacedKey worldKey,
|
||||
String dimension,
|
||||
long seed,
|
||||
String packFingerprint,
|
||||
WorldGeneratorSnapshot originalConfiguration,
|
||||
boolean originalTargetPresent,
|
||||
Phase phase
|
||||
) {
|
||||
private Transaction {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
Objects.requireNonNull(packFingerprint, "packFingerprint");
|
||||
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
|
||||
Objects.requireNonNull(phase, "phase");
|
||||
}
|
||||
|
||||
private String worldName() {
|
||||
return IrisWorldStorage.logicalName(worldKey);
|
||||
}
|
||||
|
||||
private Transaction withPhase(Phase nextPhase) {
|
||||
return new Transaction(
|
||||
id,
|
||||
worldKey,
|
||||
dimension,
|
||||
seed,
|
||||
packFingerprint,
|
||||
originalConfiguration,
|
||||
originalTargetPresent,
|
||||
nextPhase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private enum Phase {
|
||||
PREPARED,
|
||||
ARMED,
|
||||
PUBLISHED,
|
||||
ROLLBACK_PENDING
|
||||
}
|
||||
|
||||
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
|
||||
}
|
||||
|
||||
private static final class RestartBoundaryRequired extends IOException {
|
||||
private RestartBoundaryRequired(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,9 +710,9 @@ public class CommandIris implements DirectorExecutor {
|
||||
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false")
|
||||
boolean overwrite
|
||||
) {
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
if (PackDownloader.isManagedBetaPack(pack)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK_BETA_RELEASE, MessageArgument.untrusted("pack", pack), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
|
||||
Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite);
|
||||
Iris.service(StudioSVC.class).downloadManagedBeta(sender(), pack, overwrite);
|
||||
} else {
|
||||
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);
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager;
|
||||
import io.papermc.paper.plugin.lifecycle.event.handler.LifecycleEventHandler;
|
||||
import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEventHandlerConfiguration;
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBootstrapFailureContractTest {
|
||||
@Test
|
||||
public void bootstrapFailureAbortsDatapackDiscoveryInsteadOfDisablingOnlyIris() {
|
||||
BootstrapContext context = mock(BootstrapContext.class);
|
||||
ComponentLogger logger = mock(ComponentLogger.class);
|
||||
CapturingLifecycleManager manager = new CapturingLifecycleManager();
|
||||
IllegalStateException cause = new IllegalStateException("unsafe transaction");
|
||||
LifecycleEventType<BootstrapContext, LifecycleEvent, ?> eventType = lifecycleEventType();
|
||||
when(context.getLogger()).thenReturn(logger);
|
||||
when(context.getLifecycleManager()).thenReturn(manager);
|
||||
|
||||
IrisBootstrap.armStartupFailure(context, cause, eventType);
|
||||
|
||||
assertSame(eventType, manager.eventType);
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, manager::runCapturedHandler);
|
||||
assertSame(cause, failure.getCause());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
||||
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
||||
}
|
||||
|
||||
private static final class CapturingLifecycleManager implements LifecycleEventManager<BootstrapContext> {
|
||||
private LifecycleEventType<?, ?, ?> eventType;
|
||||
private LifecycleEventHandler<?> handler;
|
||||
|
||||
@Override
|
||||
public <E extends LifecycleEvent> void registerEventHandler(
|
||||
LifecycleEventType<? super BootstrapContext, ? extends E, ?> eventType,
|
||||
LifecycleEventHandler<? super E> eventHandler
|
||||
) {
|
||||
this.eventType = eventType;
|
||||
this.handler = eventHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerEventHandler(
|
||||
LifecycleEventHandlerConfiguration<? super BootstrapContext> handlerConfiguration
|
||||
) {
|
||||
throw new AssertionError("The direct handler overload must be used.");
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private void runCapturedHandler() {
|
||||
((LifecycleEventHandler) handler).run(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -42,12 +42,14 @@ public final class ModdedPackInstaller {
|
||||
}
|
||||
|
||||
public static boolean install(Path configDir, String pack, String branch,
|
||||
boolean forceOverwrite, Consumer<String> feedback) {
|
||||
boolean forceOverwrite, boolean refreshDatapack,
|
||||
Consumer<String> feedback) {
|
||||
if (pack == null || !PACK_NAME.matcher(pack).matches()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_PACK_NAME, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
||||
return false;
|
||||
}
|
||||
if (branch == null || !BRANCH_NAME.matcher(branch).matches()) {
|
||||
boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
|
||||
if (!acceptsBranch(pack, branch)) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_BRANCH_NAME, MessageArgument.untrusted("branch", String.valueOf(branch))));
|
||||
return false;
|
||||
}
|
||||
@@ -56,8 +58,8 @@ public final class ModdedPackInstaller {
|
||||
synchronized (installLock) {
|
||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||
try {
|
||||
PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack)
|
||||
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback)
|
||||
PackDownloader.PackInstallResult result = managedBeta
|
||||
? PackDownloader.downloadManagedBeta(packs, pack, forceOverwrite, feedback)
|
||||
: PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
@@ -67,7 +69,7 @@ public final class ModdedPackInstaller {
|
||||
pack,
|
||||
feedback);
|
||||
boolean installed = result != null;
|
||||
if (result != null && result.changed()) {
|
||||
if (shouldRefreshDatapack(result, refreshDatapack)) {
|
||||
// Pack-install completion is one of the four forced-datapack regeneration triggers; every
|
||||
// install call site already runs off the server thread, so regenerate inline here. A
|
||||
// regeneration failure must never turn a successful install into a failed one.
|
||||
@@ -82,7 +84,8 @@ public final class ModdedPackInstaller {
|
||||
}
|
||||
return installed;
|
||||
} catch (IOException error) {
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||
String source = managedBeta ? "beta release" : "branch " + branch;
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, source, error);
|
||||
feedback.accept(IrisLanguage.plain(
|
||||
PackDownloadMessages.DOWNLOAD_FAILED,
|
||||
MessageArgument.untrusted("type", error.getClass().getSimpleName()),
|
||||
@@ -92,4 +95,13 @@ public final class ModdedPackInstaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean acceptsBranch(String pack, String branch) {
|
||||
return PackDownloader.isManagedBetaPack(pack)
|
||||
|| (branch != null && BRANCH_NAME.matcher(branch).matches());
|
||||
}
|
||||
|
||||
static boolean shouldRefreshDatapack(PackDownloader.PackInstallResult result, boolean refreshDatapack) {
|
||||
return result != null && result.changed() && refreshDatapack;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -253,20 +254,39 @@ public final class ModdedStartup {
|
||||
if (!config.autoDownloadDefaultPack()) {
|
||||
return;
|
||||
}
|
||||
String pack = config.defaultPack();
|
||||
Path configDir = ModdedEngineBootstrap.loader().configDir();
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
return;
|
||||
}
|
||||
String source = "master branch";
|
||||
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source);
|
||||
boolean installed = ModdedPackInstaller.install(
|
||||
configDir, pack, "master", false,
|
||||
(String line) -> LOGGER.info("Iris: {}", line));
|
||||
if (!installed) {
|
||||
LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack);
|
||||
File packsRoot = ModdedPackCommands.packsRoot();
|
||||
for (String pack : startupPacks(config.defaultPack())) {
|
||||
ensureStartupPack(configDir, packsRoot, pack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> startupPacks(String configuredDefault) {
|
||||
LinkedHashSet<String> packs = new LinkedHashSet<>(PackDownloader.managedBetaPacks());
|
||||
if (configuredDefault != null && !configuredDefault.isBlank()) {
|
||||
packs.add(configuredDefault);
|
||||
}
|
||||
return List.copyOf(packs);
|
||||
}
|
||||
|
||||
private static void ensureStartupPack(Path configDir, File packsRoot, String pack) {
|
||||
boolean present = PackDownloader.isManagedBetaPack(pack)
|
||||
? PackDownloader.isManagedBetaPackPresent(packsRoot, pack)
|
||||
: PackDownloader.isPackPresent(packsRoot, pack);
|
||||
if (present) {
|
||||
return;
|
||||
}
|
||||
boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
|
||||
String branch = managedBeta ? "beta" : "master";
|
||||
String source = managedBeta ? "beta release" : "master branch";
|
||||
String role = managedBeta ? "managed beta pack" : "default pack";
|
||||
LOGGER.info("Iris {} '{}' missing; downloading IrisDimensions/{} ({})", role, pack, pack, source);
|
||||
boolean installed = ModdedPackInstaller.install(
|
||||
configDir, pack, branch, false, false,
|
||||
(String line) -> LOGGER.info("Iris: {}", line));
|
||||
if (!installed) {
|
||||
LOGGER.warn("Iris {} '{}' could not be downloaded; install it with /iris download {}", role, pack, pack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -258,8 +258,8 @@ public final class IrisModdedCommands {
|
||||
|
||||
static int download(CommandSourceStack source, String pack,
|
||||
String branch, boolean forceOverwrite) {
|
||||
boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack);
|
||||
String baseDownloadSource = defaultOverworld ? "beta release" : "branch " + branch;
|
||||
boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
|
||||
String baseDownloadSource = managedBeta ? "beta release" : "branch " + branch;
|
||||
String downloadSource = forceOverwrite
|
||||
? baseDownloadSource + IrisLanguage.plain(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX)
|
||||
: baseDownloadSource;
|
||||
@@ -274,7 +274,7 @@ public final class IrisModdedCommands {
|
||||
}
|
||||
scheduler.async(() -> {
|
||||
boolean installed = ModdedPackInstaller.install(
|
||||
ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite,
|
||||
ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, true,
|
||||
(String message) -> scheduler.global(() -> ok(source, message)));
|
||||
if (installed) {
|
||||
scheduler.global(() -> ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_INSTALLED_ITS_EXACT_DIMENSION_TYPES_CUSTOM_BIOMES_JOIN_FORCED, MessageArgument.untrusted("pack", pack))));
|
||||
|
||||
+2
-2
@@ -383,7 +383,7 @@ public final class ModdedStudioCommands {
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
|
||||
@@ -588,7 +588,7 @@ public final class ModdedStudioCommands {
|
||||
File templateFolder = new File(packsRoot, template);
|
||||
if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
|
||||
|
||||
+2
-2
@@ -177,7 +177,7 @@ public final class ModdedWorldCommands {
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
|
||||
Thread thread = new Thread(() -> {
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
server.execute(() -> {
|
||||
if (!installed || !packFolder.isDirectory()) {
|
||||
@@ -280,7 +280,7 @@ public final class ModdedWorldCommands {
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
|
||||
Thread thread = new Thread(() -> {
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
server.execute(() -> {
|
||||
if (!installed || !packFolder.isDirectory()) {
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedPackInstallerTest {
|
||||
@Test
|
||||
public void managedBetaPacksIgnoreTheRequestedBranch() {
|
||||
assertTrue(ModdedPackInstaller.acceptsBranch("overworld", null));
|
||||
assertTrue(ModdedPackInstaller.acceptsBranch("underworld", "feature/arbitrary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonManagedPacksStillRequireAValidBranch() {
|
||||
assertTrue(ModdedPackInstaller.acceptsBranch("custom", "stable"));
|
||||
assertFalse(ModdedPackInstaller.acceptsBranch("custom", null));
|
||||
assertFalse(ModdedPackInstaller.acceptsBranch("custom", "feature/arbitrary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupBatchDefersDatapackRefresh() {
|
||||
PackDownloader.PackInstallResult changed = new PackDownloader.PackInstallResult("underworld", true, true);
|
||||
|
||||
assertFalse(ModdedPackInstaller.shouldRefreshDatapack(changed, false));
|
||||
assertTrue(ModdedPackInstaller.shouldRefreshDatapack(changed, true));
|
||||
assertFalse(ModdedPackInstaller.shouldRefreshDatapack(
|
||||
new PackDownloader.PackInstallResult("underworld", false, false),
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class ModdedStartupPackSelectionTest {
|
||||
@Test
|
||||
public void managedBetaPacksAreAlwaysInstalledInStableOrder() {
|
||||
assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("overworld"));
|
||||
assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("underworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredNonManagedDefaultIsInstalledAfterManagedBetaPacks() {
|
||||
assertEquals(
|
||||
List.of("overworld", "underworld", "custom"),
|
||||
ModdedStartup.startupPacks("custom")
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -228,7 +228,7 @@ tasks.named('compileJava', JavaCompile).configure {
|
||||
|
||||
tasks.named('test', Test).configure {
|
||||
jvmArgs('--add-modules', 'jdk.incubator.vector')
|
||||
classpath += files('src/main/resources')
|
||||
classpath = files('src/main/resources') + classpath
|
||||
}
|
||||
|
||||
configurations.matching { it.name.startsWith('slim') }.all { }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
@@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy {
|
||||
private ExactWorldSlotPathPolicy() {
|
||||
}
|
||||
|
||||
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
public static Target resolve(Path levelRoot, WorldSlotKey worldKey) {
|
||||
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
|
||||
SlotKind slotKind = classify(requiredWorldKey);
|
||||
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
|
||||
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
|
||||
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
|
||||
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace());
|
||||
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize();
|
||||
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
|
||||
throw new Rejection(
|
||||
RejectionReason.PATH_TRAVERSAL,
|
||||
@@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy {
|
||||
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
|
||||
}
|
||||
|
||||
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
|
||||
public static Target validate(Path levelRoot, WorldSlotKey worldKey, Path candidate) {
|
||||
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
|
||||
rejectTraversal(requiredCandidate, "World candidate");
|
||||
Target target = resolve(levelRoot, worldKey);
|
||||
@@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
private static SlotKind classify(NamespacedKey worldKey) {
|
||||
if ("iris".equals(worldKey.getNamespace())) {
|
||||
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
|
||||
private static SlotKind classify(WorldSlotKey worldKey) {
|
||||
if ("iris".equals(worldKey.namespace())) {
|
||||
if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) {
|
||||
throw new Rejection(
|
||||
RejectionReason.INVALID_IRIS_KEY,
|
||||
"Iris world keys must be safe single path segments."
|
||||
@@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy {
|
||||
}
|
||||
return SlotKind.IRIS_MANAGED;
|
||||
}
|
||||
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
|
||||
if (!"minecraft".equals(worldKey.namespace())) {
|
||||
throw new Rejection(
|
||||
RejectionReason.FOREIGN_NAMESPACE,
|
||||
"Only Iris-managed and exact vanilla dimension slots can be replaced."
|
||||
);
|
||||
}
|
||||
return switch (worldKey.getKey()) {
|
||||
return switch (worldKey.key()) {
|
||||
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
|
||||
case "the_nether" -> SlotKind.VANILLA_NETHER;
|
||||
case "the_end" -> SlotKind.VANILLA_END;
|
||||
@@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy {
|
||||
}
|
||||
|
||||
public record Target(
|
||||
NamespacedKey worldKey,
|
||||
WorldSlotKey worldKey,
|
||||
SlotKind slotKind,
|
||||
Path levelRoot,
|
||||
Path namespaceRoot,
|
||||
@@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy {
|
||||
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
|
||||
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
|
||||
SlotKind expectedSlotKind = classify(worldKey);
|
||||
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
|
||||
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
|
||||
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace());
|
||||
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key());
|
||||
if (slotKind != expectedSlotKind
|
||||
|| !namespaceRoot.equals(expectedNamespaceRoot)
|
||||
|| !worldDirectory.equals(expectedWorldDirectory)) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public record WorldSlotKey(String namespace, String key) {
|
||||
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z0-9/._-]+$");
|
||||
private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^[a-z0-9._-]+$");
|
||||
|
||||
public WorldSlotKey {
|
||||
namespace = Objects.requireNonNull(namespace, "namespace");
|
||||
key = Objects.requireNonNull(key, "key");
|
||||
if (!NAMESPACE_PATTERN.matcher(namespace).matches()) {
|
||||
throw new IllegalArgumentException("World slot namespace is invalid: " + namespace);
|
||||
}
|
||||
if (!KEY_PATTERN.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException("World slot key is invalid: " + key);
|
||||
}
|
||||
}
|
||||
|
||||
public static WorldSlotKey parse(String value) {
|
||||
String requiredValue = Objects.requireNonNull(value, "value");
|
||||
int separator = requiredValue.indexOf(':');
|
||||
if (separator <= 0 || separator != requiredValue.lastIndexOf(':') || separator == requiredValue.length() - 1) {
|
||||
throw new IllegalArgumentException("World slot key must use namespace:key syntax.");
|
||||
}
|
||||
return new WorldSlotKey(
|
||||
requiredValue.substring(0, separator),
|
||||
requiredValue.substring(separator + 1)
|
||||
);
|
||||
}
|
||||
|
||||
public static WorldSlotKey minecraft(String key) {
|
||||
return new WorldSlotKey("minecraft", key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return namespace + ":" + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
public final class BukkitStartupPaths {
|
||||
private static final String DEFAULT_BUKKIT_CONFIGURATION = "bukkit.yml";
|
||||
private static final String DEFAULT_LEVEL_NAME = "world";
|
||||
private static final String DEFAULT_SERVER_PROPERTIES = "server.properties";
|
||||
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
||||
|
||||
private final Path bukkitConfiguration;
|
||||
private final Path levelRoot;
|
||||
private final String levelName;
|
||||
private final Path serverProperties;
|
||||
private final Path serverRoot;
|
||||
private final Path worldContainer;
|
||||
|
||||
private BukkitStartupPaths(
|
||||
Path serverRoot,
|
||||
Path serverProperties,
|
||||
Path bukkitConfiguration,
|
||||
Path worldContainer,
|
||||
String levelName,
|
||||
Path levelRoot
|
||||
) {
|
||||
this.serverRoot = Objects.requireNonNull(serverRoot, "serverRoot");
|
||||
this.serverProperties = Objects.requireNonNull(serverProperties, "serverProperties");
|
||||
this.bukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration");
|
||||
this.worldContainer = Objects.requireNonNull(worldContainer, "worldContainer");
|
||||
this.levelName = Objects.requireNonNull(levelName, "levelName");
|
||||
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
}
|
||||
|
||||
public static BukkitStartupPaths resolveCurrent() throws IOException {
|
||||
return resolve(Path.of(""), currentArguments());
|
||||
}
|
||||
|
||||
public static BukkitStartupPaths resolve(Path serverWorkingDirectory) throws IOException {
|
||||
return resolve(serverWorkingDirectory, currentArguments());
|
||||
}
|
||||
|
||||
public static BukkitStartupPaths resolve(Path serverWorkingDirectory, String[] processArguments)
|
||||
throws IOException {
|
||||
Path serverRoot = canonicalDirectory(
|
||||
Objects.requireNonNull(serverWorkingDirectory, "serverWorkingDirectory").toAbsolutePath().normalize(),
|
||||
"Server working directory"
|
||||
);
|
||||
StartupArguments arguments = parseArguments(processArguments);
|
||||
Path serverProperties = resolveAgainstServerRoot(
|
||||
serverRoot,
|
||||
arguments.serverProperties(),
|
||||
DEFAULT_SERVER_PROPERTIES,
|
||||
"server properties"
|
||||
);
|
||||
Path bukkitConfiguration = resolveAgainstServerRoot(
|
||||
serverRoot,
|
||||
arguments.bukkitConfiguration(),
|
||||
DEFAULT_BUKKIT_CONFIGURATION,
|
||||
"Bukkit configuration"
|
||||
);
|
||||
String levelName = effectiveLevelName(serverProperties, arguments.levelName());
|
||||
Path worldContainer = canonicalPotentialDirectory(
|
||||
resolveAgainstServerRoot(
|
||||
serverRoot,
|
||||
arguments.worldContainer() == null
|
||||
? BukkitWorldConfiguration.readWorldContainer(bukkitConfiguration.toFile())
|
||||
: arguments.worldContainer(),
|
||||
DEFAULT_WORLD_CONTAINER,
|
||||
"world container"
|
||||
),
|
||||
"Configured world container"
|
||||
);
|
||||
Path configuredLevel = parsePath(levelName, "level name");
|
||||
Path levelRoot = canonicalPotentialDirectory(
|
||||
configuredLevel.isAbsolute()
|
||||
? configuredLevel
|
||||
: worldContainer.resolve(configuredLevel),
|
||||
"Configured level root"
|
||||
);
|
||||
return new BukkitStartupPaths(
|
||||
serverRoot,
|
||||
serverProperties,
|
||||
bukkitConfiguration,
|
||||
worldContainer,
|
||||
levelName,
|
||||
levelRoot
|
||||
);
|
||||
}
|
||||
|
||||
public Path bukkitConfiguration() {
|
||||
return bukkitConfiguration;
|
||||
}
|
||||
|
||||
public Path levelRoot() {
|
||||
return levelRoot;
|
||||
}
|
||||
|
||||
public String levelName() {
|
||||
return levelName;
|
||||
}
|
||||
|
||||
public Path serverProperties() {
|
||||
return serverProperties;
|
||||
}
|
||||
|
||||
public Path serverRoot() {
|
||||
return serverRoot;
|
||||
}
|
||||
|
||||
public Path worldContainer() {
|
||||
return worldContainer;
|
||||
}
|
||||
|
||||
private static String[] currentArguments() {
|
||||
return applicationArguments(ProcessHandle.current().info().arguments().orElse(new String[0]));
|
||||
}
|
||||
|
||||
static String[] applicationArguments(String[] processArguments) {
|
||||
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
|
||||
for (int index = 0; index < arguments.length; index++) {
|
||||
if (arguments[index].equals("-jar")
|
||||
|| arguments[index].equals("-m")
|
||||
|| arguments[index].equals("--module")) {
|
||||
int applicationStart = Math.min(arguments.length, index + 2);
|
||||
return Arrays.copyOfRange(arguments, applicationStart, arguments.length);
|
||||
}
|
||||
}
|
||||
Set<String> optionsWithValues = Set.of(
|
||||
"-cp",
|
||||
"-classpath",
|
||||
"--class-path",
|
||||
"-p",
|
||||
"--module-path",
|
||||
"--upgrade-module-path",
|
||||
"--add-modules",
|
||||
"--enable-native-access",
|
||||
"--limit-modules",
|
||||
"--add-exports",
|
||||
"--add-opens",
|
||||
"--add-reads",
|
||||
"--patch-module",
|
||||
"--source"
|
||||
);
|
||||
for (int index = 0; index < arguments.length; index++) {
|
||||
String argument = arguments[index];
|
||||
if (optionsWithValues.contains(argument)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("-") || argument.startsWith("@")) {
|
||||
continue;
|
||||
}
|
||||
return Arrays.copyOfRange(arguments, index + 1, arguments.length);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
private static StartupArguments parseArguments(String[] processArguments) throws IOException {
|
||||
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
|
||||
String serverProperties = null;
|
||||
String bukkitConfiguration = null;
|
||||
String levelName = null;
|
||||
String worldContainer = null;
|
||||
for (int index = 0; index < arguments.length; index++) {
|
||||
String argument = Objects.requireNonNull(arguments[index], "process argument");
|
||||
if (argument.equals("--")) {
|
||||
break;
|
||||
}
|
||||
ParsedArgument parsed = parseArgument(
|
||||
argument,
|
||||
index + 1 < arguments.length ? arguments[index + 1] : null
|
||||
);
|
||||
if (parsed == null) {
|
||||
continue;
|
||||
}
|
||||
switch (parsed.kind()) {
|
||||
case SERVER_PROPERTIES -> serverProperties = parsed.value();
|
||||
case BUKKIT_CONFIGURATION -> bukkitConfiguration = parsed.value();
|
||||
case LEVEL_NAME -> levelName = parsed.value();
|
||||
case WORLD_CONTAINER -> worldContainer = parsed.value();
|
||||
}
|
||||
if (parsed.consumedFollowing()) {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return new StartupArguments(serverProperties, bukkitConfiguration, levelName, worldContainer);
|
||||
}
|
||||
|
||||
private static ParsedArgument parseArgument(String argument, String following) throws IOException {
|
||||
ParsedArgument parsed = parseArgument(
|
||||
argument,
|
||||
following,
|
||||
ArgumentKind.SERVER_PROPERTIES,
|
||||
"-c",
|
||||
"--config"
|
||||
);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
parsed = parseArgument(
|
||||
argument,
|
||||
following,
|
||||
ArgumentKind.BUKKIT_CONFIGURATION,
|
||||
"-b",
|
||||
"--bukkit-settings"
|
||||
);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
parsed = parseArgument(
|
||||
argument,
|
||||
following,
|
||||
ArgumentKind.WORLD_CONTAINER,
|
||||
"-W",
|
||||
"--world-dir",
|
||||
"--universe",
|
||||
"--world-container"
|
||||
);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
return parseArgument(
|
||||
argument,
|
||||
following,
|
||||
ArgumentKind.LEVEL_NAME,
|
||||
"-w",
|
||||
"--world",
|
||||
"--level-name"
|
||||
);
|
||||
}
|
||||
|
||||
private static ParsedArgument parseArgument(
|
||||
String argument,
|
||||
String following,
|
||||
ArgumentKind kind,
|
||||
String... keys
|
||||
) throws IOException {
|
||||
for (String key : keys) {
|
||||
if (argument.equals(key)) {
|
||||
if (following == null || following.isBlank()) {
|
||||
throw new IOException("Startup argument " + key + " requires a value");
|
||||
}
|
||||
return new ParsedArgument(kind, following, true);
|
||||
}
|
||||
if (key.length() == 2 && argument.startsWith(key) && argument.length() > key.length()) {
|
||||
String value = argument.substring(key.length());
|
||||
if (value.startsWith("=")) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
if (value.isBlank()) {
|
||||
throw new IOException("Startup argument " + key + " requires a value");
|
||||
}
|
||||
return new ParsedArgument(kind, value, false);
|
||||
}
|
||||
String prefix = key + "=";
|
||||
if (argument.startsWith(prefix)) {
|
||||
String value = argument.substring(prefix.length());
|
||||
if (value.isBlank()) {
|
||||
throw new IOException("Startup argument " + key + " requires a value");
|
||||
}
|
||||
return new ParsedArgument(kind, value, false);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Path resolveAgainstServerRoot(
|
||||
Path serverRoot,
|
||||
String configuredValue,
|
||||
String defaultValue,
|
||||
String label
|
||||
) throws IOException {
|
||||
String value = configuredValue == null ? defaultValue : configuredValue;
|
||||
Path configured = parsePath(value, label);
|
||||
return configured.isAbsolute()
|
||||
? configured.normalize()
|
||||
: serverRoot.resolve(configured).normalize();
|
||||
}
|
||||
|
||||
private static Path parsePath(String value, String label) throws IOException {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Configured " + label + " is empty");
|
||||
}
|
||||
try {
|
||||
return Path.of(value);
|
||||
} catch (InvalidPathException exception) {
|
||||
throw new IOException("Configured " + label + " is invalid", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String effectiveLevelName(Path serverProperties, String argumentOverride) throws IOException {
|
||||
if (argumentOverride != null) {
|
||||
return requireValue(argumentOverride, "level name");
|
||||
}
|
||||
if (!Files.exists(serverProperties, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return DEFAULT_LEVEL_NAME;
|
||||
}
|
||||
if (!Files.isRegularFile(serverProperties)) {
|
||||
throw new IOException("Configured server properties is not a regular file: " + serverProperties);
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(serverProperties)) {
|
||||
properties.load(input);
|
||||
}
|
||||
return requireValue(properties.getProperty("level-name", DEFAULT_LEVEL_NAME), "level name");
|
||||
}
|
||||
|
||||
private static String requireValue(String value, String label) throws IOException {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Configured " + label + " is empty");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Path canonicalDirectory(Path path, String label) throws IOException {
|
||||
Path canonical = path.toRealPath();
|
||||
if (!Files.isDirectory(canonical)) {
|
||||
throw new IOException(label + " is not a directory: " + path);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
private static Path canonicalPotentialDirectory(Path path, String label) throws IOException {
|
||||
Path normalized = path.toAbsolutePath().normalize();
|
||||
ArrayList<Path> missing = new ArrayList<>();
|
||||
Path existing = normalized;
|
||||
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Path fileName = existing.getFileName();
|
||||
Path parent = existing.getParent();
|
||||
if (fileName == null || parent == null) {
|
||||
throw new IOException(label + " has no existing filesystem ancestor: " + normalized);
|
||||
}
|
||||
missing.add(fileName);
|
||||
existing = parent;
|
||||
}
|
||||
Path canonical = canonicalDirectory(existing, label);
|
||||
for (int index = missing.size() - 1; index >= 0; index--) {
|
||||
canonical = canonical.resolve(missing.get(index));
|
||||
}
|
||||
return canonical.normalize();
|
||||
}
|
||||
|
||||
private enum ArgumentKind {
|
||||
SERVER_PROPERTIES,
|
||||
BUKKIT_CONFIGURATION,
|
||||
LEVEL_NAME,
|
||||
WORLD_CONTAINER
|
||||
}
|
||||
|
||||
private record ParsedArgument(ArgumentKind kind, String value, boolean consumedFollowing) {
|
||||
}
|
||||
|
||||
private record StartupArguments(
|
||||
String serverProperties,
|
||||
String bukkitConfiguration,
|
||||
String levelName,
|
||||
String worldContainer
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,17 @@ import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
||||
private static final Object MUTATION_LOCK = new Object();
|
||||
|
||||
private BukkitWorldConfiguration() {
|
||||
@@ -63,6 +66,31 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
public static String readWorldContainer(File configurationFile) throws IOException {
|
||||
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
Path configurationPath = requiredConfigurationFile.toPath();
|
||||
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return DEFAULT_WORLD_CONTAINER;
|
||||
}
|
||||
if (!Files.isRegularFile(configurationPath)) {
|
||||
throw new IOException("Configured Bukkit configuration is not a regular file: " + configurationPath);
|
||||
}
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(requiredConfigurationFile);
|
||||
Object configured = configuration.get("settings.world-container");
|
||||
if (configured == null) {
|
||||
return DEFAULT_WORLD_CONTAINER;
|
||||
}
|
||||
if (!(configured instanceof String value)) {
|
||||
throw new IOException("Bukkit settings.world-container must be a path string");
|
||||
}
|
||||
if (value.isBlank()) {
|
||||
throw new IOException("Configured world container is empty");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public static GeneratorReplacement replaceIfMatching(
|
||||
File configurationFile,
|
||||
String worldName,
|
||||
@@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration {
|
||||
return new GeneratorReplacement(false, current, replacement);
|
||||
}
|
||||
apply(configuration, requiredWorldName, replacement);
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
saveAtomic(configurationFile.toPath(), configuration, true);
|
||||
return new GeneratorReplacement(true, current, replacement);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration {
|
||||
return false;
|
||||
}
|
||||
apply(configuration, requiredWorldName, requiredRestoration);
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
saveAtomic(configurationFile.toPath(), configuration, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
|
||||
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
|
||||
saveAtomic(target, configuration, false);
|
||||
}
|
||||
|
||||
private static void saveAtomic(
|
||||
Path target,
|
||||
YamlConfiguration configuration,
|
||||
boolean requireAtomicReplacement
|
||||
) throws IOException {
|
||||
Path absoluteTarget = target.toAbsolutePath().normalize();
|
||||
requireRegularConfigurationFile(absoluteTarget);
|
||||
Path parent = absoluteTarget.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
|
||||
@@ -206,9 +243,16 @@ public final class BukkitWorldConfiguration {
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
requireRegularConfigurationFile(absoluteTarget);
|
||||
try {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
if (requireAtomicReplacement) {
|
||||
throw new IOException(
|
||||
"Exact world replacement requires atomic bukkit.yml publication on this filesystem.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
@@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
|
||||
private static YamlConfiguration load(File configurationFile) throws IOException {
|
||||
requireRegularConfigurationFile(configurationFile.toPath());
|
||||
YamlConfiguration configuration = new YamlConfiguration();
|
||||
try {
|
||||
configuration.load(configurationFile);
|
||||
@@ -226,6 +271,18 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRegularConfigurationFile(Path configurationFile) throws IOException {
|
||||
Path path = configurationFile.toAbsolutePath().normalize();
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
path,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
|
||||
throw new IOException("bukkit.yml must be an existing regular file and was not changed: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot snapshot(
|
||||
YamlConfiguration configuration,
|
||||
String worldName
|
||||
|
||||
@@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap {
|
||||
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
|
||||
return ReconcileAction.ROLLED_BACK;
|
||||
}
|
||||
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
|
||||
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + ".");
|
||||
return ReconcileAction.ROLLED_BACK;
|
||||
}
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
|
||||
Transaction active = transaction;
|
||||
if (active.phase() == Phase.PREPARED) {
|
||||
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
|
||||
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
|
||||
feedback.accept("Cancelled incomplete Iris world replacement for " + active.worldKey() + ".");
|
||||
return ReconcileAction.ROLLED_BACK;
|
||||
}
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
active = active.withPhase(Phase.ARMED);
|
||||
WorldReplacementJournal.write(dataDirectory, active);
|
||||
}
|
||||
if (active.phase() == Phase.ARMED) {
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
|
||||
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
|
||||
feedback.accept("Cancelled Iris world replacement for " + active.worldKey() + ".");
|
||||
return ReconcileAction.ROLLED_BACK;
|
||||
}
|
||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
WorldReplacementFilesystem.publish(
|
||||
paths,
|
||||
active.originalTargetPresent(),
|
||||
@@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap {
|
||||
return ReconcileAction.PUBLISHED;
|
||||
}
|
||||
if (active.phase() == Phase.PUBLISHED) {
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
|
||||
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
|
||||
feedback.accept("Rolled back Iris world replacement for " + active.worldKey() + ".");
|
||||
return ReconcileAction.ROLLED_BACK;
|
||||
}
|
||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
WorldReplacementFilesystem.publish(
|
||||
paths,
|
||||
active.originalTargetPresent(),
|
||||
@@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap {
|
||||
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
|
||||
? transaction
|
||||
: transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||
if (rollback != transaction) {
|
||||
if (transaction.phase() != Phase.ROLLBACK_PENDING) {
|
||||
WorldReplacementJournal.write(dataDirectory, rollback);
|
||||
}
|
||||
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
public final class WorldReplacementBootstrapMarker {
|
||||
private static volatile boolean bootstrappedThisProcess;
|
||||
|
||||
private WorldReplacementBootstrapMarker() {
|
||||
}
|
||||
|
||||
public static boolean wasBootstrappedThisProcess() {
|
||||
return bootstrappedThisProcess;
|
||||
}
|
||||
|
||||
public static void markBootstrapped() {
|
||||
bootstrappedThisProcess = true;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
@@ -26,6 +28,11 @@ import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class WorldReplacementFilesystem {
|
||||
private static final List<Path> PAPER_WORLD_METADATA = List.of(
|
||||
Path.of("data/paper/metadata.dat"),
|
||||
Path.of("data/paper/level_overrides.dat"),
|
||||
Path.of("data/minecraft/world_gen_settings.dat")
|
||||
);
|
||||
private static final Pattern STAGE_NAME = Pattern.compile(
|
||||
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
|
||||
private static final Pattern BACKUP_NAME = Pattern.compile(
|
||||
@@ -37,7 +44,7 @@ public final class WorldReplacementFilesystem {
|
||||
public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
|
||||
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
|
||||
UUID requiredId = Objects.requireNonNull(id, "id");
|
||||
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId;
|
||||
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().key() + "-" + requiredId;
|
||||
return new ReplacementPaths(
|
||||
requiredTarget.worldDirectory(),
|
||||
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
|
||||
@@ -45,6 +52,17 @@ public final class WorldReplacementFilesystem {
|
||||
);
|
||||
}
|
||||
|
||||
public static void requireExistingTarget(ReplacementPaths paths) throws IOException {
|
||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||
State state = inspect(requiredPaths);
|
||||
if (!state.targetPresent()) {
|
||||
throw new IOException("overwrite=true requires an existing exact world slot; use ordinary create for a new world.");
|
||||
}
|
||||
if (state.stagePresent() || state.backupPresent()) {
|
||||
throw new IOException("A replacement artifact already exists for this transaction.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void publish(
|
||||
ReplacementPaths paths,
|
||||
boolean originalTargetPresent,
|
||||
@@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem {
|
||||
if (state.stagePresent()) {
|
||||
requireSafeTree(requiredPaths.stage(), "replacement stage");
|
||||
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
|
||||
if (originalTargetPresent) {
|
||||
Path retainedWorld = state.targetPresent()
|
||||
? requiredPaths.target()
|
||||
: requiredPaths.backup();
|
||||
preservePaperWorldMetadata(retainedWorld, requiredPaths.stage());
|
||||
}
|
||||
if (state.targetPresent()) {
|
||||
if (state.backupPresent()) {
|
||||
throw new IOException("Replacement target, stage, and backup are all present.");
|
||||
@@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem {
|
||||
}
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
if (originalTargetPresent) {
|
||||
preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target());
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
|
||||
@@ -298,6 +327,71 @@ public final class WorldReplacementFilesystem {
|
||||
}
|
||||
}
|
||||
|
||||
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
|
||||
requireDirectory(retainedWorld, "retained world");
|
||||
requireDirectory(replacementWorld, "replacement world");
|
||||
requireDirectory(retainedWorld.resolve("data"), "retained world data");
|
||||
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
|
||||
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
|
||||
for (Path relative : PAPER_WORLD_METADATA) {
|
||||
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
|
||||
if (!sourceAttributes.isRegularFile()) {
|
||||
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
|
||||
}
|
||||
}
|
||||
ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
|
||||
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
|
||||
ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data");
|
||||
for (Path relative : PAPER_WORLD_METADATA) {
|
||||
Path destination = replacementWorld.resolve(relative);
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
BasicFileAttributes destinationAttributes = requireSafeEntry(destination);
|
||||
if (!destinationAttributes.isRegularFile()) {
|
||||
throw new IOException("Replacement Paper world metadata is not a regular file: " + relative);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
copyMetadataFile(retainedWorld.resolve(relative), destination);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureDirectory(Path directory, String label) throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireDirectory(directory, label);
|
||||
return;
|
||||
}
|
||||
Files.createDirectory(directory);
|
||||
forceDirectoryRequired(directory.getParent());
|
||||
}
|
||||
|
||||
private static void copyMetadataFile(Path source, Path destination) throws IOException {
|
||||
Path temporary = destination.resolveSibling("." + destination.getFileName() + ".iris-replace.tmp");
|
||||
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) {
|
||||
BasicFileAttributes attributes = requireSafeEntry(temporary);
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Replacement metadata staging path is unsafe: " + temporary);
|
||||
}
|
||||
Files.delete(temporary);
|
||||
}
|
||||
try {
|
||||
Files.copy(source, temporary, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
throw new IOException(
|
||||
"Exact world replacement requires atomic Paper metadata publication on this filesystem.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
forceDirectoryRequired(destination.getParent());
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
@@ -313,13 +407,37 @@ public final class WorldReplacementFilesystem {
|
||||
}
|
||||
|
||||
private static void move(Path source, Path target) throws IOException {
|
||||
forceDirectoryRequired(source.getParent());
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(source, target);
|
||||
throw new IOException(
|
||||
"Exact world replacement requires atomic directory moves on this filesystem.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
|
||||
forceDirectoryAfterCommit(source.getParent());
|
||||
}
|
||||
|
||||
private static void forceDirectoryRequired(Path directory) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException failure) {
|
||||
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectoryAfterCommit(Path directory) {
|
||||
try {
|
||||
forceDirectoryRequired(directory);
|
||||
} catch (IOException failure) {
|
||||
IrisLogging.reportError(
|
||||
"A world-replacement move completed, but its parent directory could not be durability-synced.",
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
@@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class WorldReplacementJournal {
|
||||
@@ -46,6 +50,12 @@ public final class WorldReplacementJournal {
|
||||
}
|
||||
}
|
||||
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
|
||||
Set<WorldSlotKey> worldKeys = new HashSet<>();
|
||||
for (Transaction transaction : transactions) {
|
||||
if (!worldKeys.add(transaction.worldKey())) {
|
||||
throw new IOException("Multiple replacement journals target " + transaction.worldKey() + ".");
|
||||
}
|
||||
}
|
||||
return List.copyOf(transactions);
|
||||
}
|
||||
|
||||
@@ -77,8 +87,9 @@ public final class WorldReplacementJournal {
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
forceDirectoryRequired(directory);
|
||||
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
|
||||
forceDirectory(directory);
|
||||
forceDirectoryAfterCommit(directory);
|
||||
}
|
||||
|
||||
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
|
||||
@@ -91,31 +102,44 @@ public final class WorldReplacementJournal {
|
||||
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
|
||||
throw new IOException("The configured level root changed after the world replacement was staged.");
|
||||
}
|
||||
String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
|
||||
String expectedWorldName;
|
||||
try {
|
||||
expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("The replacement journal targets an ambiguous logical world name.", failure);
|
||||
}
|
||||
if (!expectedWorldName.equals(requiredTransaction.worldName())) {
|
||||
throw new IOException("The logical world name changed after the world replacement was staged.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) {
|
||||
public static String logicalWorldName(Path levelRoot, WorldSlotKey worldKey) {
|
||||
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
if ("iris".equals(requiredWorldKey.getNamespace())) {
|
||||
return requiredWorldKey.getKey();
|
||||
}
|
||||
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
Path fileName = requiredLevelRoot.getFileName();
|
||||
if (fileName == null || fileName.toString().isBlank()) {
|
||||
throw new IllegalArgumentException("Level root must have a logical world name.");
|
||||
}
|
||||
String levelName = fileName.toString();
|
||||
if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) {
|
||||
if ("iris".equals(requiredWorldKey.namespace())) {
|
||||
String logicalName = requiredWorldKey.key();
|
||||
if (logicalName.equals(levelName)
|
||||
|| logicalName.equals(levelName + "_nether")
|
||||
|| logicalName.equals(levelName + "_the_end")) {
|
||||
throw new IllegalArgumentException(
|
||||
"An Iris-managed world cannot use a configured vanilla world alias."
|
||||
);
|
||||
}
|
||||
return logicalName;
|
||||
}
|
||||
if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) {
|
||||
return levelName;
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
|
||||
if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) {
|
||||
return levelName + "_nether";
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
|
||||
if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) {
|
||||
return levelName + "_the_end";
|
||||
}
|
||||
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
|
||||
@@ -135,9 +159,11 @@ public final class WorldReplacementJournal {
|
||||
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
|
||||
throw new IOException("Replacement journal filename does not match its transaction id.");
|
||||
}
|
||||
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
|
||||
if (worldKey == null) {
|
||||
throw new IOException("Replacement journal contains an invalid world key.");
|
||||
WorldSlotKey worldKey;
|
||||
try {
|
||||
worldKey = WorldSlotKey.parse(required(properties, "worldKey"));
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid world key.", failure);
|
||||
}
|
||||
String worldName = exact(properties, "worldName");
|
||||
Path recordedLevelRoot = recordedLevelRoot(properties);
|
||||
@@ -300,26 +326,46 @@ public final class WorldReplacementJournal {
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
forceDirectoryRequired(parent);
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException failure) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
throw new IOException(
|
||||
"Exact world replacement requires atomic journal publication on this filesystem.",
|
||||
failure
|
||||
);
|
||||
}
|
||||
forceDirectory(parent);
|
||||
forceDirectoryAfterCommit(parent);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
private static void forceDirectoryRequired(Path directory) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException failure) {
|
||||
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectoryAfterCommit(Path directory) {
|
||||
try {
|
||||
forceDirectoryRequired(directory);
|
||||
} catch (IOException failure) {
|
||||
IrisLogging.reportError(
|
||||
"A world-replacement journal change completed, but its parent directory could not be durability-synced.",
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record Transaction(
|
||||
UUID id,
|
||||
NamespacedKey worldKey,
|
||||
WorldSlotKey worldKey,
|
||||
String worldName,
|
||||
Path levelRoot,
|
||||
String dimension,
|
||||
|
||||
+274
-163
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.core.IrisDatapackCompiler;
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -27,7 +28,9 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
@@ -40,9 +43,20 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public final class DefaultPackBootstrapProvisioner {
|
||||
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
|
||||
private static final List<PackSpec> DEFAULT_PACKS = List.of(
|
||||
new PackSpec(
|
||||
"overworld",
|
||||
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
|
||||
"overworld"
|
||||
),
|
||||
new PackSpec(
|
||||
"underworld",
|
||||
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
|
||||
"underworld"
|
||||
)
|
||||
);
|
||||
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
|
||||
private static final int MARKER_SCHEMA = 3;
|
||||
private static final int MARKER_SCHEMA = 4;
|
||||
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
|
||||
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
|
||||
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
|
||||
@@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
private DefaultPackBootstrapProvisioner() {
|
||||
}
|
||||
|
||||
static List<PackSpec> defaultPacks() {
|
||||
return DEFAULT_PACKS;
|
||||
}
|
||||
|
||||
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
|
||||
return provision(dataDirectory, feedback, BukkitStartupPaths.resolveCurrent());
|
||||
}
|
||||
|
||||
public static ProvisionResult provision(
|
||||
Path dataDirectory,
|
||||
Consumer<String> feedback,
|
||||
BukkitStartupPaths startupPaths
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(dataDirectory, "dataDirectory");
|
||||
Objects.requireNonNull(feedback, "feedback");
|
||||
Path serverRoot = Path.of("").toAbsolutePath().normalize();
|
||||
Path levelRoot = resolveLevelRoot(serverRoot);
|
||||
Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.followRedirects(HttpClient.Redirect.ALWAYS)
|
||||
.build();
|
||||
ProvisionOptions options = new ProvisionOptions(
|
||||
DEFAULT_SOURCE,
|
||||
DEFAULT_PACKS,
|
||||
client,
|
||||
Clock.systemUTC(),
|
||||
Duration.ofMinutes(30),
|
||||
@@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
try {
|
||||
return isProvisioned(
|
||||
dataDirectory,
|
||||
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
|
||||
BukkitStartupPaths.resolveCurrent().levelRoot()
|
||||
);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
return false;
|
||||
@@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
|
||||
static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
|
||||
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
|
||||
}
|
||||
|
||||
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
|
||||
try {
|
||||
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
|
||||
Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
|
||||
Path bootstrapRoot = normalizedData.resolve("bootstrap");
|
||||
Path datapackRoot = worldDatapackRoot(normalizedLevel);
|
||||
Path packRoot = normalizedData.resolve("packs/overworld");
|
||||
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
|
||||
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
|
||||
if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) {
|
||||
return false;
|
||||
}
|
||||
Properties marker = loadProperties(markerFile);
|
||||
@@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
.equals(marker.getProperty("compilerIdentity"))) {
|
||||
return false;
|
||||
}
|
||||
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
|
||||
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
|
||||
for (PackSpec spec : requiredPacks) {
|
||||
Path packRoot = normalizedData.resolve("packs").resolve(spec.key());
|
||||
if (!isPackRoot(packRoot, spec)
|
||||
|| !spec.source().toString().equals(marker.getProperty(markerKey(spec, "source")))
|
||||
|| !spec.requiredDimension().equals(marker.getProperty(markerKey(spec, "requiredDimension")))
|
||||
|| !directoryFingerprint(packRoot).equals(marker.getProperty(markerKey(spec, "fingerprint")))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
|
||||
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
|
||||
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
|
||||
normalizedData,
|
||||
@@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
|
||||
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
|
||||
Path packsRoot = normalizedData.resolve("packs");
|
||||
Path packRoot = packsRoot.resolve("overworld");
|
||||
Path bootstrapRoot = normalizedData.resolve("bootstrap");
|
||||
Path legacyDatapackRoot = bootstrapRoot.resolve("datapack");
|
||||
Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks");
|
||||
@@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
Files.createDirectories(datapacksRoot);
|
||||
|
||||
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
|
||||
boolean existingPack = isPackRoot(packRoot);
|
||||
boolean existingDatapack = isDatapackRoot(datapackRoot);
|
||||
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
|
||||
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
|
||||
boolean unchangedManagedPack = markerOwned
|
||||
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
|
||||
boolean managedDefault = !existingPack || unchangedManagedPack;
|
||||
if (Files.isSymbolicLink(packRoot)) {
|
||||
managedDefault = false;
|
||||
List<PackPlan> plans = new ArrayList<>(options.packs().size());
|
||||
for (PackSpec spec : options.packs()) {
|
||||
Path packRoot = packsRoot.resolve(spec.key()).normalize();
|
||||
if (!Objects.equals(packRoot.getParent(), packsRoot)) {
|
||||
throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot);
|
||||
}
|
||||
boolean existingPack = isPackRoot(packRoot, spec);
|
||||
String currentFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
|
||||
boolean markerOwned = "true".equals(markerProperty(previousMarker, spec, "managed"));
|
||||
boolean unchangedManagedPack = markerOwned
|
||||
&& currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint"));
|
||||
boolean managed = !existingPack || unchangedManagedPack;
|
||||
if (Files.isSymbolicLink(packRoot)) {
|
||||
managed = false;
|
||||
}
|
||||
String retainedSourceSha = markerProperty(previousMarker, spec, "sourceSha256");
|
||||
Archive archive = managed
|
||||
? acquireArchive(cacheRoot, previousMarker, spec, feedback, options)
|
||||
: new Archive(null, retainedSourceSha == null || retainedSourceSha.isBlank()
|
||||
? currentFingerprint
|
||||
: retainedSourceSha);
|
||||
boolean replacePack = !existingPack || managed
|
||||
&& (!archive.sha256().equals(markerProperty(previousMarker, spec, "sourceSha256"))
|
||||
|| !currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint")));
|
||||
plans.add(new PackPlan(spec, packRoot, existingPack, managed, archive, replacePack));
|
||||
}
|
||||
Archive archive = managedDefault
|
||||
? acquireArchive(cacheRoot, previousMarker, feedback, options)
|
||||
: new Archive(null, currentPackFingerprint);
|
||||
boolean replacePack = !existingPack || managedDefault
|
||||
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|
||||
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
|
||||
|
||||
Path stagedPack = null;
|
||||
Path extractionRoot = null;
|
||||
Map<PackPlan, Path> stagedPacks = new LinkedHashMap<>();
|
||||
List<Path> extractionRoots = new ArrayList<>();
|
||||
Path compileContainer = null;
|
||||
Path packBackup = null;
|
||||
Path datapackBackup = null;
|
||||
boolean packReplaced = false;
|
||||
boolean datapackReplaced = false;
|
||||
List<PackPublication> packPublications = new ArrayList<>();
|
||||
boolean committed = false;
|
||||
try {
|
||||
if (replacePack) {
|
||||
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
|
||||
for (PackPlan plan : plans) {
|
||||
if (!plan.replace()) {
|
||||
continue;
|
||||
}
|
||||
Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID());
|
||||
extractionRoots.add(extractionRoot);
|
||||
Files.createDirectories(extractionRoot);
|
||||
Path extractedPack = extractArchive(archive.path(), extractionRoot);
|
||||
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
|
||||
Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec());
|
||||
Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID());
|
||||
copyDirectory(extractedPack, stagedPack);
|
||||
validatePackRoot(stagedPack);
|
||||
packBackup = replaceWithBackup(stagedPack, packRoot);
|
||||
packReplaced = true;
|
||||
validatePackRoot(stagedPack, plan.spec());
|
||||
stagedPacks.put(plan, stagedPack);
|
||||
}
|
||||
for (Map.Entry<PackPlan, Path> entry : stagedPacks.entrySet()) {
|
||||
Path backup = replaceWithBackup(entry.getValue(), entry.getKey().root());
|
||||
packPublications.add(new PackPublication(entry.getKey().root(), backup));
|
||||
}
|
||||
|
||||
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
|
||||
@@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
|
||||
String aggregateFingerprint = packRootsFingerprint(packRoots);
|
||||
boolean rebuildDatapack = replacePack
|
||||
boolean anyPackReplaced = !packPublications.isEmpty();
|
||||
boolean rebuildDatapack = anyPackReplaced
|
||||
|| !existingDatapack
|
||||
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|
||||
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
|
||||
@@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
datapackReplaced = true;
|
||||
}
|
||||
|
||||
validatePackRoot(packRoot);
|
||||
for (PackPlan plan : plans) {
|
||||
validatePackRoot(plan.root(), plan.spec());
|
||||
}
|
||||
if (!isDatapackRoot(datapackRoot)) {
|
||||
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
|
||||
}
|
||||
String finalPackFingerprint = directoryFingerprint(packRoot);
|
||||
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
|
||||
normalizedData,
|
||||
options.levelRoot());
|
||||
@@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
|
||||
Properties marker = new Properties();
|
||||
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
|
||||
marker.setProperty("source", options.source().toString());
|
||||
marker.setProperty("sourceSha256", archive.sha256());
|
||||
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
|
||||
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
|
||||
for (PackPlan plan : plans) {
|
||||
marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString());
|
||||
marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256());
|
||||
marker.setProperty(markerKey(plan.spec(), "managed"), Boolean.toString(plan.managed()));
|
||||
marker.setProperty(markerKey(plan.spec(), "fingerprint"), directoryFingerprint(plan.root()));
|
||||
marker.setProperty(markerKey(plan.spec(), "requiredDimension"), plan.spec().requiredDimension());
|
||||
}
|
||||
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
|
||||
marker.setProperty("compilerIdentity", compilerIdentity);
|
||||
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
|
||||
marker.setProperty("datapackPath", datapackRoot.toString());
|
||||
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
|
||||
storePropertiesAtomic(markerFile, marker);
|
||||
committed = true;
|
||||
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
|
||||
PROVISIONED_THIS_STARTUP.set(true);
|
||||
deleteQuietly(packBackup, feedback);
|
||||
for (PackPublication publication : packPublications) {
|
||||
deleteQuietly(publication.backup(), feedback);
|
||||
}
|
||||
deleteQuietly(datapackBackup, feedback);
|
||||
deleteQuietly(legacyDatapackRoot, feedback);
|
||||
|
||||
boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed);
|
||||
ProvisionStatus status;
|
||||
if (!existingPack && !existingDatapack) {
|
||||
if (everyPackMissing && !existingDatapack) {
|
||||
status = ProvisionStatus.INSTALLED;
|
||||
} else if (replacePack || rebuildDatapack) {
|
||||
} else if (anyPackReplaced || rebuildDatapack) {
|
||||
status = ProvisionStatus.UPDATED;
|
||||
} else {
|
||||
status = ProvisionStatus.UNCHANGED;
|
||||
}
|
||||
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
|
||||
return new ProvisionResult(packRoot, datapackRoot, status);
|
||||
feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + ".");
|
||||
Map<String, Path> provisionedPacks = new LinkedHashMap<>();
|
||||
for (PackPlan plan : plans) {
|
||||
provisionedPacks.put(plan.spec().key(), plan.root());
|
||||
}
|
||||
return new ProvisionResult(provisionedPacks, datapackRoot, status);
|
||||
} catch (IOException failure) {
|
||||
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
if (!committed) {
|
||||
IOException rollbackFailure = rollback(
|
||||
packPublications,
|
||||
datapackRoot,
|
||||
datapackBackup,
|
||||
datapackReplaced
|
||||
);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
if (!committed) {
|
||||
IOException rollbackFailure = rollback(
|
||||
packPublications,
|
||||
datapackRoot,
|
||||
datapackBackup,
|
||||
datapackReplaced
|
||||
);
|
||||
if (rollbackFailure != null) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw new IOException("Iris bootstrap provisioning failed", failure);
|
||||
} finally {
|
||||
deleteQuietly(stagedPack, feedback);
|
||||
deleteQuietly(extractionRoot, feedback);
|
||||
for (Path stagedPack : stagedPacks.values()) {
|
||||
deleteQuietly(stagedPack, feedback);
|
||||
}
|
||||
for (Path extractionRoot : extractionRoots) {
|
||||
deleteQuietly(extractionRoot, feedback);
|
||||
}
|
||||
deleteQuietly(compileContainer, feedback);
|
||||
}
|
||||
}
|
||||
@@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
private static Archive acquireArchive(
|
||||
Path cacheRoot,
|
||||
Properties marker,
|
||||
PackSpec spec,
|
||||
Consumer<String> feedback,
|
||||
ProvisionOptions options
|
||||
) throws IOException {
|
||||
Path archivePath = cacheRoot.resolve("default-overworld.zip");
|
||||
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
|
||||
Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip");
|
||||
Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties");
|
||||
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
|
||||
boolean validCache = false;
|
||||
if (Files.isRegularFile(archivePath)) {
|
||||
try {
|
||||
validCache = validateArchive(archivePath);
|
||||
validCache = validateArchive(archivePath, spec);
|
||||
} catch (IOException exception) {
|
||||
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
|
||||
feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement.");
|
||||
}
|
||||
}
|
||||
String cachedSource = metadata.getProperty("source", "");
|
||||
boolean cacheMatchesSource = validCache && (spec.source().toString().equals(cachedSource)
|
||||
|| cachedSource.isBlank() && "overworld".equals(spec.key()));
|
||||
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
|
||||
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
|
||||
boolean fresh = cacheMatchesSource
|
||||
&& options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
|
||||
if (fresh) {
|
||||
if (cachedSource.isBlank()) {
|
||||
metadata.setProperty("source", spec.source().toString());
|
||||
storePropertiesAtomic(metadataPath, metadata);
|
||||
}
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
}
|
||||
|
||||
IOException networkFailure = null;
|
||||
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
|
||||
try {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(spec.source())
|
||||
.timeout(options.requestTimeout())
|
||||
.header("Accept", "application/octet-stream")
|
||||
.header("User-Agent", "Iris-Bootstrap")
|
||||
.GET();
|
||||
if (validCache) {
|
||||
if (cacheMatchesSource) {
|
||||
String etag = metadata.getProperty("etag");
|
||||
String lastModified = metadata.getProperty("lastModified");
|
||||
if (etag != null && !etag.isBlank()) {
|
||||
@@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
int status = response.statusCode();
|
||||
if (status == 304 && validCache) {
|
||||
if (status == 304 && cacheMatchesSource) {
|
||||
close(response.body());
|
||||
metadata.setProperty("source", spec.source().toString());
|
||||
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
|
||||
storePropertiesAtomic(metadataPath, metadata);
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
@@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
|
||||
try {
|
||||
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
|
||||
copyLimited(input, output, options.maxArchiveBytes());
|
||||
copyLimited(input, output, options.maxArchiveBytes(), spec);
|
||||
}
|
||||
if (!validateArchive(temporary)) {
|
||||
throw new IOException("Downloaded default overworld archive is invalid");
|
||||
if (!validateArchive(temporary, spec)) {
|
||||
throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid");
|
||||
}
|
||||
move(temporary, archivePath, true);
|
||||
} finally {
|
||||
@@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
Properties updated = new Properties();
|
||||
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
|
||||
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
|
||||
updated.setProperty("source", spec.source().toString());
|
||||
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
|
||||
updated.setProperty("sha256", sha256(archivePath));
|
||||
storePropertiesAtomic(metadataPath, updated);
|
||||
feedback.accept("Downloaded the Iris default overworld beta archive.");
|
||||
feedback.accept("Downloaded the Iris " + spec.key() + " beta archive.");
|
||||
return new Archive(archivePath, updated.getProperty("sha256"));
|
||||
}
|
||||
close(response.body());
|
||||
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
|
||||
IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status);
|
||||
if (!retryableStatus(status)) {
|
||||
networkFailure = statusFailure;
|
||||
break;
|
||||
@@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
networkFailure = statusFailure;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Default overworld download was interrupted", exception);
|
||||
throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception);
|
||||
} catch (IOException exception) {
|
||||
networkFailure = exception;
|
||||
}
|
||||
@@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
if (validCache) {
|
||||
feedback.accept("Default overworld download failed; using the validated cached archive.");
|
||||
if (cacheMatchesSource) {
|
||||
feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive.");
|
||||
return new Archive(archivePath, sha256(archivePath));
|
||||
}
|
||||
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) {
|
||||
String sourceSha = marker.getProperty("sourceSha256");
|
||||
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
|
||||
String sourceSha = markerProperty(marker, spec, "sourceSha256");
|
||||
return new Archive(null, sourceSha);
|
||||
}
|
||||
throw networkFailure == null
|
||||
? new IOException("Default overworld archive is unavailable and no valid cache exists")
|
||||
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
|
||||
? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists")
|
||||
: new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure);
|
||||
}
|
||||
|
||||
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) {
|
||||
String sourceSha = marker.getProperty("sourceSha256");
|
||||
String expectedFingerprint = marker.getProperty("defaultPackFingerprint");
|
||||
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
|
||||
String sourceSha = markerProperty(marker, spec, "sourceSha256");
|
||||
String expectedFingerprint = markerProperty(marker, spec, "fingerprint");
|
||||
if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
|
||||
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key());
|
||||
try {
|
||||
return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint);
|
||||
return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Path resolveLevelRoot(Path serverRoot) throws IOException {
|
||||
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
|
||||
String levelName = readConfiguredLevelName(normalizedServerRoot);
|
||||
Path configured = Path.of(levelName);
|
||||
return configured.isAbsolute()
|
||||
? configured.normalize()
|
||||
: normalizedServerRoot.resolve(configured).normalize();
|
||||
public static Path resolveLevelRoot(Path serverRoot) throws IOException {
|
||||
return BukkitStartupPaths.resolve(serverRoot).levelRoot();
|
||||
}
|
||||
|
||||
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
|
||||
String levelName = "world";
|
||||
Path propertiesFile = serverRoot.resolve("server.properties");
|
||||
if (Files.isRegularFile(propertiesFile)) {
|
||||
Properties properties = loadProperties(propertiesFile);
|
||||
levelName = properties.getProperty("level-name", levelName);
|
||||
}
|
||||
|
||||
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
|
||||
for (int index = 0; index < arguments.length; index++) {
|
||||
String argument = arguments[index];
|
||||
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
|
||||
String parsed = parseLevelArgument(argument, following);
|
||||
if (parsed != null) {
|
||||
levelName = parsed;
|
||||
}
|
||||
}
|
||||
if (levelName.isBlank()) {
|
||||
throw new IOException("Configured level name is empty");
|
||||
}
|
||||
return levelName;
|
||||
}
|
||||
|
||||
private static String parseLevelArgument(String argument, String following) {
|
||||
for (String key : List.of("-w", "--level-name", "--world")) {
|
||||
if (argument.equals(key) && following != null && !following.isBlank()) {
|
||||
return following;
|
||||
}
|
||||
String prefix = key + "=";
|
||||
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
|
||||
return argument.substring(prefix.length());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean validateArchive(Path archive) throws IOException {
|
||||
private static boolean validateArchive(Path archive, PackSpec spec) throws IOException {
|
||||
int entries = 0;
|
||||
long expanded = 0L;
|
||||
boolean dimensionFound = false;
|
||||
@@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entries++;
|
||||
if (entries > MAX_ARCHIVE_ENTRIES) {
|
||||
throw new IOException("Default overworld archive contains too many entries");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
|
||||
}
|
||||
String name = normalizedZipEntry(entry.getName());
|
||||
if (!paths.add(name)) {
|
||||
throw new IOException("Default overworld archive contains duplicate path " + name);
|
||||
throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name);
|
||||
}
|
||||
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
|
||||
String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json";
|
||||
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
|
||||
dimensionFound = true;
|
||||
}
|
||||
if (!entry.isDirectory()) {
|
||||
@@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
while ((read = zip.read(buffer)) >= 0) {
|
||||
expanded += read;
|
||||
if (expanded > MAX_EXPANDED_BYTES) {
|
||||
throw new IOException("Default overworld archive expands beyond the safety limit");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,9 +526,9 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
return entries > 0 && dimensionFound;
|
||||
}
|
||||
|
||||
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
|
||||
private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException {
|
||||
if (archive == null) {
|
||||
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
|
||||
throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild");
|
||||
}
|
||||
long expanded = 0L;
|
||||
int entries = 0;
|
||||
@@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entries++;
|
||||
if (entries > MAX_ARCHIVE_ENTRIES) {
|
||||
throw new IOException("Default overworld archive contains too many entries");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
|
||||
}
|
||||
String name = normalizedZipEntry(entry.getName());
|
||||
Path output = extractionRoot.resolve(name).normalize();
|
||||
if (!output.startsWith(extractionRoot)) {
|
||||
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
|
||||
throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName());
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(output);
|
||||
@@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
while ((read = zip.read(buffer)) >= 0) {
|
||||
expanded += read;
|
||||
if (expanded > MAX_EXPANDED_BYTES) {
|
||||
throw new IOException("Default overworld archive expands beyond the safety limit");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
|
||||
}
|
||||
file.write(buffer, 0, read);
|
||||
}
|
||||
@@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
if (isPackRoot(extractionRoot)) {
|
||||
if (isPackRoot(extractionRoot, spec)) {
|
||||
return extractionRoot;
|
||||
}
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
|
||||
for (Path child : children) {
|
||||
if (Files.isDirectory(child) && isPackRoot(child)) {
|
||||
if (Files.isDirectory(child) && isPackRoot(child, spec)) {
|
||||
candidates.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.size() != 1) {
|
||||
throw new IOException("Default overworld archive has an invalid root layout");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout");
|
||||
}
|
||||
return candidates.getFirst();
|
||||
}
|
||||
|
||||
private static String normalizedZipEntry(String raw) throws IOException {
|
||||
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
|
||||
throw new IOException("Invalid path in default overworld archive");
|
||||
throw new IOException("Invalid path in Iris bootstrap pack archive");
|
||||
}
|
||||
String normalized = raw.replace('\\', '/');
|
||||
Path path = Path.of(normalized).normalize();
|
||||
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IOException("Unsafe path in default overworld archive: " + raw);
|
||||
throw new IOException("Unsafe path in Iris bootstrap pack archive: " + raw);
|
||||
}
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private static boolean isPackRoot(Path path) {
|
||||
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
|
||||
private static boolean isPackRoot(Path path, PackSpec spec) {
|
||||
return path != null
|
||||
&& Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json"));
|
||||
}
|
||||
|
||||
private static void validatePackRoot(Path path) throws IOException {
|
||||
if (!isPackRoot(path)) {
|
||||
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
|
||||
private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
|
||||
if (!isPackRoot(path, spec)) {
|
||||
throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/"
|
||||
+ spec.requiredDimension() + ".json at " + path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,26 +693,27 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
|
||||
private static IOException rollback(
|
||||
Path packRoot,
|
||||
Path packBackup,
|
||||
boolean packReplaced,
|
||||
List<PackPublication> packPublications,
|
||||
Path datapackRoot,
|
||||
Path datapackBackup,
|
||||
boolean datapackReplaced
|
||||
) {
|
||||
IOException failure = null;
|
||||
try {
|
||||
restore(packRoot, packBackup, packReplaced);
|
||||
restore(datapackRoot, datapackBackup, datapackReplaced);
|
||||
} catch (IOException exception) {
|
||||
failure = exception;
|
||||
}
|
||||
try {
|
||||
restore(datapackRoot, datapackBackup, datapackReplaced);
|
||||
} catch (IOException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
for (int index = packPublications.size() - 1; index >= 0; index--) {
|
||||
PackPublication publication = packPublications.get(index);
|
||||
try {
|
||||
restore(publication.root(), publication.backup(), true);
|
||||
} catch (IOException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
@@ -726,14 +784,19 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
|
||||
private static void copyLimited(
|
||||
InputStream input,
|
||||
OutputStream output,
|
||||
long limit,
|
||||
PackSpec spec
|
||||
) throws IOException {
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0L;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > limit) {
|
||||
throw new IOException("Default overworld archive exceeds the download size limit");
|
||||
throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
@@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
Thread.sleep(duration.toMillis());
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Default overworld retry wait was interrupted", exception);
|
||||
throw new IOException("Iris bootstrap pack retry wait was interrupted", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +850,24 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
}
|
||||
}
|
||||
|
||||
private static String markerKey(PackSpec spec, String property) {
|
||||
return "pack." + spec.key() + "." + property;
|
||||
}
|
||||
|
||||
private static String markerProperty(Properties marker, PackSpec spec, String property) {
|
||||
String current = marker.getProperty(markerKey(spec, property));
|
||||
if (current != null || !"overworld".equals(spec.key())) {
|
||||
return current;
|
||||
}
|
||||
return switch (property) {
|
||||
case "source" -> marker.getProperty("source");
|
||||
case "sourceSha256" -> marker.getProperty("sourceSha256");
|
||||
case "managed" -> marker.getProperty("managedDefault");
|
||||
case "fingerprint" -> marker.getProperty("defaultPackFingerprint");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static void close(InputStream input) {
|
||||
try {
|
||||
input.close();
|
||||
@@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
UNCHANGED
|
||||
}
|
||||
|
||||
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
|
||||
public record ProvisionResult(Map<String, Path> packRoots, Path datapackRoot, ProvisionStatus status) {
|
||||
public ProvisionResult {
|
||||
Objects.requireNonNull(packRoot, "packRoot");
|
||||
packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots"));
|
||||
Objects.requireNonNull(datapackRoot, "datapackRoot");
|
||||
Objects.requireNonNull(status, "status");
|
||||
}
|
||||
}
|
||||
|
||||
record ProvisionOptions(
|
||||
URI source,
|
||||
List<PackSpec> packs,
|
||||
HttpClient client,
|
||||
Clock clock,
|
||||
Duration refreshInterval,
|
||||
@@ -820,19 +901,49 @@ public final class DefaultPackBootstrapProvisioner {
|
||||
Path levelRoot
|
||||
) {
|
||||
ProvisionOptions {
|
||||
Objects.requireNonNull(source, "source");
|
||||
packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
|
||||
Objects.requireNonNull(client, "client");
|
||||
Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(refreshInterval, "refreshInterval");
|
||||
Objects.requireNonNull(requestTimeout, "requestTimeout");
|
||||
Objects.requireNonNull(retryDelay, "retryDelay");
|
||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
if (attempts < 1 || maxArchiveBytes < 1L) {
|
||||
if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) {
|
||||
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (PackSpec pack : packs) {
|
||||
if (!keys.add(pack.key())) {
|
||||
throw new IllegalArgumentException("Duplicate bootstrap pack key '" + pack.key() + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record PackSpec(String key, URI source, String requiredDimension) {
|
||||
PackSpec {
|
||||
Objects.requireNonNull(key, "key");
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(requiredDimension, "requiredDimension");
|
||||
if (!key.matches("[a-z0-9_-]+") || !requiredDimension.matches("[a-z0-9_/-]+")) {
|
||||
throw new IllegalArgumentException("Invalid bootstrap pack specification for '" + key + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PackPlan(
|
||||
PackSpec spec,
|
||||
Path root,
|
||||
boolean existed,
|
||||
boolean managed,
|
||||
Archive archive,
|
||||
boolean replace
|
||||
) {
|
||||
}
|
||||
|
||||
private record PackPublication(Path root, Path backup) {
|
||||
}
|
||||
|
||||
private record Archive(Path path, String sha256) {
|
||||
private Archive {
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
|
||||
@@ -36,10 +36,12 @@ import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -55,6 +57,14 @@ public final class PackDownloader {
|
||||
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
|
||||
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
|
||||
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
|
||||
private static final String UNDERWORLD_PACK = "underworld";
|
||||
private static final String UNDERWORLD_REPOSITORY = "IrisDimensions/underworld";
|
||||
private static final String UNDERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip";
|
||||
private static final List<String> MANAGED_BETA_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK);
|
||||
private static final Map<String, ManagedBetaPack> MANAGED_BETA_PACKS = Map.of(
|
||||
DEFAULT_OVERWORLD_PACK, new ManagedBetaPack(DEFAULT_OVERWORLD_REPOSITORY, DEFAULT_OVERWORLD_RELEASE_URL),
|
||||
UNDERWORLD_PACK, new ManagedBetaPack(UNDERWORLD_REPOSITORY, UNDERWORLD_RELEASE_URL)
|
||||
);
|
||||
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
|
||||
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
|
||||
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
|
||||
@@ -74,6 +84,14 @@ public final class PackDownloader {
|
||||
return DEFAULT_OVERWORLD_PACK.equals(pack);
|
||||
}
|
||||
|
||||
public static boolean isManagedBetaPack(String pack) {
|
||||
return pack != null && MANAGED_BETA_PACKS.containsKey(pack);
|
||||
}
|
||||
|
||||
public static List<String> managedBetaPacks() {
|
||||
return MANAGED_BETA_PACK_KEYS;
|
||||
}
|
||||
|
||||
public static String defaultOverworldPack() {
|
||||
return DEFAULT_OVERWORLD_PACK;
|
||||
}
|
||||
@@ -115,8 +133,40 @@ public final class PackDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isManagedBetaPackPresent(File packsFolder, String key) {
|
||||
if (!isManagedBetaPack(key) || !isPackPresent(packsFolder, key)) {
|
||||
return false;
|
||||
}
|
||||
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
|
||||
if (resolvedPack == null) {
|
||||
return false;
|
||||
}
|
||||
Path primaryDimension = resolvedPack.toPath().toAbsolutePath().normalize()
|
||||
.resolve("dimensions")
|
||||
.resolve(key + ".json");
|
||||
return !Files.isSymbolicLink(primaryDimension)
|
||||
&& Files.isRegularFile(primaryDimension, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
|
||||
return downloadManagedBeta(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
|
||||
}
|
||||
|
||||
public static PackInstallResult downloadManagedBeta(File packsFolder, String pack, boolean forceOverwrite,
|
||||
Consumer<String> feedback) throws IOException {
|
||||
ManagedBetaPack managed = pack == null ? null : MANAGED_BETA_PACKS.get(pack);
|
||||
if (managed == null) {
|
||||
throw new IllegalArgumentException("Pack '" + pack + "' has no managed beta release");
|
||||
}
|
||||
return download(
|
||||
packsFolder,
|
||||
managed.repository(),
|
||||
managed.releaseUrl(),
|
||||
forceOverwrite,
|
||||
true,
|
||||
pack,
|
||||
feedback
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +185,10 @@ public final class PackDownloader {
|
||||
}
|
||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
|
||||
return withDownloadLock(lockKey, () -> {
|
||||
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
|
||||
boolean present = isManagedBetaPack(expectedKey)
|
||||
? isManagedBetaPackPresent(packsFolder, expectedKey)
|
||||
: isPackPresent(packsFolder, expectedKey);
|
||||
if (!forceOverwrite && present) {
|
||||
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return new PackInstallResult(expectedKey, false, false);
|
||||
}
|
||||
@@ -235,11 +288,11 @@ public final class PackDownloader {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
|
||||
return null;
|
||||
}
|
||||
if (dimensions.length != 1) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback);
|
||||
if (selectedDimension == null) {
|
||||
return null;
|
||||
}
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
|
||||
IrisDimension dimension = data.getDimensionLoader().load(selectedDimension);
|
||||
if (dimension == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
|
||||
return null;
|
||||
@@ -280,6 +333,29 @@ public final class PackDownloader {
|
||||
return new PreparedPack(key, name, validation);
|
||||
}
|
||||
|
||||
private static String selectDimensionKey(String[] dimensions, String expectedKey,
|
||||
Consumer<String> feedback) throws IOException {
|
||||
if (expectedKey == null || expectedKey.isBlank()) {
|
||||
if (dimensions.length != 1) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
return null;
|
||||
}
|
||||
return dimensions[0];
|
||||
}
|
||||
|
||||
int matches = 0;
|
||||
for (String dimension : dimensions) {
|
||||
if (expectedKey.equals(dimension)) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
if (matches != 1) {
|
||||
throw new IOException("Downloaded pack dimensions " + Arrays.toString(dimensions)
|
||||
+ " do not contain exactly one requested key '" + expectedKey + "'");
|
||||
}
|
||||
return expectedKey;
|
||||
}
|
||||
|
||||
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
|
||||
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
Path target = packsRoot.resolve(prepared.key()).normalize();
|
||||
@@ -299,15 +375,19 @@ public final class PackDownloader {
|
||||
));
|
||||
return null;
|
||||
}
|
||||
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
|
||||
boolean present = isManagedBetaPack(prepared.key())
|
||||
? isManagedBetaPackPresent(packsRoot.toFile(), prepared.key())
|
||||
: isPackPresent(packsRoot.toFile(), prepared.key());
|
||||
if (!forceOverwrite && present) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.PACK_KEY_CONFLICT,
|
||||
MessageArgument.untrusted("key", prepared.key())
|
||||
));
|
||||
return null;
|
||||
}
|
||||
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
|
||||
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
|
||||
if (!forceOverwrite && Files.exists(target) && !present) {
|
||||
IrisLogging.warn("Replacing partial pack folder " + target
|
||||
+ " (required primary dimension is missing).");
|
||||
}
|
||||
|
||||
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
|
||||
@@ -572,6 +652,10 @@ public final class PackDownloader {
|
||||
return DEFAULT_OVERWORLD_RELEASE_URL;
|
||||
}
|
||||
|
||||
static String underworldReleaseUrl() {
|
||||
return UNDERWORLD_RELEASE_URL;
|
||||
}
|
||||
|
||||
private static void validateGithubRef(String qualifiedRef) {
|
||||
String refPath = qualifiedRef.startsWith("refs/heads/")
|
||||
? qualifiedRef.substring("refs/heads/".length())
|
||||
@@ -597,6 +681,9 @@ public final class PackDownloader {
|
||||
private record PreparedPack(String key, String name, PackValidationResult validation) {
|
||||
}
|
||||
|
||||
private record ManagedBetaPack(String repository, String releaseUrl) {
|
||||
}
|
||||
|
||||
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +68,13 @@ import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
@@ -97,20 +98,17 @@ public class StudioSVC implements IrisService {
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
J.a(() -> {
|
||||
String pack = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
VolmitSender console = BukkitPlatform.console();
|
||||
runPackMutation(console, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
|
||||
"managed-beta-packs", () -> installMissingManagedBetaPacks(console),
|
||||
"Failed to install Iris managed beta packs at startup.");
|
||||
|
||||
// Presence means a non-empty pack folder: an empty leftover folder must still
|
||||
// trigger the install instead of shadowing it forever.
|
||||
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) {
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
|
||||
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
|
||||
} else {
|
||||
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
|
||||
}
|
||||
}
|
||||
});
|
||||
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
if (!PackDownloader.isManagedBetaPack(configuredPack)
|
||||
&& !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
|
||||
IrisLogging.warn("Default pack '" + configuredPack
|
||||
+ "' is not installed. Please download it manually with /iris download " + configuredPack);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -348,12 +346,15 @@ public class StudioSVC implements IrisService {
|
||||
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key))));
|
||||
}
|
||||
|
||||
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
|
||||
String key = PackDownloader.defaultOverworldPack();
|
||||
public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) {
|
||||
if (!PackDownloader.isManagedBetaPack(key)) {
|
||||
sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release.");
|
||||
return;
|
||||
}
|
||||
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
|
||||
DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite);
|
||||
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite);
|
||||
return finishStandalonePackMutation(sender, outcome);
|
||||
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE));
|
||||
}, "Failed to download IrisDimensions/" + key + " beta release.");
|
||||
}
|
||||
|
||||
public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) {
|
||||
@@ -380,8 +381,8 @@ public class StudioSVC implements IrisService {
|
||||
}
|
||||
|
||||
private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException {
|
||||
if (PackDownloader.isDefaultOverworld(key)) {
|
||||
return downloadDefaultOverworldLocked(sender, forceOverwrite);
|
||||
if (PackDownloader.isManagedBetaPack(key)) {
|
||||
return downloadManagedBetaLocked(sender, key, forceOverwrite);
|
||||
}
|
||||
|
||||
String descriptor = key.contains("/") ? key : getListing(false).get(key);
|
||||
@@ -411,21 +412,52 @@ public class StudioSVC implements IrisService {
|
||||
return new PackListingReference(repository, ref, expectedKey);
|
||||
}
|
||||
|
||||
private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException {
|
||||
String expectedKey = PackDownloader.defaultOverworldPack();
|
||||
if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) {
|
||||
private DownloadOutcome downloadManagedBetaLocked(
|
||||
VolmitSender sender,
|
||||
String expectedKey,
|
||||
boolean forceOverwrite
|
||||
) throws IOException {
|
||||
if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) {
|
||||
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return DownloadOutcome.notChanged();
|
||||
}
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld(
|
||||
PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta(
|
||||
getWorkspaceFolder(),
|
||||
expectedKey,
|
||||
forceOverwrite,
|
||||
sender::sendMessage
|
||||
);
|
||||
return DownloadOutcome.from(result);
|
||||
}
|
||||
|
||||
private boolean installMissingManagedBetaPacks(VolmitSender sender) {
|
||||
boolean changed = false;
|
||||
boolean restartRequired = false;
|
||||
for (String key : missingManagedBetaPacks(getWorkspaceFolder())) {
|
||||
IrisLogging.info("Downloading managed Iris pack " + key + " (beta release)");
|
||||
try {
|
||||
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, false);
|
||||
changed |= outcome.changed();
|
||||
restartRequired |= outcome.restartRequired();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to download IrisDimensions/" + key + " beta release.", failure);
|
||||
sender.sendMessage("Failed to download IrisDimensions/" + key + " beta release. " + errorDetail(failure));
|
||||
}
|
||||
}
|
||||
return finishStandalonePackMutation(sender, new DownloadOutcome(changed, restartRequired));
|
||||
}
|
||||
|
||||
static List<String> missingManagedBetaPacks(File workspaceFolder) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String key : PackDownloader.managedBetaPacks()) {
|
||||
if (!PackDownloader.isManagedBetaPackPresent(workspaceFolder, key)) {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
return List.copyOf(missing);
|
||||
}
|
||||
|
||||
private DownloadOutcome downloadLocked(
|
||||
VolmitSender sender,
|
||||
String repo,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package art.arcane.iris.util.common.misc;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
@@ -16,48 +15,19 @@ public class ServerProperties {
|
||||
public static final String LEVEL_NAME;
|
||||
|
||||
static {
|
||||
String[] args = ProcessHandle.current()
|
||||
.info()
|
||||
.arguments()
|
||||
.orElse(new String[0]);
|
||||
|
||||
String propertiesPath = "server.properties";
|
||||
String bukkitYml = "bukkit.yml";
|
||||
String levelName = null;
|
||||
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String arg = args[i];
|
||||
String next = i < args.length - 1 ? args[i + 1] : null;
|
||||
|
||||
propertiesPath = parse(arg, next, propertiesPath, "-c", "--config");
|
||||
bukkitYml = parse(arg, next, bukkitYml, "-b", "--bukkit-settings");
|
||||
levelName = parse(arg, next, levelName, "-w", "--level-name", "--world");
|
||||
BukkitStartupPaths startupPaths;
|
||||
try {
|
||||
startupPaths = BukkitStartupPaths.resolveCurrent();
|
||||
} catch (IOException exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
|
||||
SERVER_PROPERTIES = new File(propertiesPath);
|
||||
BUKKIT_YML = new File(bukkitYml);
|
||||
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)){
|
||||
SERVER_PROPERTIES = startupPaths.serverProperties().toFile();
|
||||
BUKKIT_YML = startupPaths.bukkitConfiguration().toFile();
|
||||
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) {
|
||||
DATA.load(in);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (IOException exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
|
||||
if (levelName != null) LEVEL_NAME = levelName;
|
||||
else LEVEL_NAME = DATA.getProperty("level-name", "world");
|
||||
}
|
||||
|
||||
private static String parse(
|
||||
@NotNull String current,
|
||||
@Nullable String next,
|
||||
String fallback,
|
||||
@NotNull String @NotNull ... keys
|
||||
) {
|
||||
for (String k : keys) {
|
||||
if (current.equals(k) && next != null)
|
||||
return next;
|
||||
if (current.startsWith(k + "=") && current.length() > k.length() + 1)
|
||||
return current.substring(k.length() + 1);
|
||||
}
|
||||
return fallback;
|
||||
LEVEL_NAME = startupPaths.levelName();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dimension oder Pack, mit der bzw. dem die Welt erstellt wird",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Seed für die Generierung der Welt",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ob man diese Welt automatisch als Hauptwelt benutzt oder nicht",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Den exakten vorhandenen Welt-Slot beim nächsten Neustart ersetzen",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleportieren in eine andere Welt",
|
||||
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
|
||||
"iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensión o el pack con el que se creará el mundo",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "La semilla con la que se generará el mundo",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indica si este mundo debe usarse automáticamente como mundo principal",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Reemplazar el espacio exacto del mundo existente en el próximo reinicio",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teletransportarse a otro mundo",
|
||||
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
|
||||
"iris.director.commandiris.param.player_teleport": "El jugador que se teletransportará",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Ulottuvuus / paketti luoda maailma",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Siemenet tuottaa maailman kanssa",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Käytetäänkö tätä maailmaa automaattisesti päämaailmana",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Korvaa täsmällinen olemassa oleva maailmapaikka seuraavalla uudelleenkäynnistyksellä",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
|
||||
"iris.director.commandiris.param.world_teleport": "Maailman teleportata",
|
||||
"iris.director.commandiris.param.player_teleport": "Pelaaja teleportata",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimension ou le pack avec lequel créer le monde",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "La graine avec laquelle générer le monde",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indique si ce monde doit être utilisé automatiquement comme monde principal",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Remplacer l’emplacement exact du monde existant au prochain redémarrage",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Se téléporter vers un autre monde",
|
||||
"iris.director.commandiris.param.world_teleport": "Le monde vers lequel se téléporter",
|
||||
"iris.director.commandiris.param.player_teleport": "Le joueur à téléporter",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "הזרע ליצור את העולם עם",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "בין אם להשתמש בעולם באופן אוטומטי כעולם הראשי",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "החלפת משבצת העולם הקיימת המדויקת בהפעלה מחדש הבאה",
|
||||
"iris.director.commandiris.director.teleport_another_world": "טלפורט לעולם אחר",
|
||||
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
|
||||
"iris.director.commandiris.param.player_teleport": "שחקן לטלפורט",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensione o il Pack con cui creare il mondo",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Il seed con cui generare il mondo",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Che sia o meno utilizzare automaticamente questo mondo come il mondo principale",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Sostituisci lo slot esatto del mondo esistente al prossimo riavvio",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teletrasporto in un altro mondo",
|
||||
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
|
||||
"iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "ワールドの生成に使用するシード",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "このワールドをメインワールドとして自動設定するかどうか",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "次回の再起動時に既存の正確なワールドスロットを置き換える",
|
||||
"iris.director.commandiris.director.teleport_another_world": "別のワールドへテレポートします",
|
||||
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
|
||||
"iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "월드 생성에 사용할 시드",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "이 월드를 메인 월드로 자동 사용할지 여부",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "다음 재시작 시 기존의 정확한 월드 슬롯 교체",
|
||||
"iris.director.commandiris.director.teleport_another_world": "다른 월드로 순간이동합니다",
|
||||
"iris.director.commandiris.param.world_teleport": "순간이동할 월드",
|
||||
"iris.director.commandiris.param.player_teleport": "순간이동시킬 플레이어",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "dimensija / paketas sukurti pasaulį su",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Sėkla generuoti pasaulį su",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ar automatiškai naudoti šį pasaulį kaip pagrindinį pasaulį",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Per kitą paleidimą iš naujo pakeisti tikslų esamą pasaulio lizdą",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleportas į kitą pasaulį",
|
||||
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
|
||||
"iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "De dimensie/pack om de wereld te creëren met",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Het zaad om de wereld te genereren met",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Of deze wereld automatisch gebruikt moet worden als de belangrijkste wereld",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "De exacte bestaande wereldsleuf bij de volgende herstart vervangen",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleporteren naar een andere wereld",
|
||||
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
|
||||
"iris.director.commandiris.param.player_teleport": "Speler naar teleporteren",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Wymiar / pakiet do tworzenia świata z",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Nasienie do generowania świata z",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Czy automatycznie używać tego świata jako głównego świata",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Zastąp dokładny istniejący slot świata przy następnym restarcie",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleport do innego świata",
|
||||
"iris.director.commandiris.param.world_teleport": "Świat teleportować do",
|
||||
"iris.director.commandiris.param.player_teleport": "Gracz do teleportowania",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "A dimensão/pack para criar o mundo com",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "A semente para gerar o mundo com",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Se deve ou não usar automaticamente este mundo como o mundo principal",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Substituir o espaço exato do mundo existente no próximo reinício",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teletransporte para outro mundo",
|
||||
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
|
||||
"iris.director.commandiris.param.player_teleport": "Jogador para teletransportar",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Размер/пакет для создания мира",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Семя, чтобы создать мир с",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Использовать или не использовать этот мир как основной",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Заменить точный существующий слот мира при следующем перезапуске",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Телепорт в другой мир",
|
||||
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
|
||||
"iris.director.commandiris.param.player_teleport": "Игрок телепортируется",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dünyayı yaratmak için boyut / paket",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Dünyayı üretmek için tohum",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Bu dünyayı ana dünya olarak otomatik olarak kullanıp kullanmayalım",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Bir sonraki yeniden başlatmada mevcut tam dünya yuvasını değiştir",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Teleport başka bir dünyaya",
|
||||
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
|
||||
"iris.director.commandiris.param.player_teleport": "Oyuncuya teleport",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "Kích thước/ lốc tạo ra thế giới với",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "Hạt giống để tạo ra thế giới với",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Có nên tự động sử dụng thế giới này làm thế giới chính hay không",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Thay thế đúng vị trí thế giới hiện có vào lần khởi động lại tiếp theo",
|
||||
"iris.director.commandiris.director.teleport_another_world": "Name",
|
||||
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển",
|
||||
"iris.director.commandiris.param.player_teleport": "Người chơi cần dịch chuyển",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "用于创建世界的维度包",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "用于生成世界的种子",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自动将此世界设为主世界",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重启时替换指定的现有世界槽位",
|
||||
"iris.director.commandiris.director.teleport_another_world": "传送到另一个世界",
|
||||
"iris.director.commandiris.param.world_teleport": "要传送到的世界",
|
||||
"iris.director.commandiris.param.player_teleport": "要传送的玩家",
|
||||
|
||||
@@ -697,6 +697,7 @@
|
||||
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
|
||||
"iris.director.commandiris.param.seed_generate_world_with": "用於生成世界的種子",
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自動將此世界設為主世界",
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重新啟動時取代指定的現有世界槽位",
|
||||
"iris.director.commandiris.director.teleport_another_world": "傳送到另一個世界",
|
||||
"iris.director.commandiris.param.world_teleport": "要傳送到的世界",
|
||||
"iris.director.commandiris.param.player_teleport": "要傳送的玩家",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
@@ -22,22 +21,22 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
Path canonicalRoot = levelRoot.toRealPath();
|
||||
List<SlotExpectation> expectations = List.of(
|
||||
new SlotExpectation(
|
||||
new NamespacedKey("iris", "underworld"),
|
||||
new WorldSlotKey("iris", "underworld"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
|
||||
"dimensions/iris/underworld"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("overworld"),
|
||||
WorldSlotKey.minecraft("overworld"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
|
||||
"dimensions/minecraft/overworld"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("the_nether"),
|
||||
WorldSlotKey.minecraft("the_nether"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
|
||||
"dimensions/minecraft/the_nether"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("the_end"),
|
||||
WorldSlotKey.minecraft("the_end"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
|
||||
"dimensions/minecraft/the_end"
|
||||
)
|
||||
@@ -63,7 +62,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("the_nether")
|
||||
WorldSlotKey.minecraft("the_nether")
|
||||
);
|
||||
|
||||
assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
|
||||
@@ -75,15 +74,15 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
|
||||
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world"))
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("foreign", "world"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world"))
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("iris", "nested/world"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom"))
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, WorldSlotKey.minecraft("custom"))
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
|
||||
@@ -97,7 +96,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
@Test
|
||||
public void validatesOnlyTheExactExpectedCandidate() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath();
|
||||
NamespacedKey worldKey = NamespacedKey.minecraft("the_nether");
|
||||
WorldSlotKey worldKey = WorldSlotKey.minecraft("the_nether");
|
||||
Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether");
|
||||
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
|
||||
@@ -136,7 +135,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(
|
||||
levelRoot.resolve("child/.."),
|
||||
new NamespacedKey("iris", "underworld")
|
||||
new WorldSlotKey("iris", "underworld")
|
||||
)
|
||||
);
|
||||
|
||||
@@ -148,41 +147,41 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath();
|
||||
Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
|
||||
Files.createSymbolicLink(levelLink, linkedLevelTarget);
|
||||
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld"));
|
||||
assertSymbolicLinkRejected(levelLink, new WorldSlotKey("iris", "underworld"));
|
||||
|
||||
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
|
||||
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
|
||||
Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions);
|
||||
assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
|
||||
assertSymbolicLinkRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
|
||||
|
||||
Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath();
|
||||
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
|
||||
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
|
||||
Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace);
|
||||
assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether"));
|
||||
assertSymbolicLinkRejected(namespaceLevel, WorldSlotKey.minecraft("the_nether"));
|
||||
|
||||
Path targetLevel = temporaryFolder.newFolder("linked-target").toPath();
|
||||
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
|
||||
Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
|
||||
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
|
||||
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether"));
|
||||
assertSymbolicLinkRejected(targetLevel, WorldSlotKey.minecraft("the_nether"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonDirectoryStorageEntries() throws Exception {
|
||||
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
|
||||
Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory");
|
||||
assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
|
||||
assertUnsafeEntryRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
|
||||
|
||||
Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath();
|
||||
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
|
||||
Files.writeString(dimensions.resolve("iris"), "not a directory");
|
||||
assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld"));
|
||||
assertUnsafeEntryRejected(namespaceLevel, new WorldSlotKey("iris", "underworld"));
|
||||
|
||||
Path targetLevel = temporaryFolder.newFolder("file-target").toPath();
|
||||
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
|
||||
Files.writeString(namespace.resolve("underworld"), "not a directory");
|
||||
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld"));
|
||||
assertUnsafeEntryRejected(targetLevel, new WorldSlotKey("iris", "underworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -191,13 +190,13 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
|
||||
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld"))
|
||||
() -> ExactWorldSlotPathPolicy.resolve(missing, new WorldSlotKey("iris", "underworld"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(
|
||||
missing.toAbsolutePath().getRoot(),
|
||||
new NamespacedKey("iris", "underworld")
|
||||
new WorldSlotKey("iris", "underworld")
|
||||
)
|
||||
);
|
||||
|
||||
@@ -205,7 +204,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason());
|
||||
}
|
||||
|
||||
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) {
|
||||
private void assertSymbolicLinkRejected(Path levelRoot, WorldSlotKey worldKey) {
|
||||
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
|
||||
@@ -214,7 +213,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason());
|
||||
}
|
||||
|
||||
private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) {
|
||||
private void assertUnsafeEntryRejected(Path levelRoot, WorldSlotKey worldKey) {
|
||||
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
|
||||
@@ -224,7 +223,7 @@ public class ExactWorldSlotPathPolicyTest {
|
||||
}
|
||||
|
||||
private record SlotExpectation(
|
||||
NamespacedKey worldKey,
|
||||
WorldSlotKey worldKey,
|
||||
ExactWorldSlotPathPolicy.SlotKind slotKind,
|
||||
String relativePath
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class BukkitStartupPathsTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void defaultsResolveAgainstServerWorkingDirectory() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||
|
||||
assertEquals(serverRoot, paths.serverRoot());
|
||||
assertEquals(serverRoot.resolve("server.properties"), paths.serverProperties());
|
||||
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot, paths.worldContainer());
|
||||
assertEquals("world", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shortSeparatedArgumentsOverrideRelativeDefaults() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path configurationRoot = Files.createDirectories(serverRoot.resolve("short-config"));
|
||||
Path properties = configurationRoot.resolve("custom.properties");
|
||||
Path bukkit = configurationRoot.resolve("custom-bukkit.yml");
|
||||
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: relative-worlds\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"-c", "short-config/custom.properties",
|
||||
"-b", "short-config/custom-bukkit.yml",
|
||||
"-w", "argument-world"
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(properties, paths.serverProperties());
|
||||
assertEquals(bukkit, paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot.resolve("relative-worlds"), paths.worldContainer());
|
||||
assertEquals("argument-world", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("relative-worlds/argument-world"), paths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void longSeparatedArgumentsUseCustomPropertiesAndWorldContainer() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path configurationRoot = Files.createDirectories(serverRoot.resolve("long-config"));
|
||||
Path properties = configurationRoot.resolve("server.properties");
|
||||
Path bukkit = configurationRoot.resolve("bukkit.yml");
|
||||
Files.writeString(properties, "level-name=ignored-property-world\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: long-worlds\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"--config", "long-config/server.properties",
|
||||
"--bukkit-settings", "long-config/bukkit.yml",
|
||||
"--world", "long-world"
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(properties, paths.serverProperties());
|
||||
assertEquals(bukkit, paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot.resolve("long-worlds"), paths.worldContainer());
|
||||
assertEquals("long-world", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("long-worlds/long-world"), paths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equalsArgumentsResolveRelativePathsAndLastWorldOverride() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path configurationRoot = Files.createDirectories(serverRoot.resolve("equals-config"));
|
||||
Path properties = configurationRoot.resolve("server.properties");
|
||||
Path bukkit = configurationRoot.resolve("bukkit.yml");
|
||||
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: equals-worlds\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"--config=equals-config/server.properties",
|
||||
"--bukkit-settings=equals-config/bukkit.yml",
|
||||
"--world=first-world",
|
||||
"-w=last-world"
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(properties, paths.serverProperties());
|
||||
assertEquals(bukkit, paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot.resolve("equals-worlds"), paths.worldContainer());
|
||||
assertEquals("last-world", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("equals-worlds/last-world"), paths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void absoluteConfigurationContainerAndLevelPathsRemainAbsolute() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path configurationRoot = Files.createDirectories(serverRoot.resolve("absolute-config"));
|
||||
Path worldContainer = Files.createDirectories(serverRoot.resolve("absolute-worlds"));
|
||||
Path absoluteLevel = serverRoot.resolve("absolute-level");
|
||||
Path properties = configurationRoot.resolve("server.properties");
|
||||
Path bukkit = configurationRoot.resolve("bukkit.yml");
|
||||
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: '" + worldContainer + "'\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths propertiesPaths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"--config=" + properties,
|
||||
"--bukkit-settings=" + bukkit
|
||||
}
|
||||
);
|
||||
BukkitStartupPaths argumentPaths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"-c", properties.toString(),
|
||||
"-b", bukkit.toString(),
|
||||
"-w", absoluteLevel.toString()
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(properties, propertiesPaths.serverProperties());
|
||||
assertEquals(bukkit, propertiesPaths.bukkitConfiguration());
|
||||
assertEquals(worldContainer, propertiesPaths.worldContainer());
|
||||
assertEquals(worldContainer.resolve("property-world"), propertiesPaths.levelRoot());
|
||||
assertEquals(absoluteLevel.toString(), argumentPaths.levelName());
|
||||
assertEquals(absoluteLevel, argumentPaths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldContainerArgumentsOverrideBukkitConfiguration() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path bukkit = serverRoot.resolve("bukkit.yml");
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: ignored-worlds\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths shortPaths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{"-W", "short-worlds", "--world=short-level"}
|
||||
);
|
||||
BukkitStartupPaths longPaths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{"--world-dir=first-worlds", "--universe", "second-worlds"}
|
||||
);
|
||||
BukkitStartupPaths explicitPaths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{"--world-container=explicit-worlds"}
|
||||
);
|
||||
|
||||
assertEquals(serverRoot.resolve("short-worlds"), shortPaths.worldContainer());
|
||||
assertEquals(serverRoot.resolve("short-worlds/short-level"), shortPaths.levelRoot());
|
||||
assertEquals(serverRoot.resolve("second-worlds"), longPaths.worldContainer());
|
||||
assertEquals(serverRoot.resolve("second-worlds/world"), longPaths.levelRoot());
|
||||
assertEquals(serverRoot.resolve("explicit-worlds"), explicitPaths.worldContainer());
|
||||
assertEquals(serverRoot.resolve("explicit-worlds/world"), explicitPaths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compactShortArgumentsMatchServerOptionParsing() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
Path configurationRoot = Files.createDirectories(serverRoot.resolve("compact-config"));
|
||||
Path properties = configurationRoot.resolve("server.properties");
|
||||
Path bukkit = configurationRoot.resolve("bukkit.yml");
|
||||
Files.writeString(properties, "level-name=ignored-world\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
bukkit,
|
||||
"settings:\n world-container: ignored-worlds\n",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{
|
||||
"-ccompact-config/server.properties",
|
||||
"-bcompact-config/bukkit.yml",
|
||||
"-Wcompact-worlds",
|
||||
"-wcompact-level"
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(properties, paths.serverProperties());
|
||||
assertEquals(bukkit, paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot.resolve("compact-worlds"), paths.worldContainer());
|
||||
assertEquals("compact-level", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("compact-worlds/compact-level"), paths.levelRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applicationArgumentsExcludeJvmAndLauncherOptions() {
|
||||
assertArrayEquals(
|
||||
new String[]{"-bconfig/bukkit.yml", "-Wworlds", "-wlevel", "-cserver.properties"},
|
||||
BukkitStartupPaths.applicationArguments(new String[]{
|
||||
"-Xmx4G",
|
||||
"-jar",
|
||||
"paper.jar",
|
||||
"-bconfig/bukkit.yml",
|
||||
"-Wworlds",
|
||||
"-wlevel",
|
||||
"-cserver.properties"
|
||||
})
|
||||
);
|
||||
assertArrayEquals(
|
||||
new String[]{"-bconfig/bukkit.yml"},
|
||||
BukkitStartupPaths.applicationArguments(new String[]{
|
||||
"-Xmx4G",
|
||||
"-cp",
|
||||
"paper.jar",
|
||||
"org.bukkit.craftbukkit.Main",
|
||||
"-bconfig/bukkit.yml"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void endOfOptionsStopsStartupOptionParsing() throws Exception {
|
||||
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
|
||||
|
||||
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
|
||||
serverRoot,
|
||||
new String[]{"--", "-bcustom-bukkit.yml", "-Wcustom-worlds", "-wcustom-level"}
|
||||
);
|
||||
|
||||
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
|
||||
assertEquals(serverRoot, paths.worldContainer());
|
||||
assertEquals("world", paths.levelName());
|
||||
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,20 @@ import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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;
|
||||
import static org.junit.Assume.assumeNoException;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
|
||||
public class BukkitWorldConfigurationTest {
|
||||
@Rule
|
||||
@@ -317,4 +324,48 @@ public class BukkitWorldConfigurationTest {
|
||||
assertTrue(failure.getMessage().contains("generator"));
|
||||
assertEquals(malformed, Files.readString(configuration.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replacementRejectsSymbolicConfigurationWithoutChangingLinkOrTarget() throws Exception {
|
||||
Path target = temporaryFolder.newFile("shared-bukkit.yml").toPath();
|
||||
String original = "settings:\n allow-end: true\n";
|
||||
Files.writeString(target, original);
|
||||
Path link = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
|
||||
try {
|
||||
Files.createSymbolicLink(link, target.getFileName());
|
||||
} catch (IOException | UnsupportedOperationException failure) {
|
||||
assumeNoException(failure);
|
||||
}
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot expected =
|
||||
new BukkitWorldConfiguration.WorldGeneratorSnapshot(false, false, false, null, false, null);
|
||||
|
||||
assertThrows(IOException.class, () -> BukkitWorldConfiguration.replaceIfMatching(
|
||||
link.toFile(),
|
||||
"world_nether",
|
||||
expected,
|
||||
"underworld",
|
||||
1337L
|
||||
));
|
||||
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertEquals(original, Files.readString(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshotRejectsSpecialConfigurationWithoutOpeningIt() throws Exception {
|
||||
assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix"));
|
||||
Path socket = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
|
||||
try (ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
|
||||
try {
|
||||
channel.bind(UnixDomainSocketAddress.of(socket));
|
||||
} catch (IOException | UnsupportedOperationException failure) {
|
||||
assumeNoException(failure);
|
||||
}
|
||||
|
||||
assertThrows(IOException.class, () -> BukkitWorldConfiguration.snapshot(
|
||||
socket.toFile(),
|
||||
"world_nether"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
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 WorldReplacementBootstrapTest {
|
||||
private static final WorldSlotKey WORLD_KEY = WorldSlotKey.minecraft("the_nether");
|
||||
private static final long SEED = 4242424242L;
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private Path serverRoot;
|
||||
private Path dataDirectory;
|
||||
private Path levelRoot;
|
||||
private Path bukkitConfiguration;
|
||||
private ExactWorldSlotPathPolicy.Target target;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
serverRoot = temporaryFolder.newFolder("server").toPath();
|
||||
dataDirectory = Files.createDirectories(serverRoot.resolve("plugins/Iris"));
|
||||
levelRoot = Files.createDirectories(serverRoot.resolve("world"));
|
||||
bukkitConfiguration = Files.createFile(serverRoot.resolve("bukkit.yml"));
|
||||
target = ExactWorldSlotPathPolicy.resolve(levelRoot, WORLD_KEY);
|
||||
Files.createDirectories(target.namespaceRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishesArmedReplacementBeforeRegistryCompilation() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.published());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
|
||||
configureExistingReplacement();
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.published());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsPublishedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
|
||||
configureExistingReplacement();
|
||||
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
|
||||
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.retained());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelsPreparedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
|
||||
configureExistingReplacement();
|
||||
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.rolledBack());
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths(transaction).stage()));
|
||||
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resumesPublicationAfterOriginalMoveCrash() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
ReplacementPaths paths = paths(transaction);
|
||||
Files.move(paths.target(), paths.backup());
|
||||
|
||||
reconcile();
|
||||
|
||||
assertEquals("replacement", replacementContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelsPreparedTransactionWhenConfigurationWasNotApplied() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.rolledBack());
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths(transaction).stage()));
|
||||
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restoresPublishedWorldWhenConfigurationWasReverted() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
|
||||
restoreOriginalConfiguration(transaction);
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.rolledBack());
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths(transaction).stage()));
|
||||
assertFalse(Files.exists(paths(transaction).backup()));
|
||||
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsThirdPartyConfigurationAfterPublicationWithoutMovingStorage() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
|
||||
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
bukkitConfiguration.toFile(),
|
||||
transaction.worldName(),
|
||||
replacement,
|
||||
"other",
|
||||
SEED
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, this::reconcile);
|
||||
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completesRollbackAcrossPreparedStorageCrashBoundary() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ROLLBACK_PENDING, true, "original");
|
||||
configureReplacement(transaction);
|
||||
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
|
||||
WorldReplacementFilesystem.prepareRollback(paths(transaction), true);
|
||||
restoreOriginalConfiguration(transaction);
|
||||
WorldReplacementJournal.write(dataDirectory, transaction.withPhase(Phase.ROLLBACK_CLEANUP));
|
||||
|
||||
reconcile();
|
||||
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths(transaction).stage()));
|
||||
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsVerifiedTargetWhenBackupWasAlreadyCleaned() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original");
|
||||
configureReplacement(transaction);
|
||||
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
|
||||
WorldReplacementFilesystem.cleanupBackup(paths(transaction));
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.retained());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals(Phase.CLEANUP_PENDING, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world"));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementBootstrap.reconcile(
|
||||
dataDirectory,
|
||||
otherLevelRoot,
|
||||
bukkitConfiguration,
|
||||
ignored -> {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertTrue(Files.isDirectory(paths(transaction).stage()));
|
||||
assertFalse(Files.exists(paths(transaction).backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDuplicateWorldJournalsBeforePublishingEither() throws Exception {
|
||||
Transaction first = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(first);
|
||||
Transaction second = new Transaction(
|
||||
UUID.randomUUID(),
|
||||
first.worldKey(),
|
||||
first.worldName(),
|
||||
first.levelRoot(),
|
||||
first.dimension(),
|
||||
first.seed(),
|
||||
first.packFingerprint(),
|
||||
first.originalConfiguration(),
|
||||
first.originalTargetPresent(),
|
||||
first.phase()
|
||||
);
|
||||
WorldReplacementJournal.write(dataDirectory, second);
|
||||
|
||||
assertThrows(IOException.class, this::reconcile);
|
||||
|
||||
assertTrue(Files.isDirectory(paths(first).stage()));
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths(first).backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception {
|
||||
for (String generator : List.of("", " ")) {
|
||||
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
generator,
|
||||
false,
|
||||
null
|
||||
);
|
||||
Transaction transaction = transaction(UUID.randomUUID(), original, Phase.PREPARED, false, "replacement");
|
||||
WorldReplacementJournal.write(dataDirectory, transaction);
|
||||
|
||||
Transaction loaded = loadSingle();
|
||||
|
||||
assertEquals(generator, loaded.originalConfiguration().generator());
|
||||
WorldReplacementJournal.delete(dataDirectory, transaction.id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsIrisWorldKeysThatCollideWithConfiguredVanillaAliases() {
|
||||
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(false, false, false, null, false, null);
|
||||
for (String alias : List.of("world", "world_nether", "world_the_end")) {
|
||||
Transaction transaction = new Transaction(
|
||||
UUID.randomUUID(),
|
||||
new WorldSlotKey("iris", alias),
|
||||
alias,
|
||||
levelRoot,
|
||||
"underworld",
|
||||
SEED,
|
||||
"0".repeat(64),
|
||||
original,
|
||||
false,
|
||||
Phase.ARMED
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> WorldReplacementJournal.resolveTarget(transaction, levelRoot));
|
||||
}
|
||||
}
|
||||
|
||||
private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception {
|
||||
return WorldReplacementBootstrap.reconcile(
|
||||
dataDirectory,
|
||||
levelRoot,
|
||||
bukkitConfiguration,
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private Transaction stagedTransaction(Phase phase, boolean originalPresent, String originalContent)
|
||||
throws Exception {
|
||||
WorldGeneratorSnapshot original = BukkitWorldConfiguration.snapshot(
|
||||
bukkitConfiguration.toFile(),
|
||||
"world_nether"
|
||||
);
|
||||
return transaction(UUID.randomUUID(), original, phase, originalPresent, originalContent);
|
||||
}
|
||||
|
||||
private Transaction transaction(
|
||||
UUID id,
|
||||
WorldGeneratorSnapshot original,
|
||||
Phase phase,
|
||||
boolean originalPresent,
|
||||
String originalContent
|
||||
) throws Exception {
|
||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, id);
|
||||
if (originalPresent) {
|
||||
writeOriginalTarget(paths, originalContent);
|
||||
}
|
||||
Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json");
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, "replacement");
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
|
||||
Transaction transaction = new Transaction(
|
||||
id,
|
||||
WORLD_KEY,
|
||||
"world_nether",
|
||||
target.levelRoot(),
|
||||
"underworld",
|
||||
SEED,
|
||||
fingerprint,
|
||||
original,
|
||||
originalPresent,
|
||||
phase
|
||||
);
|
||||
WorldReplacementJournal.write(dataDirectory, transaction);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
private void configureReplacement(Transaction transaction) throws Exception {
|
||||
BukkitWorldConfiguration.GeneratorReplacement result = BukkitWorldConfiguration.replaceIfMatching(
|
||||
bukkitConfiguration.toFile(),
|
||||
transaction.worldName(),
|
||||
transaction.originalConfiguration(),
|
||||
transaction.dimension(),
|
||||
transaction.seed()
|
||||
);
|
||||
assertTrue(result.applied());
|
||||
}
|
||||
|
||||
private void configureExistingReplacement() throws Exception {
|
||||
BukkitWorldConfiguration.register(
|
||||
bukkitConfiguration.toFile(),
|
||||
"world_nether",
|
||||
"underworld",
|
||||
SEED
|
||||
);
|
||||
}
|
||||
|
||||
private void restoreOriginalConfiguration(Transaction transaction) throws Exception {
|
||||
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
|
||||
bukkitConfiguration.toFile(),
|
||||
transaction.worldName(),
|
||||
WorldReplacementBootstrap.replacementSnapshot(transaction),
|
||||
transaction.originalConfiguration()
|
||||
));
|
||||
}
|
||||
|
||||
private Transaction loadSingle() throws Exception {
|
||||
return WorldReplacementJournal.load(dataDirectory, levelRoot).getFirst();
|
||||
}
|
||||
|
||||
private ReplacementPaths paths(Transaction transaction) {
|
||||
return WorldReplacementFilesystem.paths(target, transaction.id());
|
||||
}
|
||||
|
||||
private Path backup(Transaction transaction) {
|
||||
return paths(transaction).backup();
|
||||
}
|
||||
|
||||
private void writeOriginalTarget(ReplacementPaths paths, String originalContent) throws Exception {
|
||||
Files.createDirectories(paths.target().resolve("data/paper"));
|
||||
Files.createDirectories(paths.target().resolve("data/minecraft"));
|
||||
Files.writeString(paths.target().resolve("original.txt"), originalContent);
|
||||
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
|
||||
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
|
||||
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
|
||||
}
|
||||
|
||||
private String replacementContent(Path worldDirectory) throws Exception {
|
||||
return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json"));
|
||||
}
|
||||
}
|
||||
+104
@@ -31,15 +31,83 @@ public class WorldReplacementFilesystemTest {
|
||||
public void publishesReplacementAndRetainsOriginalBackup() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
Files.createDirectories(paths.target().resolve("region"));
|
||||
Files.createDirectories(paths.target().resolve("entities"));
|
||||
Files.createDirectories(paths.target().resolve("poi"));
|
||||
Files.writeString(paths.target().resolve("region/r.0.0.mca"), "old-region");
|
||||
Files.writeString(paths.target().resolve("entities/r.0.0.mca"), "old-entities");
|
||||
Files.writeString(paths.target().resolve("poi/r.0.0.mca"), "old-poi");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
|
||||
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
|
||||
assertEquals(
|
||||
"generation",
|
||||
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
|
||||
);
|
||||
assertFalse(Files.exists(paths.target().resolve("region/r.0.0.mca")));
|
||||
assertFalse(Files.exists(paths.target().resolve("entities/r.0.0.mca")));
|
||||
assertFalse(Files.exists(paths.target().resolve("poi/r.0.0.mca")));
|
||||
assertTrue(Files.exists(paths.backup().resolve("region/r.0.0.mca")));
|
||||
assertTrue(Files.exists(paths.backup().resolve("entities/r.0.0.mca")));
|
||||
assertTrue(Files.exists(paths.backup().resolve("poi/r.0.0.mca")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.requireExistingTarget(paths)
|
||||
);
|
||||
|
||||
assertTrue(failure.getMessage().contains("requires an existing exact world slot"));
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completesMetadataForAlreadyPublishedReplacement() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("published-metadata", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Files.move(paths.target(), paths.backup());
|
||||
Files.move(paths.stage(), paths.target());
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
|
||||
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
|
||||
assertEquals(
|
||||
"generation",
|
||||
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnmigratedRetainedWorldBeforePublication() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("missing-paper-metadata", TRANSACTION_ID);
|
||||
Files.createDirectories(paths.target());
|
||||
Files.writeString(paths.target().resolve("original.txt"), "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(paths, true, fingerprint)
|
||||
);
|
||||
|
||||
assertTrue(Files.isDirectory(paths.target()));
|
||||
assertTrue(Files.isDirectory(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
|
||||
@@ -136,6 +204,37 @@ public class WorldReplacementFilesystemTest {
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedRollbackCanRepublishWhenConfigurationRestoreFails() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-republish", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
WorldReplacementFilesystem.prepareRollback(paths, true);
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedRollbackCleanupIsRetryableAfterStageDeletion() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-cleanup-retry", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
WorldReplacementFilesystem.prepareRollback(paths, true);
|
||||
|
||||
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
|
||||
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
|
||||
|
||||
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPackMutationBeforePublication() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID);
|
||||
@@ -289,6 +388,11 @@ public class WorldReplacementFilesystemTest {
|
||||
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
|
||||
Files.createDirectories(paths.target());
|
||||
Files.writeString(paths.target().resolve("original.txt"), content);
|
||||
Files.createDirectories(paths.target().resolve("data/paper"));
|
||||
Files.createDirectories(paths.target().resolve("data/minecraft"));
|
||||
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
|
||||
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
|
||||
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
|
||||
}
|
||||
|
||||
private String readPackContent(Path worldDirectory) throws Exception {
|
||||
|
||||
+264
-34
@@ -7,6 +7,7 @@ import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -17,10 +18,14 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -31,6 +36,26 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DefaultPackBootstrapProvisionerTest {
|
||||
@Test
|
||||
public void defaultBetaSourcesArePinnedPerRequiredPack() {
|
||||
Map<String, DefaultPackBootstrapProvisioner.PackSpec> packs = new LinkedHashMap<>();
|
||||
for (DefaultPackBootstrapProvisioner.PackSpec pack : DefaultPackBootstrapProvisioner.defaultPacks()) {
|
||||
packs.put(pack.key(), pack);
|
||||
}
|
||||
|
||||
assertEquals(2, packs.size());
|
||||
assertEquals(
|
||||
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
|
||||
packs.get("overworld").source()
|
||||
);
|
||||
assertEquals("overworld", packs.get("overworld").requiredDimension());
|
||||
assertEquals(
|
||||
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
|
||||
packs.get("underworld").source()
|
||||
);
|
||||
assertEquals("underworld", packs.get("underworld").requiredDimension());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
|
||||
byte[] archive = packArchive("overworld", "bootstrap_biome");
|
||||
@@ -58,14 +83,23 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json")));
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isRegularFile(installed.packRoots().get("overworld").resolve("dimensions/overworld.json")));
|
||||
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld.json")));
|
||||
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json")));
|
||||
assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot());
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
|
||||
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
assertEquals("true", marker.getProperty("pack.overworld.managed"));
|
||||
assertEquals("true", marker.getProperty("pack.underworld.managed"));
|
||||
assertEquals("underworld", marker.getProperty("pack.underworld.requiredDimension"));
|
||||
delete(installed.packRoots().get("underworld"));
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
@@ -101,9 +135,10 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
|
||||
} finally {
|
||||
if (!serverStopped) {
|
||||
server.stop(0);
|
||||
@@ -138,9 +173,10 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs")));
|
||||
assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json")));
|
||||
assertTrue(Files.isRegularFile(sharedPacks.resolve("underworld/dimensions/underworld.json")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
@@ -165,7 +201,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, options);
|
||||
|
||||
assertEquals(2, requests.get());
|
||||
assertEquals(3, requests.get());
|
||||
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
|
||||
assertTrue(zip.getNextEntry() != null);
|
||||
}
|
||||
@@ -245,13 +281,13 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
options
|
||||
);
|
||||
|
||||
assertEquals(0, requests.get());
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertEquals(target.toRealPath(), link.toRealPath());
|
||||
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
|
||||
|
||||
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
@@ -259,7 +295,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
options
|
||||
);
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(0, requests.get());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
@@ -280,7 +316,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
|
||||
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
|
||||
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
@@ -289,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(1, requests.get());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
|
||||
} finally {
|
||||
@@ -319,14 +355,15 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
refreshOptions
|
||||
);
|
||||
|
||||
assertEquals(1, requests.get());
|
||||
assertEquals(3, requests.get());
|
||||
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
|
||||
Properties marker = new Properties();
|
||||
try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
|
||||
try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
|
||||
marker.load(input);
|
||||
}
|
||||
assertEquals("false", marker.getProperty("managedDefault"));
|
||||
assertEquals("false", marker.getProperty("pack.overworld.managed"));
|
||||
assertEquals("true", marker.getProperty("pack.underworld.managed"));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
@@ -334,39 +371,185 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception {
|
||||
public void underworldLocalEditRelinquishesOnlyUnderworldOwnership() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
|
||||
HttpServer server = server(packArchive("overworld", "overworld_managed"), requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-underworld-edit");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(
|
||||
server,
|
||||
root,
|
||||
Duration.ofHours(1)
|
||||
);
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, initialOptions);
|
||||
Path editedBiome = dataDirectory.resolve("packs/underworld/biomes/local.json");
|
||||
Files.writeString(editedBiome, biomeJson("underworld_local_edit"), StandardCharsets.UTF_8);
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO);
|
||||
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
refreshOptions
|
||||
);
|
||||
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
|
||||
Path invalidPack = dataDirectory.resolve("packs/invalid");
|
||||
Files.createDirectories(invalidPack.resolve("dimensions"));
|
||||
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(3, requests.get());
|
||||
assertTrue(Files.readString(editedBiome).contains("underworld_local_edit"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_local_edit.json")));
|
||||
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
assertEquals("true", marker.getProperty("pack.overworld.managed"));
|
||||
assertEquals("false", marker.getProperty("pack.underworld.managed"));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void betaPacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception {
|
||||
byte[] overworld = packArchive("overworld", "overworld_first");
|
||||
byte[] underworldFirst = underworldArchive("underworld_first");
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer initialServer = server(overworld, underworldFirst, requests);
|
||||
HttpServer updateServer = null;
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-independent-update");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options(initialServer, root, Duration.ofHours(1))
|
||||
);
|
||||
initialServer.stop(0);
|
||||
initialServer = null;
|
||||
|
||||
byte[] underworldSecond = underworldArchive("underworld_second");
|
||||
updateServer = server(overworld, underworldSecond, requests);
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options(updateServer, root, Duration.ZERO)
|
||||
);
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(4, requests.get());
|
||||
assertTrue(Files.readString(dataDirectory.resolve("packs/overworld/biomes/local.json"))
|
||||
.contains("overworld_first"));
|
||||
assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json"))
|
||||
.contains("underworld_second"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/overworld/worldgen/biome/overworld_first.json")));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_second.json")));
|
||||
assertFalse(Files.exists(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_first.json")));
|
||||
} finally {
|
||||
if (initialServer != null) {
|
||||
initialServer.stop(0);
|
||||
}
|
||||
if (updateServer != null) {
|
||||
updateServer.stop(0);
|
||||
}
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidUnderworldArchivePublishesNeitherRequiredPack() throws Exception {
|
||||
byte[] overworld = packArchive("overworld", "overworld_valid");
|
||||
byte[] invalidUnderworld = packArchive("underworld_roof", "roof_only");
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = server(overworld, invalidUnderworld, requests);
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-underworld-invalid");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
|
||||
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options
|
||||
options(server, root, Duration.ZERO)
|
||||
));
|
||||
assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
|
||||
assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
|
||||
|
||||
assertEquals(2, requests.get());
|
||||
assertFalse(Files.exists(dataDirectory.resolve("packs/overworld")));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("packs/underworld")));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
|
||||
assertFalse(Files.exists(root.resolve("datapacks/iris")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAggregateCompilationRollsBackBothPackUpdatesAndDatapack() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer initialServer = server(
|
||||
packArchive("overworld", "overworld_first"),
|
||||
underworldArchive("underworld_first"),
|
||||
requests
|
||||
);
|
||||
HttpServer updateServer = null;
|
||||
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
|
||||
try {
|
||||
Path dataDirectory = root.resolve("plugins/Iris");
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
options(initialServer, root, Duration.ofHours(1))
|
||||
);
|
||||
initialServer.stop(0);
|
||||
initialServer = null;
|
||||
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
|
||||
byte[] originalOverworld = Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"));
|
||||
byte[] originalUnderworld = Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"));
|
||||
Path invalidPack = dataDirectory.resolve("packs/invalid");
|
||||
Files.createDirectories(invalidPack.resolve("dimensions"));
|
||||
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
|
||||
updateServer = server(
|
||||
packArchive("overworld", "overworld_second"),
|
||||
underworldArchive("underworld_second"),
|
||||
requests
|
||||
);
|
||||
DefaultPackBootstrapProvisioner.ProvisionOptions updateOptions = options(
|
||||
updateServer,
|
||||
root,
|
||||
Duration.ZERO
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
|
||||
dataDirectory,
|
||||
ignored -> {
|
||||
},
|
||||
updateOptions
|
||||
));
|
||||
assertTrue(Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
|
||||
assertTrue(Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
|
||||
assertTrue(Arrays.equals(originalOverworld,
|
||||
Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"))));
|
||||
assertTrue(Arrays.equals(originalUnderworld,
|
||||
Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"))));
|
||||
assertNoBootstrapTransactionPaths(dataDirectory.resolve("packs"));
|
||||
assertNoBootstrapTransactionPaths(root.resolve("datapacks"));
|
||||
} finally {
|
||||
if (initialServer != null) {
|
||||
initialServer.stop(0);
|
||||
}
|
||||
if (updateServer != null) {
|
||||
updateServer.stop(0);
|
||||
}
|
||||
delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
|
||||
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
|
||||
@@ -378,7 +561,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
serverRoot.resolve("levels/primary").normalize(),
|
||||
serverRoot.toRealPath().resolve("levels/primary").normalize(),
|
||||
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
|
||||
);
|
||||
} finally {
|
||||
@@ -391,9 +574,20 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
Path serverRoot,
|
||||
Duration refreshInterval
|
||||
) {
|
||||
URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip");
|
||||
String sourceRoot = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
return new DefaultPackBootstrapProvisioner.ProvisionOptions(
|
||||
source,
|
||||
List.of(
|
||||
new DefaultPackBootstrapProvisioner.PackSpec(
|
||||
"overworld",
|
||||
URI.create(sourceRoot + "/overworld.zip"),
|
||||
"overworld"
|
||||
),
|
||||
new DefaultPackBootstrapProvisioner.PackSpec(
|
||||
"underworld",
|
||||
URI.create(sourceRoot + "/underworld.zip"),
|
||||
"underworld"
|
||||
)
|
||||
),
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
|
||||
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
|
||||
refreshInterval,
|
||||
@@ -406,8 +600,17 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
}
|
||||
|
||||
private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException {
|
||||
return server(response, underworldArchive("underworld_biome"), requests);
|
||||
}
|
||||
|
||||
private static HttpServer server(
|
||||
byte[] overworldResponse,
|
||||
byte[] underworldResponse,
|
||||
AtomicInteger requests
|
||||
) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests));
|
||||
server.createContext("/overworld.zip", exchange -> respond(exchange, overworldResponse, requests));
|
||||
server.createContext("/underworld.zip", exchange -> respond(exchange, underworldResponse, requests));
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
@@ -428,6 +631,33 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
return zip(files);
|
||||
}
|
||||
|
||||
private static byte[] underworldArchive(String biomeId) throws IOException {
|
||||
LinkedHashMap<String, String> files = new LinkedHashMap<>();
|
||||
files.put("dimensions/underworld.json", dimensionJson("underworld"));
|
||||
files.put("dimensions/underworld_roof.json", dimensionJson("underworld_roof"));
|
||||
files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
|
||||
files.put("biomes/local.json", biomeJson(biomeId));
|
||||
return zip(files);
|
||||
}
|
||||
|
||||
private static Properties loadProperties(Path path) throws IOException {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
properties.load(input);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static void assertNoBootstrapTransactionPaths(Path root) throws IOException {
|
||||
if (!Files.isDirectory(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("-stage-")
|
||||
|| path.getFileName().toString().contains("-backup-")));
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException {
|
||||
Files.createDirectories(root.resolve("dimensions"));
|
||||
Files.createDirectories(root.resolve("regions"));
|
||||
@@ -462,8 +692,8 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<Path> stream = Files.walk(root)) {
|
||||
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) {
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -95,6 +96,60 @@ public class PackDownloaderTest {
|
||||
PackDownloader.defaultOverworldReleaseUrl()
|
||||
);
|
||||
assertTrue(PackDownloader.isDefaultOverworld("overworld"));
|
||||
assertTrue(PackDownloader.isManagedBetaPack("overworld"));
|
||||
assertEquals(List.of("overworld", "underworld"), PackDownloader.managedBetaPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesUnderworldBetaRelease() {
|
||||
assertEquals(
|
||||
"https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip",
|
||||
PackDownloader.underworldReleaseUrl()
|
||||
);
|
||||
assertTrue(PackDownloader.isManagedBetaPack("underworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void managedPackPresenceRequiresItsPrimaryDimension() throws Exception {
|
||||
File packsFolder = temp.newFolder("managed-presence");
|
||||
Path dimensions = Files.createDirectories(
|
||||
packsFolder.toPath().resolve("underworld/dimensions")
|
||||
);
|
||||
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld_roof");
|
||||
|
||||
assertTrue(PackDownloader.isPackPresent(packsFolder, "underworld"));
|
||||
assertFalse(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
|
||||
|
||||
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld");
|
||||
assertTrue(Files.isDirectory(dimensions));
|
||||
assertTrue(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repairsManagedFolderMissingItsPrimaryDimension() throws Exception {
|
||||
File packsFolder = temp.newFolder("managed-repair-packs");
|
||||
Path target = packsFolder.toPath().resolve("underworld");
|
||||
Files.createDirectories(target.resolve("dimensions"));
|
||||
writeDimension(target, "underworld_roof");
|
||||
Files.writeString(target.resolve("partial.txt"), "partial", StandardCharsets.UTF_8);
|
||||
File extracted = writePack(temp.newFolder("managed-repair-source").toPath(), "underworld", "new");
|
||||
writeDimension(extracted.toPath(), "underworld_roof");
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
false,
|
||||
"underworld",
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.changed());
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld.json")));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld_roof.json")));
|
||||
assertFalse(Files.exists(target.resolve("partial.txt")));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -102,6 +157,9 @@ public class PackDownloaderTest {
|
||||
assertFalse(PackDownloader.isDefaultOverworld("theend"));
|
||||
assertFalse(PackDownloader.isDefaultOverworld(""));
|
||||
assertFalse(PackDownloader.isDefaultOverworld(null));
|
||||
assertFalse(PackDownloader.isManagedBetaPack("theend"));
|
||||
assertFalse(PackDownloader.isManagedBetaPack(""));
|
||||
assertFalse(PackDownloader.isManagedBetaPack(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -345,6 +403,71 @@ public class PackDownloaderTest {
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void importsExpectedDimensionFromMultiDimensionPack() throws Exception {
|
||||
File packsFolder = temp.newFolder("multi-dimension-packs");
|
||||
File extracted = writePack(temp.newFolder("multi-dimension-source").toPath(), "underworld", "new");
|
||||
writeDimension(extracted.toPath(), "underworld_roof");
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
false,
|
||||
"underworld",
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals("underworld", result.key());
|
||||
assertTrue(result.changed());
|
||||
assertTrue(Files.isRegularFile(
|
||||
packsFolder.toPath().resolve("underworld/dimensions/underworld_roof.json")
|
||||
));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMultiDimensionPackWithoutExpectedDimension() throws Exception {
|
||||
File packsFolder = temp.newFolder("missing-dimension-packs");
|
||||
File extracted = writePack(temp.newFolder("missing-dimension-source").toPath(), "underworld", "new");
|
||||
writeDimension(extracted.toPath(), "underworld_roof");
|
||||
|
||||
IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
false,
|
||||
"missing",
|
||||
ignored -> {
|
||||
}
|
||||
));
|
||||
|
||||
assertTrue(failure.getMessage().contains("missing"));
|
||||
assertTrue(failure.getMessage().contains("underworld"));
|
||||
assertFalse(new File(packsFolder, "missing").exists());
|
||||
assertTransactionStateClean(packsFolder);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAmbiguousMultiDimensionPackWithoutExpectedKey() throws Exception {
|
||||
File packsFolder = temp.newFolder("ambiguous-dimension-packs");
|
||||
File extracted = writePack(temp.newFolder("ambiguous-dimension-source").toPath(), "underworld", "new");
|
||||
writeDimension(extracted.toPath(), "underworld_roof");
|
||||
List<String> feedback = new ArrayList<>();
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
false,
|
||||
null,
|
||||
feedback::add
|
||||
);
|
||||
|
||||
assertNull(result);
|
||||
assertFalse(feedback.isEmpty());
|
||||
assertFalse(new File(packsFolder, "underworld").exists());
|
||||
assertTransactionStateClean(packsFolder);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception {
|
||||
File packsFolder = temp.newFolder("concurrent-packs");
|
||||
@@ -465,12 +588,7 @@ public class PackDownloaderTest {
|
||||
Files.createDirectories(root.resolve("dimensions"));
|
||||
Files.createDirectories(root.resolve("regions"));
|
||||
Files.createDirectories(root.resolve("biomes"));
|
||||
Files.writeString(
|
||||
root.resolve("dimensions/" + key + ".json"),
|
||||
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
|
||||
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
writeDimension(root, key);
|
||||
Files.writeString(
|
||||
root.resolve("regions/local.json"),
|
||||
"{\"name\":\"Local\",\"landBiomes\":[\"local\"]}",
|
||||
@@ -485,6 +603,15 @@ public class PackDownloaderTest {
|
||||
return root.toFile();
|
||||
}
|
||||
|
||||
private static void writeDimension(Path root, String key) throws IOException {
|
||||
Files.writeString(
|
||||
root.resolve("dimensions/" + key + ".json"),
|
||||
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
|
||||
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
|
||||
private static void writeArchive(Path archive, Map<String, String> entries) throws IOException {
|
||||
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class StudioSVCManagedBetaPackTest {
|
||||
@Test
|
||||
public void startupSelectsOnlyMissingManagedBetaPacks() throws IOException {
|
||||
Path workspace = Files.createTempDirectory("iris-managed-beta-startup");
|
||||
try {
|
||||
assertEquals(
|
||||
List.of("overworld", "underworld"),
|
||||
StudioSVC.missingManagedBetaPacks(workspace.toFile())
|
||||
);
|
||||
|
||||
createPack(workspace, "overworld");
|
||||
assertEquals(
|
||||
List.of("underworld"),
|
||||
StudioSVC.missingManagedBetaPacks(workspace.toFile())
|
||||
);
|
||||
|
||||
createDimension(workspace, "underworld", "underworld_roof");
|
||||
assertEquals(
|
||||
List.of("underworld"),
|
||||
StudioSVC.missingManagedBetaPacks(workspace.toFile())
|
||||
);
|
||||
|
||||
createPack(workspace, "underworld");
|
||||
assertEquals(List.of(), StudioSVC.missingManagedBetaPacks(workspace.toFile()));
|
||||
} finally {
|
||||
deleteTree(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createPack(Path workspace, String key) throws IOException {
|
||||
createDimension(workspace, key, key);
|
||||
}
|
||||
|
||||
private static void createDimension(Path workspace, String folder, String key) throws IOException {
|
||||
Path dimensions = Files.createDirectories(workspace.resolve(folder).resolve("dimensions"));
|
||||
Files.writeString(dimensions.resolve(key + ".json"), "{}", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void deleteTree(Path root) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(root)) {
|
||||
for (Path path : paths.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
# 01 - Installation & Platforms
|
||||
|
||||
Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the default `overworld` pack is downloaded when missing; packs live under each platform’s data directory.
|
||||
Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the managed `overworld` and `underworld` beta packs are downloaded when missing; packs live under each platform’s data directory.
|
||||
|
||||
## Installation outcome
|
||||
|
||||
Complete one platform path below. A successful install has all three results:
|
||||
|
||||
1. Iris reaches its enabled/ready state without an exception.
|
||||
2. The platform data directory contains `settings.json` and a loadable `packs/overworld/` directory.
|
||||
2. The platform data directory contains `settings.json` and loadable `packs/overworld/` and `packs/underworld/` directories.
|
||||
3. `/iris` prints help from the server console. On a modded client, the Iris keybind category is an additional client-side check, not a substitute for the server check.
|
||||
|
||||
Keep the previous jar/mod and the entire Iris data directory until the new build passes these checks. Replacing the binary does not update pack snapshots already stored inside worlds.
|
||||
@@ -22,7 +22,7 @@ Keep the previous jar/mod and the entire Iris data directory until the new build
|
||||
| Fabric Loader | 0.19.3+ |
|
||||
| Forge | 65.0.4+ |
|
||||
| NeoForge | 26.2.0.12-beta+ |
|
||||
| Network | Outbound HTTPS on first boot for default pack download (GitHub IrisDimensions overworld release / pack install) |
|
||||
| Network | Outbound HTTPS on first boot for the GitHub IrisDimensions Overworld and Underworld beta release assets |
|
||||
|
||||
Before replacing an existing installation:
|
||||
|
||||
@@ -37,7 +37,7 @@ Do not copy multiple Iris platform jars into the same `plugins/` or `mods/` dire
|
||||
|
||||
1. Place the CraftBukkit-labelled plugin jar into `plugins/`.
|
||||
2. Start the server. Iris loads at `STARTUP` (`plugin.yml` / `paper-plugin.yml`).
|
||||
3. On first boot Iris provisions the default `overworld` pack into `plugins/Iris/packs/overworld` when missing (source: IrisDimensions overworld `beta` release zip).
|
||||
3. On first boot Iris provisions `overworld` and `underworld` into `plugins/Iris/packs/` when missing from their IrisDimensions `beta` release ZIPs.
|
||||
4. Settings are written at `plugins/Iris/settings.json` if absent (`IrisSettings.read()`).
|
||||
|
||||
Validate the plugin install from the server console:
|
||||
@@ -45,6 +45,7 @@ Validate the plugin install from the server console:
|
||||
```text
|
||||
/iris version
|
||||
/iris pack validate pack=overworld
|
||||
/iris pack validate pack=underworld
|
||||
```
|
||||
|
||||
The first command must report the running Iris, platform, and Minecraft versions. The second must resolve the downloaded pack and finish without blocking validation errors. Then complete the disposable-world workflow in `02 - Getting Started.md`; a command response alone does not prove that the generator can create chunks.
|
||||
@@ -66,16 +67,17 @@ Before creating a real world, run the Bukkit fresh-install smoke in `31 - Operat
|
||||
1. Place the matching mod jar into `mods/`.
|
||||
2. Start the dedicated server (or a client for singleplayer; see below).
|
||||
3. The jar is self-contained: core, SPI, and required Fabric API modules are bundled where applicable. Mod id: `irisworldgen`.
|
||||
4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default) and `defaultPack` (default `overworld`) is missing, Iris downloads `IrisDimensions/<pack>` (branch `master` for the auto-prefetch path) into the packs folder before the forced worldgen datapack is written.
|
||||
4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default), Iris installs the managed `overworld` and `underworld` beta releases when missing, followed by a configured non-managed `defaultPack` when applicable, before the forced worldgen datapack is written.
|
||||
|
||||
Validate the server-side mod install:
|
||||
|
||||
```text
|
||||
/iris version
|
||||
/iris pack validate overworld
|
||||
/iris pack validate underworld
|
||||
```
|
||||
|
||||
The install passes when Iris reports the expected loader/version, `config/irisworldgen/packs/overworld/dimensions/` contains a dimension JSON file, and validation has no blocking errors. Restart once before creating a world if the pack or its generated dimension-type datapack was installed during this boot.
|
||||
The install passes when Iris reports the expected loader/version, both managed pack directories contain their primary dimension JSON, and validation has no blocking errors. Restart once before creating a world if a pack or its generated dimension-type datapack was installed during this boot.
|
||||
|
||||
Packs installed later register custom dimension types (height ranges) and custom biomes through the forced datapack at server start. **Restart once after adding a pack** so worlds get full heights and biomes. Worlds created before that restart run with fallback heights.
|
||||
|
||||
@@ -118,7 +120,7 @@ Pack resolution for engines, commands, and the forced datapack uses `config/iris
|
||||
| Key | Default | Effect |
|
||||
|---|---|---|
|
||||
| `defaultPack` | `overworld` | Pack auto-download and default create pack name |
|
||||
| `autoDownloadDefaultPack` | `true` | Async prefetch at boot when pack missing |
|
||||
| `autoDownloadDefaultPack` | `true` | Async prefetch of both managed beta packs and any configured non-managed default when missing |
|
||||
| `primaryWorld` | `""` | Primary-world router target dimension id |
|
||||
| `routePlayersToPrimaryWorld` | `true` | Route players from vanilla overworld when primary is set |
|
||||
| `mainWorldPack` | `""` | Main-world generator override pack ref |
|
||||
@@ -142,17 +144,17 @@ Full key list: `03 - Configuration.md`.
|
||||
|
||||
| Platform | Behavior |
|
||||
|---|---|
|
||||
| Plugin | `DefaultPackBootstrapProvisioner` downloads `https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip` into `packs/overworld` when not already provisioned |
|
||||
| Mod | If `autoDownloadDefaultPack` and pack missing, async install of configured `defaultPack` into `config/irisworldgen/packs` |
|
||||
| Plugin | `DefaultPackBootstrapProvisioner` independently manages the Overworld and Underworld beta assets under `packs/overworld` and `packs/underworld`, then compiles the aggregate datapack once |
|
||||
| Mod | If `autoDownloadDefaultPack` is enabled, async install of both managed beta packs plus any distinct configured default into `config/irisworldgen/packs` |
|
||||
|
||||
Manual install: `/iris download <pack>` (alias `dl`). Default overworld uses the beta-release path; other packs use `IrisDimensions/<pack>/<branch>` (plugin default branch `stable` for non-default; mod download defaults branch `stable` unless auto-prefetch uses `master` — see `25 - Pack Management.md`).
|
||||
Manual install: `/iris download <pack>` (alias `dl`). `overworld` and `underworld` use their beta-release assets and ignore the branch argument; other packs use `IrisDimensions/<pack>/<branch>` (default branch `stable` — see `25 - Pack Management.md`).
|
||||
|
||||
## Installation recovery
|
||||
|
||||
| Symptom | Check | Recovery |
|
||||
|---|---|---|
|
||||
| Iris does not appear in `/iris version` | Wrong directory, wrong platform jar, duplicate jar, Java mismatch, or an enable exception | Stop the server, keep only the matching artifact, confirm Java 25, and fix the first Iris exception in the startup log |
|
||||
| `settings.json` exists but `packs/overworld` does not | Default-pack download failed or is still incomplete | Restore outbound HTTPS or install a complete pack, then restart; do not create an empty `overworld` folder |
|
||||
| `settings.json` exists but a managed pack is absent | Managed beta download failed or is still incomplete | Restore outbound HTTPS or install the complete release pack, then restart; do not create an empty pack folder |
|
||||
| Pack validates but modded height/biomes use fallbacks | Forced datapack was generated after registries loaded | Restart once with the pack already installed, then create a new disposable world |
|
||||
| Bukkit command is denied for a non-op | `iris.all` is missing | Grant `iris.all`; `iris.treefeller` controls only survival tree felling |
|
||||
| Client HUD is absent but server commands work | Client mod missing, disabled keybind, or server capability not negotiated | Install the matching client mod, reconnect, and verify the Iris keybind category; server generation does not require the client HUD |
|
||||
|
||||
@@ -15,7 +15,7 @@ Treat each numbered section as a gate. Confirm the world is loaded before telepo
|
||||
- Iris installed per `01 - Installation & Platforms.md`
|
||||
- Java 25 server or mod instance running
|
||||
- Operator / gamemaster access (`iris` commands; modded mutating commands require permission level 2 / gamemasters)
|
||||
- Default pack present (auto-downloaded on first boot) or an installed pack under the platform packs directory
|
||||
- Managed Overworld and Underworld packs present (auto-downloaded on first boot) or the required project pack installed under the platform packs directory
|
||||
|
||||
## Argument style
|
||||
|
||||
@@ -263,7 +263,7 @@ The session passes when the production world loads again after a clean restart a
|
||||
| Bukkit optional args without `key=` | Parse error | Use `seed=1337`, not a bare second number for optional params |
|
||||
| Mod pregen while another job runs | Start fails | `/iris pregen stop` then start again |
|
||||
| Studio closed mid-edit | World discarded | Edits on disk in `packs/` remain; reopen studio |
|
||||
| Default pack download blocked | Create/open fails missing pack | Allow HTTPS or `/iris download overworld` offline install of a pack tree |
|
||||
| Managed pack download blocked | Startup or create/open fails with a missing pack | Allow HTTPS or install with `/iris download overworld` and `/iris download underworld`; an offline install must contain each complete pack tree |
|
||||
| `type=default` vs pack key | Resolves via `generator.defaultWorldType` | Prefer explicit `type=overworld` or your pack key |
|
||||
|
||||
## Quick reference
|
||||
|
||||
@@ -244,8 +244,8 @@ Path: `<configDir>/irisworldgen/modded.json`. Written with defaults on first loa
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `defaultPack` | string | `"overworld"` | Default pack for bootstrap download/install |
|
||||
| `autoDownloadDefaultPack` | boolean | `true` | Download default pack when missing |
|
||||
| `defaultPack` | string | `"overworld"` | Default create pack; a distinct non-managed value is also prefetched when enabled |
|
||||
| `autoDownloadDefaultPack` | boolean | `true` | Download missing managed Overworld/Underworld beta packs and any distinct configured default |
|
||||
| `primaryWorld` | string | `""` | Primary Iris dimension id for player routing |
|
||||
| `routePlayersToPrimaryWorld` | boolean | `true` | Route players to primary when set |
|
||||
| `mainWorldPack` | string | `""` | Pack (or `pack:dimensionKey`) for main-world preset |
|
||||
|
||||
@@ -79,7 +79,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
|
||||
| `load` | `import` | **Bukkit** | `<world>` | Load managed Iris world |
|
||||
| `unload` | | **Bukkit** | `<world>` | Unload Iris world |
|
||||
| `debug` | | Both | — | Toggle `general.debug` and save settings |
|
||||
| `download` | `dl` | Both | `<pack> [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download pack project |
|
||||
| `download` | `dl` | Both | `<pack> [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download a pack; `overworld` and `underworld` resolve to managed beta release ZIPs |
|
||||
| `metrics` | `measure` | Both | — | Generation metrics (player / current Iris level) |
|
||||
| `reload` | | Both | — | Reload `settings.json` and locale; modded also schedules forced datapack regeneration |
|
||||
| `seed` | | **Modded** | — | Print world/engine seeds (gamemaster) |
|
||||
@@ -102,7 +102,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
|
||||
|
||||
---
|
||||
|
||||
On Bukkit, `overwrite=true` is deliberately restart-only. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required.
|
||||
On Paper-family servers, `overwrite=true` is deliberately restart-only; Spigot rejects it because it has no pre-registry plugin bootstrap. The exact target dimension folder must already exist; use ordinary `/iris create` for a new world. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ Live overworld also contains authoring-only or empty trees that loaders do not r
|
||||
2. `dimensions/` is missing.
|
||||
3. `dimensions/` has no `*.json` files.
|
||||
|
||||
A downloaded archive is also rejected unless it contains exactly one loadable dimension (install key becomes that dimension’s load key). Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file.
|
||||
A downloaded archive without an expected key is rejected unless it contains exactly one loadable dimension, whose load key becomes the install key. Managed-release and listing downloads carry an exact expected primary key, so their archive may retain additional dimension resources; the expected dimension selects the folder key and the entire pack still validates before publication. Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file.
|
||||
|
||||
## Snippets
|
||||
|
||||
|
||||
@@ -126,11 +126,11 @@ Runtime world creation is disabled on Folia. `/iris create` instead:
|
||||
|
||||
## Exact world-slot replacement
|
||||
|
||||
`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for restart on Bukkit-family servers, including Paper and Folia. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow.
|
||||
`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for a complete Paper-family restart, including Paper, Purpur, Leaf, and Folia; Spigot has no early registry bootstrap and rejects this mode. The exact target dimension folder must already exist; ordinary create remains the path for a new world. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow.
|
||||
|
||||
The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, journals the original target state and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. During Iris `STARTUP`, before Bukkit loads worlds, each authorized transaction atomically moves the old exact dimension directory to a retained sibling backup and publishes its stage. There is no chunk merge: old region, entity, POI, and Iris data remain only in the backup, while the target starts with the staged pack snapshot.
|
||||
The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, binds its journal to the canonical level root and logical world name, records the original target and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. Paper bootstrap reconciles each authorized transaction before Iris compiles its aggregate datapack or Minecraft builds registries: it atomically moves the old exact dimension directory to a retained sibling backup and publishes the stage. The filesystem must support atomic replacement for the world directories, journal, and `bukkit.yml`; Iris refuses the operation without falling back to a non-atomic destructive move. Publication retains Paper's per-world `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` so the replacement keeps the exact slot metadata and authoritative seed. Old `region`, `entities`, `poi`, and Iris runtime data are never merged; they remain only in the backup while the target starts with the staged pack snapshot.
|
||||
|
||||
The backup is deleted only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint. A failed runtime check journals rollback, restores the prior `bukkit.yml` generator/seed with compare-and-swap semantics, requests another restart, and restores the retained directory before that restart loads worlds. A crash between either atomic move or journal write is retried idempotently. Conflicting manual configuration, changed staged bytes, unsafe storage, or corrupt journals block Iris world admission and preserve the stage/backup for operator recovery instead of guessing or deleting.
|
||||
The backup is eligible for asynchronous deletion only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint; cleanup failure retains its committed journal and retries without rolling back a verified world. A failed runtime check journals rollback and requests another restart, then cold bootstrap restores the retained directory and prior `bukkit.yml` generator/seed before registry or world loading. A crash between any move, configuration write, or journal phase is retried idempotently. Conflicting manual configuration, changed roots or logical names, changed staged bytes, unsafe storage, duplicate/corrupt journals, or irreconcilable transaction state abort the early bootstrap and preserve recoverable artifacts instead of guessing or deleting.
|
||||
|
||||
## Studio create
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ If validation reports a missing edge, restore or repair that resource before pac
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| Bukkit: `/iris download <pack> [branch=stable] [overwrite=false]`; modded: `/iris download <pack> [branch] [force]` | Download into packs root |
|
||||
| Default overworld special case | Pack name `overworld` uses IrisDimensions overworld **beta release zip** (`…/releases/download/beta/overworld.zip`), not an arbitrary branch zip |
|
||||
| Managed beta packs | Pack names `overworld` and `underworld` use their IrisDimensions **beta release ZIPs**, not arbitrary branch archives |
|
||||
| Other packs | `IrisDimensions/<pack>/<branch>` GitHub archive search via `StudioSVC.downloadSearch` |
|
||||
|
||||
| Param | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `pack` | required | Folder/key or repo short name |
|
||||
| `branch` | `stable` | GitHub ref when not default overworld |
|
||||
| `branch` | `stable` | GitHub ref when the pack is not a managed beta |
|
||||
| `overwrite` | `false` | Force replace existing present pack |
|
||||
|
||||
### Install pipeline (`PackDownloader`)
|
||||
@@ -48,12 +48,12 @@ If validation reports a missing edge, restore or repair that resource before pac
|
||||
1. Per-key/ref download lock (concurrent startup and commands do not double-fetch).
|
||||
2. If pack present and not force → skip network.
|
||||
3. Download zip (size/entry limits: archive ≤512MiB, ≤100k entries, total uncompressed budget, per-file cap).
|
||||
4. Unpack to temp; require single pack home directory.
|
||||
5. Open as datapack-compiler `IrisData`; require **exactly one** dimension; key = that dimension load key.
|
||||
4. Unpack to temp; require a single pack home directory.
|
||||
5. Open as datapack-compiler `IrisData`. A normal download without an expected key requires exactly one dimension; a managed or listing download selects its exact expected dimension while retaining and validating any additional dimension resources in the same pack.
|
||||
6. Run `PackValidator.validate`; blocking errors abort install.
|
||||
7. Publish into `packs/<key>/` with conflict checks (refuses symlink targets; detects dimension-key conflicts with other folders).
|
||||
|
||||
Default overworld repository constant: `IrisDimensions/overworld`.
|
||||
Managed beta sources are `IrisDimensions/overworld` (`overworld.zip`) and `IrisDimensions/underworld` (`underworld.zip`). Startup treats their ownership independently: an operator-edited or linked pack is preserved without preventing the other managed pack from updating.
|
||||
|
||||
## Validate
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ Worlds created from a pack store a **copy** at:
|
||||
|
||||
`StudioSVC.installIntoWorld` and `replaceIntoWorld` copy the source pack tree into that directory. Runtime generation for a normal world reads the world copy, not the global `packs/` tree. Studio worlds hotload the pack under `packs/` directly.
|
||||
|
||||
First install often downloads the default overworld release into `packs/` (`downloadDefaultOverworld` / `/iris download` flows — see `02 - Getting Started.md`, `25 - Pack Management.md`).
|
||||
First install downloads the managed Overworld and Underworld beta releases into `packs/`; `/iris download overworld` uses the same Overworld asset (see `02 - Getting Started.md`, `25 - Pack Management.md`).
|
||||
|
||||
## High-level layout (shipping overworld)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
|
||||
|
||||
| Concern | Bukkit | Modded |
|
||||
|---------|--------|--------|
|
||||
| Create | `/iris create` → managed world name, generator Iris, optional main-world; `overwrite=true` stages exact Iris/vanilla-slot replacement for restart | `/iris create` or `/iris world enable` → dimension id + pack injection |
|
||||
| Create | `/iris create` → managed world name, generator Iris, optional main-world; on Paper-family servers `overwrite=true` stages replacement of an existing exact Iris/vanilla slot for restart | `/iris create` or `/iris world enable` → dimension id + pack injection |
|
||||
| Load / unload | `/iris load` (`import`), `/iris unload` | `/iris world disable` unloads; no separate load command |
|
||||
| Remove / delete | `/iris remove` optional folder delete | `/iris world delete` wipes chunk/mantle data |
|
||||
| Primary / main world | create `main=true` for a new level root, or name the configured main with `overwrite=true` for journaled in-place dimension replacement | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` |
|
||||
@@ -49,7 +49,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
|
||||
| Studio world | Transient studio world via StudioSVC; `/iris jigsaw` can select the Jigsaw Studio generator for one activation | Studio dimension under `irisworldgen:studio_*`; no Jigsaw Studio authoring command tree |
|
||||
| Folia | Regionized schedulers; pregen `runtimeSchedulerMode` forces `FOLIA` when regionized | N/A (not Bukkit Folia) |
|
||||
|
||||
Default pack bootstrap still downloads the IrisDimensions overworld release into `packs/overworld` when missing (shared provisioner).
|
||||
Startup installs the IrisDimensions Overworld and Underworld beta releases into `packs/overworld` and `packs/underworld` when missing. Paper bootstrap publishes both in one rollback scope before compiling the aggregate datapack; legacy Bukkit and modded startup use the same managed release sources.
|
||||
|
||||
Modded startup quarantines a corrupt persistent-dimension registry as `iris-dimensions.json.broken-<timestamp>` and continues without those dynamic worlds. Recovery details are in `06 - Worlds & Lifecycle.md`.
|
||||
|
||||
@@ -76,7 +76,7 @@ Jigsaw pack resources are shared runtime data, but in-game Jigsaw Studio is not
|
||||
| Jigsaw Studio create/grid/marker capture/rules/export | yes | no | no | no |
|
||||
| Saved planar/spatial Iris jigsaw runtime | yes | yes | yes | yes |
|
||||
| Pack validate / cleanup / download | yes | yes | yes | yes |
|
||||
| Exact restart replacement of configured Overworld/Nether/End slots | yes | no | no | no |
|
||||
| Exact restart replacement of configured Overworld/Nether/End slots | Paper/Purpur/Leaf/Folia | no | no | no |
|
||||
| Pregen | yes (Paper-like / Folia modes) | yes (`moddedPregenInFlight`) | yes | yes |
|
||||
| Studio open/close/vscode/package | yes | yes | yes | yes |
|
||||
| Object wand / paste / save / undo | yes | yes | yes | yes |
|
||||
|
||||
@@ -25,7 +25,7 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
|
||||
## A. Fresh install and first world (Bukkit-family)
|
||||
|
||||
1. Install the CraftBukkit-family jar into `plugins/` (Paper, Purpur, Folia, Spigot, Leaf, Canvas as advertised). Require Java 25. See `01 - Installation & Platforms.md`.
|
||||
2. Start the server once. Confirm Iris enables, default pack download completes when no pack is present, and `settings.json` is written under the Iris data directory.
|
||||
2. Start the server once. Confirm Iris enables, managed `overworld` and `underworld` beta downloads complete when absent, and `settings.json` is written under the Iris data directory.
|
||||
3. Create a world with a fixed seed and teleport into it:
|
||||
|
||||
```
|
||||
@@ -36,21 +36,21 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
|
||||
4. Join or teleport into the world. Confirm non-empty terrain, surface biomes, and no repeating console stack traces on first chunks.
|
||||
5. Gate: world is loaded as an Iris world; chunks generate without enable-time crash; console shows no fatal engine init failure.
|
||||
|
||||
## A.1 Exact vanilla-slot replacement (Bukkit-family)
|
||||
## A.1 Exact vanilla-slot replacement (Paper-family)
|
||||
|
||||
Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files before staging.
|
||||
Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files, and retain copies of `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` for comparison before staging.
|
||||
|
||||
1. Run `/iris create world_nether type=<nether-pack> seed=1337 overwrite=true`. Gate: the command says the replacement is staged, the loaded Nether and its files remain unchanged, `bukkit.yml` now names `Iris:<dimension>`, and one pending replacement journal plus one sibling stage exists.
|
||||
2. Optionally stage the configured main name with a `NORMAL` pack and the End alias with a `THE_END` pack. Gate: each distinct slot gets its own transaction and no live dimension folder is moved.
|
||||
3. Restart normally. Gate: Iris publishes before Bukkit world loading; `minecraft:the_nether` loads with the Iris generator, requested dimension and seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent.
|
||||
4. Gate after `WorldLoad`: the retained sibling backup and journal disappear only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds.
|
||||
3. Restart normally. Gate: Iris publishes before aggregate-datapack compilation and Bukkit world loading; `minecraft:the_nether` loads with the selected Iris dimension, the prior Paper per-world metadata files, and the authoritative shared level seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent.
|
||||
4. Gate after `WorldLoad`: the journal advances to committed cleanup only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds; asynchronous cleanup then removes the retained sibling backup and journal without stalling the world thread.
|
||||
5. Restart again and generate fresh Nether chunks. Gate: the exact vanilla identity and Iris generator persist, ordinary Nether portals still target `minecraft:the_nether`, and no pending stage/backup/journal returns.
|
||||
6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: Iris refuses publication or world admission, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris restores the prior configuration, requests the controlled rollback restart, and restores the retained original directory before world load.
|
||||
6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: early Paper bootstrap aborts before registry/world loading, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris journals rollback and requests the controlled restart, then restores the retained original directory and prior configuration before datapack compilation or world loading.
|
||||
|
||||
## B. Fresh install and first world (Fabric / Forge / NeoForge)
|
||||
|
||||
1. Install the matching mod jar into `mods/`. Fabric requires Loader ≥ declared floor; Forge/NeoForge require their declared floors. See `01 - Installation & Platforms.md` and `30 - Platform Differences.md`.
|
||||
2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, default pack installs, and datapack/biome registration completes.
|
||||
2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, both managed beta packs install, and datapack/biome registration completes.
|
||||
3. Create a world with fixed seed (positional mod syntax):
|
||||
|
||||
```
|
||||
|
||||
@@ -270,7 +270,7 @@ Gate: every advertised server, loader, client, and content path completes the sa
|
||||
|
||||
### Confirmed release blockers and follow-ups
|
||||
|
||||
- [ ] Freeze the default overworld pack to an immutable release input. The runtime downloader currently follows the mutable `master` branch, so any recorded tree checksum remains reproducible only while that upstream content is unchanged. Immutable branch/tag/commit URL resolution is implemented, but published commit `8e32852ee6ecd039fae27a36f701f57cdc02e83f` predates the five local slime-category and biome-tag corrections, the dormant standard entity resource restoration, and removal of the legacy default ambient-spawner attachments; publish those pack edits under a new commit/tag before pinning automatic installs.
|
||||
- [ ] Publish and retain anonymously downloadable `beta` assets for both managed pack repositories before shipping a build that requires dual bootstrap. Runtime uses the mutable Overworld and Underworld beta release URLs; record each downloaded asset checksum for a release baseline, and move production installs to immutable release inputs when the beta streams are promoted.
|
||||
- [x] Make modded GoldenHash metadata use the active Iris engine seed. Fabric, Forge, and NeoForge generated identical output from Iris seed `1337`, but filenames and headers recorded each vanilla level seed, preventing one captured baseline file from being reused directly across loaders.
|
||||
- [x] Correct the default overworld pack's slime spawn category from implicit `MISC` to explicit `MONSTER` in `biomes/vanilla/mangrove_swamp.json`, `biomes/swamp/cambian-drift.json`, `biomes/swamp/cambian-drift-extended.json`, `biomes/swamp/marsh.json`, and `biomes/swamp/marsh-rotten.json`. NeoForge exposes the bad category at startup; all loaders generate the same bad datapack entry, which can affect natural slime spawning and mob-cap accounting.
|
||||
- [x] Extend `PackValidator` to reject authored custom-biome spawn categories that disagree with the live entity category instead of allowing the bad datapack to reach loader validation.
|
||||
|
||||
@@ -248,7 +248,7 @@ Paths relative to the loader config directory (`config/`):
|
||||
|
||||
Pack install root is `config/irisworldgen/packs`, not `config/iris`. Missing pack at world open is a hard failure with the expected absolute path (no silent vanilla terrain).
|
||||
|
||||
Async default-pack prefetch at boot when `autoDownloadDefaultPack` is set and `defaultPack` is missing (`IrisDimensions/<pack>` from `master`). Failures log a pointer to `/iris download <pack>`.
|
||||
When `autoDownloadDefaultPack` is enabled, startup asynchronously installs missing `overworld` and `underworld` managed beta releases, plus a distinct configured non-managed `defaultPack`. Failures log a pointer to `/iris download <pack>`; managed beta names always resolve to their release assets rather than the requested branch.
|
||||
|
||||
Forced datapack contributes presets, dimension types, and biomes under `irisworldgen` (ids from pack/dimension names). Regenerated on pack change / studio hotload. Failure to inject (mixin/event not applied) logs once:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user