mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 08:07:42 +00:00
Vwop
This commit is contained in:
1 parent
aaccbacf32
commit
cd217c05f9
174 files changed
+28830
-2645
No files matched your search
@@ -0,0 +1,44 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record DatapackInstallResult(Status status) {
|
||||
public DatapackInstallResult {
|
||||
Objects.requireNonNull(status, "status");
|
||||
}
|
||||
|
||||
public static DatapackInstallResult failedResult() {
|
||||
return new DatapackInstallResult(Status.FAILED);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult unchangedResult() {
|
||||
return new DatapackInstallResult(Status.UNCHANGED);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult readyResult() {
|
||||
return new DatapackInstallResult(Status.READY);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult restartRequiredResult() {
|
||||
return new DatapackInstallResult(Status.RESTART_REQUIRED);
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return status != Status.FAILED;
|
||||
}
|
||||
|
||||
public boolean changed() {
|
||||
return status == Status.READY || status == Status.RESTART_REQUIRED;
|
||||
}
|
||||
|
||||
public boolean restartRequired() {
|
||||
return status == Status.RESTART_REQUIRED;
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
FAILED,
|
||||
UNCHANGED,
|
||||
READY,
|
||||
RESTART_REQUIRED
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package art.arcane.iris.core;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -10,8 +12,12 @@ import art.arcane.volmlib.util.collection.KSet;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
@@ -19,6 +25,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -49,9 +56,7 @@ public final class IrisDatapackCompiler {
|
||||
throw new IOException("No Iris datapack output roots were provided");
|
||||
}
|
||||
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Files.createDirectories(datapackRoot.toPath());
|
||||
}
|
||||
resetOutputRoots(datapackRoots);
|
||||
IrisDimension.clearGeneratedBiomeTags(datapackRoots);
|
||||
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
@@ -59,6 +64,7 @@ public final class IrisDatapackCompiler {
|
||||
int packCount = 0;
|
||||
int dimensionCount = 0;
|
||||
for (File packRoot : packRoots) {
|
||||
PackDirectoryResolver.requireSafePackTree(packRoot);
|
||||
if (!hasDimensions(packRoot.toPath())) {
|
||||
continue;
|
||||
}
|
||||
@@ -93,42 +99,42 @@ public final class IrisDatapackCompiler {
|
||||
}
|
||||
|
||||
IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight);
|
||||
validateOutputs(datapackRoots);
|
||||
validateOutputs(datapackRoots, dimensionCount);
|
||||
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
|
||||
}
|
||||
|
||||
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(packsRoot)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(packsRoot)) {
|
||||
List<Path> candidates = stream
|
||||
.filter(Files::isDirectory)
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
List<File> candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile());
|
||||
for (File candidate : candidates) {
|
||||
addPackRoot(candidate.toPath(), roots);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(dimensionsRoot)) {
|
||||
if (Files.isSymbolicLink(dimensionsRoot)
|
||||
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.find(
|
||||
dimensionsRoot,
|
||||
WORLD_PACK_SCAN_DEPTH,
|
||||
(path, attributes) -> attributes.isDirectory()
|
||||
&& "pack".equals(path.getFileName().toString())
|
||||
&& path.getParent() != null
|
||||
&& "iris".equals(path.getParent().getFileName().toString())
|
||||
&& hasDimensions(path)
|
||||
)) {
|
||||
List<Path> candidates = stream.sorted(Comparator.comparing(Path::toString)).toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(dimensionsRoot)
|
||||
&& PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
if ("pack".equals(directory.getFileName().toString())
|
||||
&& directory.getParent() != null
|
||||
&& "iris".equals(directory.getParent().getFileName().toString())
|
||||
&& hasDimensions(directory)) {
|
||||
candidates.add(directory);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
candidates.sort(Comparator.comparing(Path::toString));
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,29 +143,52 @@ public final class IrisDatapackCompiler {
|
||||
return;
|
||||
}
|
||||
Path normalized = root.toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(normalized)) {
|
||||
return;
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
|
||||
Path identity = normalized.toRealPath();
|
||||
roots.putIfAbsent(identity, normalized.toFile());
|
||||
}
|
||||
|
||||
private static boolean hasDimensions(Path root) {
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
if (!Files.isDirectory(dimensions)) {
|
||||
if (Files.isSymbolicLink(dimensions)
|
||||
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(dimensions)) {
|
||||
return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json"));
|
||||
return stream.anyMatch(path -> !Files.isSymbolicLink(path)
|
||||
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|
||||
&& path.getFileName().toString().endsWith(".json"));
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOutputs(Collection<File> datapackRoots) throws IOException {
|
||||
private static void resetOutputRoots(Collection<File> datapackRoots) throws IOException {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Path root = datapackRoot.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Iris datapack output root is a symbolic link: " + root);
|
||||
}
|
||||
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Iris datapack output root is not a directory: " + root);
|
||||
}
|
||||
AtomicDirectoryPublisher.deleteTree(root);
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOutputs(Collection<File> datapackRoots, int dimensionCount) throws IOException {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Path root = datapackRoot.toPath();
|
||||
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
|
||||
throw new IOException("Iris datapack metadata was not generated at " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
|
||||
if (dimensionCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
|
||||
throw new IOException("Iris dimension types were not generated at " + root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -50,6 +51,10 @@ public final class IrisWorldStorage {
|
||||
return cached;
|
||||
}
|
||||
|
||||
public static String configuredLevelName() {
|
||||
return levelNameFromProperties(new File("server.properties"));
|
||||
}
|
||||
|
||||
static String levelNameFromProperties(File serverProperties) {
|
||||
Properties properties = new Properties();
|
||||
if (Objects.requireNonNull(serverProperties, "serverProperties").isFile()) {
|
||||
@@ -87,6 +92,39 @@ public final class IrisWorldStorage {
|
||||
return keyFromName(worldName, levelRoot().getName());
|
||||
}
|
||||
|
||||
public static NamespacedKey managedKeyFromName(String worldName) {
|
||||
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
if (requestedName.contains(":")) {
|
||||
return managedKeyFromName(requestedName, DEFAULT_LEVEL_NAME);
|
||||
}
|
||||
return managedKeyFromName(requestedName, levelRoot().getName());
|
||||
}
|
||||
|
||||
public static NamespacedKey managedKeyFromName(String worldName, String levelName) {
|
||||
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
if (requestedName.isEmpty()) {
|
||||
throw new IllegalArgumentException("World name cannot be empty.");
|
||||
}
|
||||
if (requestedName.contains("/") || requestedName.contains("\\") || requestedName.contains("..")) {
|
||||
throw new IllegalArgumentException("World name must be a safe single path segment.");
|
||||
}
|
||||
|
||||
NamespacedKey key;
|
||||
if (requestedName.contains(":")) {
|
||||
key = NamespacedKey.fromString(requestedName.toLowerCase(Locale.ENGLISH));
|
||||
if (key == null) {
|
||||
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
|
||||
}
|
||||
} else {
|
||||
key = keyFromName(requestedName, levelName);
|
||||
}
|
||||
|
||||
if (!IRIS_NAMESPACE.equals(key.getNamespace()) || !key.getKey().matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("Only Iris-managed dimension worlds can be changed.");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
static NamespacedKey keyFromName(String worldName, String levelName) {
|
||||
String name = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
|
||||
@@ -112,7 +150,11 @@ public final class IrisWorldStorage {
|
||||
}
|
||||
|
||||
public static String logicalName(NamespacedKey key) {
|
||||
return logicalName(key, levelRoot().getName());
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
|
||||
return worldKey.getKey();
|
||||
}
|
||||
return logicalName(worldKey, levelRoot().getName());
|
||||
}
|
||||
|
||||
static String logicalName(NamespacedKey key, String levelName) {
|
||||
@@ -147,6 +189,31 @@ public final class IrisWorldStorage {
|
||||
return dimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
public static File requireSafeManagedDimensionRoot(NamespacedKey key) {
|
||||
return requireSafeManagedDimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
public static File requireSafeManagedDimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("Only safe Iris-managed dimension worlds can be changed.");
|
||||
}
|
||||
|
||||
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(IRIS_NAMESPACE);
|
||||
Path target = namespace.resolve(worldKey.getKey()).normalize();
|
||||
if (!Objects.equals(target.getParent(), namespace)) {
|
||||
throw new IllegalArgumentException("World target escapes the Iris namespace root.");
|
||||
}
|
||||
for (Path path : new Path[]{dimensions, namespace, target}) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IllegalArgumentException("World storage path contains a symbolic link: " + path);
|
||||
}
|
||||
}
|
||||
return target.toFile();
|
||||
}
|
||||
|
||||
public static File dimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||
.toPath()
|
||||
|
||||
@@ -22,8 +22,15 @@ import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -62,9 +69,42 @@ public class IrisWorlds {
|
||||
});
|
||||
}
|
||||
|
||||
public void put(String identity, String type) {
|
||||
put0(identity, type);
|
||||
save();
|
||||
public synchronized void put(String identity, String type) {
|
||||
String canonicalIdentity = WorldIdentity.parse(identity).toString();
|
||||
String requiredType = Objects.requireNonNull(type, "type");
|
||||
String previous = worlds.put(canonicalIdentity, requiredType);
|
||||
if (requiredType.equals(previous)) {
|
||||
return;
|
||||
}
|
||||
dirty = true;
|
||||
try {
|
||||
saveOrThrow();
|
||||
} catch (IOException e) {
|
||||
if (previous == null) {
|
||||
worlds.remove(canonicalIdentity);
|
||||
} else {
|
||||
worlds.put(canonicalIdentity, previous);
|
||||
}
|
||||
dirty = true;
|
||||
throw new UncheckedIOException("Failed to persist Iris world registry entry for " + canonicalIdentity, e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean remove(String identity) {
|
||||
String canonicalIdentity = WorldIdentity.parse(identity).toString();
|
||||
String previous = worlds.remove(canonicalIdentity);
|
||||
if (previous == null) {
|
||||
return false;
|
||||
}
|
||||
dirty = true;
|
||||
try {
|
||||
saveOrThrow();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
worlds.put(canonicalIdentity, previous);
|
||||
dirty = true;
|
||||
throw new UncheckedIOException("Failed to remove Iris world registry entry for " + canonicalIdentity, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void put0(String identity, String type) {
|
||||
@@ -74,7 +114,7 @@ public class IrisWorlds {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
public KMap<String, String> getWorlds() {
|
||||
public synchronized KMap<String, String> getWorlds() {
|
||||
clean();
|
||||
KMap<String, String> result = new KMap<>();
|
||||
readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type));
|
||||
@@ -95,7 +135,7 @@ public class IrisWorlds {
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public void clean() {
|
||||
public synchronized void clean() {
|
||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||
try {
|
||||
File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey()));
|
||||
@@ -108,18 +148,43 @@ public class IrisWorlds {
|
||||
}
|
||||
|
||||
public synchronized void save() {
|
||||
clean();
|
||||
if (!dirty) return;
|
||||
try {
|
||||
IO.write(IrisPlatforms.get().dataFile("worlds.json"), OutputStreamWriter::new, writer -> GSON.toJson(worlds, TYPE, writer));
|
||||
dirty = false;
|
||||
saveOrThrow();
|
||||
} catch (IOException e) {
|
||||
IrisLogging.error("Failed to save worlds.json!");
|
||||
e.printStackTrace();
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveOrThrow() throws IOException {
|
||||
clean();
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path target = IrisPlatforms.get().dataFile("worlds.json").toPath().toAbsolutePath().normalize();
|
||||
Path parent = target.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("worlds.json target has no parent: " + target);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".iris-worlds-", ".json");
|
||||
try {
|
||||
Files.writeString(staged, GSON.toJson(worlds, TYPE), StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
dirty = false;
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
public static Long readBukkitWorldSeed(String world) {
|
||||
YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||
ConfigurationSection worlds = bukkit.getConfigurationSection("worlds");
|
||||
|
||||
@@ -22,10 +22,14 @@ import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
@@ -47,16 +51,27 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -64,6 +79,8 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
public class ServerConfigurator {
|
||||
private static final Object DATAPACK_INSTALL_LOCK = new Object();
|
||||
|
||||
public static void configure() {
|
||||
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
|
||||
if (s.isConfigureSpigotTimeoutTime()) {
|
||||
@@ -77,7 +94,10 @@ public class ServerConfigurator {
|
||||
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
|
||||
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
|
||||
} else {
|
||||
installDataPacks(true);
|
||||
DatapackInstallResult result = installDataPacks(true);
|
||||
if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) {
|
||||
restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,20 +144,20 @@ public class ServerConfigurator {
|
||||
return roots;
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(boolean fullInstall) {
|
||||
IDataFixer fixer = DataVersion.getDefault();
|
||||
if (fixer == null) {
|
||||
DataVersion fallback = DataVersion.getLatest();
|
||||
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
|
||||
fixer = fallback.get();
|
||||
}
|
||||
return installDataPacks(fixer, fullInstall);
|
||||
public static DatapackInstallResult installDataPacks(boolean fullInstall) {
|
||||
return installDataPacks(resolveDataFixer(), fullInstall);
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) {
|
||||
public static DatapackInstallResult installDataPacks(IDataFixer fixer, boolean fullInstall) {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
return installDataPacksLocked(fixer, fullInstall);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) {
|
||||
if (fixer == null) {
|
||||
IrisLogging.error("Unable to install datapacks, fixer is null!");
|
||||
return false;
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
if (fullInstall) {
|
||||
IrisLogging.info("Checking Data Packs...");
|
||||
@@ -145,7 +165,10 @@ public class ServerConfigurator {
|
||||
IrisLogging.debug("Checking Data Packs...");
|
||||
}
|
||||
KList<File> datapacksFolders = getDatapacksFolder();
|
||||
DatapackIngestService.reapplyFromStaging(datapacksFolders);
|
||||
if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) {
|
||||
IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete.");
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
List<File> packRoots;
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
packRoots = stream
|
||||
@@ -155,17 +178,58 @@ public class ServerConfigurator {
|
||||
.toList();
|
||||
}
|
||||
|
||||
KList<File> liveRoots = getIrisDatapackRoots();
|
||||
KList<File> stagedRoots = new KList<>();
|
||||
List<Path> stagedPaths = new ArrayList<>(liveRoots.size());
|
||||
List<AtomicDirectoryPublisher.Publication> publications = new ArrayList<>(liveRoots.size());
|
||||
try {
|
||||
for (File liveRoot : liveRoots) {
|
||||
Path target = liveRoot.toPath().toAbsolutePath().normalize();
|
||||
Path parent = target.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Iris datapack root has no parent: " + target);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = parent.resolve(".iris-compile-" + UUID.randomUUID());
|
||||
Files.createDirectories(staged);
|
||||
stagedPaths.add(staged);
|
||||
stagedRoots.add(staged.toFile());
|
||||
}
|
||||
IrisDatapackCompiler.compile(
|
||||
packRoots,
|
||||
getIrisDatapackRoots(),
|
||||
stagedRoots,
|
||||
fixer,
|
||||
BukkitPlatform.dataPackFormat(),
|
||||
IrisSettings.get().getGeneral().adjustVanillaHeight
|
||||
);
|
||||
} catch (IOException e) {
|
||||
for (int i = 0; i < liveRoots.size(); i++) {
|
||||
publications.add(AtomicDirectoryPublisher.publish(
|
||||
stagedRoots.get(i).toPath(),
|
||||
liveRoots.get(i).toPath()
|
||||
));
|
||||
}
|
||||
for (AtomicDirectoryPublisher.Publication publication : publications) {
|
||||
publication.commit();
|
||||
try {
|
||||
publication.cleanupBackup();
|
||||
} catch (IOException cleanupFailure) {
|
||||
IrisLogging.warn("Iris datapack was committed but its backup could not be removed: "
|
||||
+ cleanupFailure.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
closePublications(publications, e);
|
||||
IrisLogging.reportError("Unable to compile Iris datapacks", e);
|
||||
return false;
|
||||
return DatapackInstallResult.failedResult();
|
||||
} finally {
|
||||
for (Path stagedPath : stagedPaths) {
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(stagedPath);
|
||||
} catch (IOException cleanupFailure) {
|
||||
IrisLogging.warn("Failed to clean Iris datapack compilation stage " + stagedPath + ": "
|
||||
+ cleanupFailure.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fullInstall) {
|
||||
IrisLogging.info("Data Packs Setup!");
|
||||
@@ -173,74 +237,205 @@ public class ServerConfigurator {
|
||||
IrisLogging.debug("Data Packs Setup!");
|
||||
}
|
||||
|
||||
return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall());
|
||||
boolean restartRequired = fullInstall && verifyDataPacksPost();
|
||||
return restartRequired
|
||||
? DatapackInstallResult.restartRequiredResult()
|
||||
: DatapackInstallResult.readyResult();
|
||||
}
|
||||
|
||||
public static boolean installDataPacksIfChanged(boolean fullInstall) {
|
||||
File packsDir = IrisPlatforms.get().dataFolder("packs");
|
||||
String current = computePackFingerprint(packsDir);
|
||||
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
|
||||
String cached = "";
|
||||
if (cacheFile.exists()) {
|
||||
private static IDataFixer resolveDataFixer() {
|
||||
IDataFixer fixer = DataVersion.getDefault();
|
||||
if (fixer != null) {
|
||||
return fixer;
|
||||
}
|
||||
DataVersion fallback = DataVersion.getLatest();
|
||||
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
|
||||
return fallback.get();
|
||||
}
|
||||
|
||||
private static void closePublications(List<AtomicDirectoryPublisher.Publication> publications, Throwable failure) {
|
||||
for (int i = publications.size() - 1; i >= 0; i--) {
|
||||
try {
|
||||
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException e) {
|
||||
cached = "";
|
||||
publications.get(i).close();
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty() && current.equals(cached)) {
|
||||
IrisLogging.debug("Data packs unchanged, skipping install.");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
File packsDir = IrisPlatforms.get().dataFolder("packs");
|
||||
String current;
|
||||
try {
|
||||
current = computePackFingerprint(packsDir);
|
||||
} catch (RuntimeException exception) {
|
||||
IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception);
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
|
||||
String cached = "";
|
||||
if (cacheFile.exists()) {
|
||||
try {
|
||||
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException e) {
|
||||
cached = "";
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty() && current.equals(cached)) {
|
||||
IrisLogging.debug("Data packs unchanged, skipping install.");
|
||||
return DatapackInstallResult.unchangedResult();
|
||||
}
|
||||
DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall);
|
||||
if (result.succeeded()) {
|
||||
try {
|
||||
writeFingerprintAtomic(cacheFile.toPath(), current);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
boolean result = installDataPacks(fullInstall);
|
||||
try {
|
||||
cacheFile.getParentFile().mkdirs();
|
||||
Files.writeString(cacheFile.toPath(), current, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String computePackFingerprint(File packsDir) {
|
||||
if (packsDir == null || !packsDir.isDirectory()) {
|
||||
if (packsDir == null) {
|
||||
return "";
|
||||
}
|
||||
Path root = packsDir.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IllegalArgumentException("Iris packs root is a symbolic link: " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
List<String> entries = new ArrayList<>();
|
||||
collectFingerprintEntries(packsDir, packsDir.getAbsolutePath(), entries);
|
||||
Collections.sort(entries);
|
||||
for (String entry : entries) {
|
||||
digest.update(entry.getBytes(StandardCharsets.UTF_8));
|
||||
List<FingerprintEntry> entries = collectFingerprintEntries(root);
|
||||
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
|
||||
byte[] buffer = new byte[8192];
|
||||
for (FingerprintEntry entry : entries) {
|
||||
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
|
||||
updateDigestInt(digest, relativePath.length);
|
||||
digest.update(relativePath);
|
||||
updateDigestLong(digest, Files.size(entry.source()));
|
||||
try (InputStream input = Files.newInputStream(entry.source())) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFingerprintEntries(File dir, String rootPath, List<String> entries) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) {
|
||||
return;
|
||||
private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException {
|
||||
Path absoluteTarget = target.toAbsolutePath().normalize();
|
||||
Path parent = absoluteTarget.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Datapack fingerprint target has no parent: " + absoluteTarget);
|
||||
}
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
if (file.getName().startsWith(".")) {
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".datapack-fingerprint-", ".tmp");
|
||||
try {
|
||||
Files.writeString(staged, fingerprint, StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FingerprintEntry> collectFingerprintEntries(Path root) throws IOException {
|
||||
List<FingerprintEntry> entries = new ArrayList<>();
|
||||
try (Stream<Path> children = Files.list(root)) {
|
||||
for (Path child : children.toList()) {
|
||||
String childName = child.getFileName().toString();
|
||||
if (PackDirectoryResolver.isHiddenName(childName)) {
|
||||
continue;
|
||||
}
|
||||
collectFingerprintEntries(file, rootPath, entries);
|
||||
} else {
|
||||
String relative = file.getAbsolutePath().substring(rootPath.length());
|
||||
entries.add(relative + "|" + file.length() + "|" + file.lastModified());
|
||||
if (Files.isSymbolicLink(child)) {
|
||||
if (!Files.isDirectory(child)) {
|
||||
throw new IOException("Iris pack fingerprint rejected symbolic link: " + child);
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(child.toFile());
|
||||
collectFingerprintTree(child.toRealPath(), childName, entries);
|
||||
} else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
collectFingerprintTree(child, childName, entries);
|
||||
} else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
entries.add(new FingerprintEntry(child, childName));
|
||||
} else {
|
||||
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void collectFingerprintTree(
|
||||
Path treeRoot,
|
||||
String logicalRoot,
|
||||
List<FingerprintEntry> entries
|
||||
) throws IOException {
|
||||
Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(treeRoot)
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
if (PackDirectoryResolver.isHiddenName(file.getFileName().toString())) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Iris pack fingerprint rejected symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file);
|
||||
}
|
||||
String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/');
|
||||
entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect Iris pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record FingerprintEntry(Path source, String relativePath) {
|
||||
}
|
||||
|
||||
private static void updateDigestInt(MessageDigest digest, int value) {
|
||||
for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
|
||||
digest.update((byte) (value >>> shift));
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateDigestLong(MessageDigest digest, long value) {
|
||||
for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
|
||||
digest.update((byte) (value >>> shift));
|
||||
}
|
||||
}
|
||||
|
||||
public static File resolveDatapacksFolder(File worldFolder) {
|
||||
@@ -255,12 +450,12 @@ public class ServerConfigurator {
|
||||
return IrisWorldStorage.levelRoot(worldFolder);
|
||||
}
|
||||
|
||||
private static boolean verifyDataPacksPost(boolean allowRestarting) {
|
||||
private static boolean verifyDataPacksPost() {
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
boolean bad = stream
|
||||
.map(data -> {
|
||||
IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath());
|
||||
var loader = data.getDimensionLoader();
|
||||
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
|
||||
return loader.loadAll(loader.getPossibleKeys())
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
@@ -270,13 +465,13 @@ public class ServerConfigurator {
|
||||
})
|
||||
.toList()
|
||||
.contains(true);
|
||||
if (!bad) return false;
|
||||
if (!bad) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (allowRestarting) {
|
||||
restart();
|
||||
} else if (INMS.get().supportsDataPacks()) {
|
||||
if (INMS.get().supportsDataPacks()) {
|
||||
IrisLogging.error("============================================================================");
|
||||
IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
|
||||
IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
|
||||
@@ -292,22 +487,23 @@ public class ServerConfigurator {
|
||||
}
|
||||
}
|
||||
|
||||
J.sleep(3000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void restart() {
|
||||
J.s(() -> {
|
||||
IrisLogging.warn("New data pack entries have been installed in Iris! Restarting server!");
|
||||
IrisLogging.warn("This will only happen when your pack changes (updates/first time setup)");
|
||||
IrisLogging.warn("(You can disable this auto restart in iris settings)");
|
||||
restart("New data pack entries have been installed in Iris.");
|
||||
}
|
||||
|
||||
public static void restart(String reason) {
|
||||
LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> {
|
||||
IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary.");
|
||||
J.s(() -> {
|
||||
IrisLogging.warn("Looks like the restart command didn't work. Stopping the server instead!");
|
||||
Bukkit.shutdown();
|
||||
}, 100);
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart");
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public static boolean verifyDataPackInstalled(IrisDimension dimension) {
|
||||
@@ -360,12 +556,12 @@ public class ServerConfigurator {
|
||||
}
|
||||
|
||||
public static Stream<IrisData> allPacks() {
|
||||
File[] packs = IrisPlatforms.get().dataFolder("packs").listFiles(File::isDirectory);
|
||||
Stream<File> locals = packs == null ? Stream.empty() : Arrays.stream(packs);
|
||||
Stream<File> locals = PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs")
|
||||
).stream();
|
||||
return Stream.concat(locals
|
||||
.filter(base -> !base.getName().contains(".importing-"))
|
||||
.filter( base -> {
|
||||
var content = new File(base, "dimensions").listFiles();
|
||||
.filter(base -> {
|
||||
File[] content = new File(base, "dimensions").listFiles();
|
||||
return content != null && content.length > 0;
|
||||
})
|
||||
.map(IrisData::get), IrisWorlds.get().getPacks());
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class WorldRemovalPathPolicy {
|
||||
private WorldRemovalPathPolicy() {
|
||||
}
|
||||
|
||||
public static Target resolve(String identifier, String configuredMainWorld, Path levelRoot) {
|
||||
return resolve(identifier, configuredMainWorld, List.of(configuredMainWorld), levelRoot);
|
||||
}
|
||||
|
||||
public static Target resolve(
|
||||
String identifier,
|
||||
String currentMainWorld,
|
||||
Collection<String> protectedWorldNames,
|
||||
Path levelRoot
|
||||
) {
|
||||
String requestedIdentifier = requireIdentifier(identifier);
|
||||
String mainWorld = requireIdentifier(currentMainWorld);
|
||||
for (String protectedWorldName : Objects.requireNonNull(protectedWorldNames, "protectedWorldNames")) {
|
||||
if (protectedWorldName != null
|
||||
&& !protectedWorldName.isBlank()
|
||||
&& requestedIdentifier.equalsIgnoreCase(protectedWorldName.trim())) {
|
||||
throw new Rejection(RejectionReason.CONFIGURED_MAIN_WORLD,
|
||||
"A current or configured main world cannot be removed.");
|
||||
}
|
||||
}
|
||||
if (requestedIdentifier.toLowerCase(Locale.ENGLISH).startsWith(NamespacedKey.MINECRAFT + ":")) {
|
||||
throw new Rejection(RejectionReason.MINECRAFT_NAMESPACE,
|
||||
"Minecraft namespace worlds cannot be removed.");
|
||||
}
|
||||
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(requestedIdentifier, mainWorld);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyIdentifierFailure(requestedIdentifier, failure);
|
||||
}
|
||||
|
||||
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(normalizedLevelRoot)) {
|
||||
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||
}
|
||||
Path target;
|
||||
try {
|
||||
target = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
validateStoragePath(normalizedLevelRoot, worldKey, target);
|
||||
return new Target(
|
||||
requestedIdentifier,
|
||||
worldKey,
|
||||
IrisWorldStorage.logicalName(worldKey, mainWorld),
|
||||
normalizedLevelRoot,
|
||||
target
|
||||
);
|
||||
}
|
||||
|
||||
public static void validateStoragePath(Path levelRoot, NamespacedKey worldKey, Path candidate) {
|
||||
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(normalizedLevelRoot)) {
|
||||
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||
}
|
||||
Path expected;
|
||||
try {
|
||||
expected = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
Objects.requireNonNull(worldKey, "worldKey")
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.equals(expected)) {
|
||||
throw new Rejection(RejectionReason.OUTSIDE_STORAGE_ROOT,
|
||||
"The world directory is outside its exact Iris dimension storage root.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) {
|
||||
NamespacedKey parsed = identifier.contains(":")
|
||||
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
|
||||
: null;
|
||||
RejectionReason reason = parsed != null && !"iris".equals(parsed.getNamespace())
|
||||
? RejectionReason.NOT_IRIS_NAMESPACE
|
||||
: RejectionReason.INVALID_IDENTIFIER;
|
||||
return new Rejection(reason, failure.getMessage(), failure);
|
||||
}
|
||||
|
||||
private static Rejection classifyStorageFailure(
|
||||
Path levelRoot,
|
||||
NamespacedKey worldKey,
|
||||
IllegalArgumentException failure
|
||||
) {
|
||||
Path dimensions = levelRoot.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(worldKey.getNamespace());
|
||||
Path target = namespace.resolve(worldKey.getKey());
|
||||
RejectionReason reason = Files.isSymbolicLink(dimensions)
|
||||
|| Files.isSymbolicLink(namespace)
|
||||
|| Files.isSymbolicLink(target)
|
||||
? RejectionReason.SYMBOLIC_LINK
|
||||
: RejectionReason.OUTSIDE_STORAGE_ROOT;
|
||||
return new Rejection(reason, failure.getMessage(), failure);
|
||||
}
|
||||
|
||||
private static String requireIdentifier(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
|
||||
}
|
||||
return identifier.trim();
|
||||
}
|
||||
|
||||
public record Target(
|
||||
String requestedIdentifier,
|
||||
NamespacedKey worldKey,
|
||||
String logicalName,
|
||||
Path levelRoot,
|
||||
Path worldDirectory
|
||||
) {
|
||||
public Target {
|
||||
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(logicalName, "logicalName");
|
||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
Objects.requireNonNull(worldDirectory, "worldDirectory");
|
||||
}
|
||||
}
|
||||
|
||||
public enum RejectionReason {
|
||||
INVALID_IDENTIFIER,
|
||||
CONFIGURED_MAIN_WORLD,
|
||||
MINECRAFT_NAMESPACE,
|
||||
NOT_IRIS_NAMESPACE,
|
||||
OUTSIDE_STORAGE_ROOT,
|
||||
SYMBOLIC_LINK
|
||||
}
|
||||
|
||||
public static final class Rejection extends IllegalArgumentException {
|
||||
private final RejectionReason reason;
|
||||
|
||||
private Rejection(RejectionReason reason, String message) {
|
||||
super(message);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
private Rejection(RejectionReason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
public RejectionReason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,14 +23,17 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -39,6 +42,7 @@ public final class ModrinthResolver {
|
||||
private static final String API = "https://api.modrinth.com/v2";
|
||||
private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
|
||||
private static final String DATAPACK_LOADER = "datapack";
|
||||
private static final int MAX_API_RESPONSE_CHARS = 8 * 1024 * 1024;
|
||||
|
||||
private ModrinthResolver() {
|
||||
}
|
||||
@@ -63,9 +67,19 @@ public final class ModrinthResolver {
|
||||
? selectLatestDatapackVersion(versions, serverMcVersion)
|
||||
: selectVersionByToken(versions, ref.versionToken);
|
||||
if (version == null) {
|
||||
String detail = ref.versionToken == null ? "no datapack-loader version" : "no version matching '" + ref.versionToken + "'";
|
||||
String detail = ref.versionToken == null
|
||||
? (serverMcVersion == null || serverMcVersion.isBlank()
|
||||
? "no datapack-loader version"
|
||||
: "no datapack-loader version compatible with Minecraft " + serverMcVersion)
|
||||
: "no version matching '" + ref.versionToken + "'";
|
||||
throw new IOException("Modrinth project '" + ref.slug + "' has " + detail);
|
||||
}
|
||||
if (!isDatapack(version)) {
|
||||
throw new IOException("Modrinth version for '" + ref.slug + "' is not published for the datapack loader");
|
||||
}
|
||||
if (serverMcVersion != null && !serverMcVersion.isBlank() && !gameVersionsContains(version, serverMcVersion)) {
|
||||
throw new IOException("Modrinth version for '" + ref.slug + "' is not compatible with Minecraft " + serverMcVersion);
|
||||
}
|
||||
|
||||
JsonObject file = selectFile(version);
|
||||
if (file == null) {
|
||||
@@ -76,41 +90,30 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static ModrinthRef parse(String url) {
|
||||
if (!url.toLowerCase(Locale.ROOT).contains("modrinth.com/")) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || !(host.equalsIgnoreCase("modrinth.com") || host.equalsIgnoreCase("www.modrinth.com"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String path = url.replaceFirst("^[a-zA-Z][a-zA-Z0-9+.-]*://", "");
|
||||
int query = path.indexOf('?');
|
||||
if (query >= 0) {
|
||||
path = path.substring(0, query);
|
||||
}
|
||||
int fragment = path.indexOf('#');
|
||||
if (fragment >= 0) {
|
||||
path = path.substring(0, fragment);
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String segment : path.split("/")) {
|
||||
for (String segment : uri.getPath().split("/")) {
|
||||
if (!segment.isBlank()) {
|
||||
parts.add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
int base = -1;
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
if (parts.get(i).equalsIgnoreCase("modrinth.com")) {
|
||||
base = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (base < 0 || parts.size() < base + 3) {
|
||||
if (parts.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String slug = parts.get(base + 2);
|
||||
String slug = parts.get(1);
|
||||
String token = null;
|
||||
for (int i = base + 3; i + 1 < parts.size(); i++) {
|
||||
for (int i = 2; i + 1 < parts.size(); i++) {
|
||||
if (parts.get(i).equalsIgnoreCase("version")) {
|
||||
token = parts.get(i + 1);
|
||||
break;
|
||||
@@ -125,6 +128,9 @@ public final class ModrinthResolver {
|
||||
String normalizedToken = normalizeVersion(token);
|
||||
|
||||
for (JsonElement element : versions) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject version = element.getAsJsonObject();
|
||||
String id = optString(version, "id");
|
||||
String number = optString(version, "version_number");
|
||||
@@ -146,25 +152,24 @@ public final class ModrinthResolver {
|
||||
return datapackMatch != null ? datapackMatch : anyMatch;
|
||||
}
|
||||
|
||||
private static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) {
|
||||
JsonObject firstDatapack = null;
|
||||
static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) {
|
||||
for (JsonElement element : versions) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject version = element.getAsJsonObject();
|
||||
if (!isDatapack(version)) {
|
||||
continue;
|
||||
}
|
||||
if (firstDatapack == null) {
|
||||
firstDatapack = version;
|
||||
}
|
||||
if (serverMcVersion != null && !serverMcVersion.isBlank() && gameVersionsContains(version, serverMcVersion)) {
|
||||
if (serverMcVersion == null || serverMcVersion.isBlank() || gameVersionsContains(version, serverMcVersion)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
return firstDatapack;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject selectFile(JsonObject version) {
|
||||
JsonArray files = version.getAsJsonArray("files");
|
||||
static JsonObject selectFile(JsonObject version) {
|
||||
JsonArray files = optArray(version, "files");
|
||||
if (files == null || files.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -174,11 +179,14 @@ public final class ModrinthResolver {
|
||||
JsonObject primary = null;
|
||||
JsonObject first = null;
|
||||
for (JsonElement element : files) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject file = element.getAsJsonObject();
|
||||
if (first == null) {
|
||||
first = file;
|
||||
}
|
||||
boolean isPrimary = file.has("primary") && file.get("primary").getAsBoolean();
|
||||
boolean isPrimary = optBoolean(file, "primary");
|
||||
boolean isZip = optString(file, "filename").toLowerCase(Locale.ROOT).endsWith(".zip");
|
||||
if (isPrimary && primary == null) {
|
||||
primary = file;
|
||||
@@ -203,14 +211,20 @@ public final class ModrinthResolver {
|
||||
return first;
|
||||
}
|
||||
|
||||
private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) {
|
||||
private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) throws IOException {
|
||||
String downloadUrl = optString(file, "url");
|
||||
String filename = optString(file, "filename");
|
||||
if (downloadUrl.isBlank() || filename.isBlank()) {
|
||||
throw new IOException("Modrinth version for '" + slug + "' has an invalid downloadable file");
|
||||
}
|
||||
String sha1 = null;
|
||||
if (file.has("hashes") && file.get("hashes").isJsonObject()) {
|
||||
sha1 = optString(file.getAsJsonObject("hashes"), "sha1");
|
||||
if (!sha1.isBlank() && !sha1.matches("(?i)[0-9a-f]{40}")) {
|
||||
throw new IOException("Modrinth version for '" + slug + "' has an invalid SHA-1 checksum");
|
||||
}
|
||||
}
|
||||
return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug);
|
||||
return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug, false);
|
||||
}
|
||||
|
||||
private static ResolvedDatapack directResolve(String url) {
|
||||
@@ -226,16 +240,45 @@ public final class ModrinthResolver {
|
||||
if (filename.isBlank()) {
|
||||
filename = "datapack.zip";
|
||||
}
|
||||
return new ResolvedDatapack(url, filename, null, "direct", "direct", null);
|
||||
String identity = directIdentity(url);
|
||||
return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true);
|
||||
}
|
||||
|
||||
static String directIdentity(String url) {
|
||||
String normalized = url;
|
||||
try {
|
||||
URI parsed = new URI(url).normalize();
|
||||
normalized = new URI(
|
||||
parsed.getScheme() == null ? null : parsed.getScheme().toLowerCase(Locale.ROOT),
|
||||
parsed.getUserInfo(),
|
||||
parsed.getHost() == null ? null : parsed.getHost().toLowerCase(Locale.ROOT),
|
||||
parsed.getPort(),
|
||||
parsed.getPath(),
|
||||
parsed.getQuery(),
|
||||
null
|
||||
).toASCIIString();
|
||||
} catch (URISyntaxException ignored) {
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(normalized.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
builder.append(String.format("%02x", hash[i]));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDatapack(JsonObject version) {
|
||||
JsonArray loaders = version.getAsJsonArray("loaders");
|
||||
JsonArray loaders = optArray(version, "loaders");
|
||||
if (loaders == null) {
|
||||
return false;
|
||||
}
|
||||
for (JsonElement loader : loaders) {
|
||||
if (DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) {
|
||||
if (isString(loader) && DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -243,12 +286,12 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static boolean gameVersionsContains(JsonObject version, String mc) {
|
||||
JsonArray gameVersions = version.getAsJsonArray("game_versions");
|
||||
JsonArray gameVersions = optArray(version, "game_versions");
|
||||
if (gameVersions == null) {
|
||||
return false;
|
||||
}
|
||||
for (JsonElement gv : gameVersions) {
|
||||
if (mc.equalsIgnoreCase(gv.getAsString())) {
|
||||
if (isString(gv) && mc.equalsIgnoreCase(gv.getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -267,19 +310,42 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static String optString(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
|
||||
if (object == null || !object.has(key) || !isString(object.get(key))) {
|
||||
return "";
|
||||
}
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
|
||||
private static JsonArray optArray(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
return object.getAsJsonArray(key);
|
||||
}
|
||||
|
||||
private static boolean optBoolean(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()
|
||||
|| !object.get(key).getAsJsonPrimitive().isBoolean()) {
|
||||
return false;
|
||||
}
|
||||
return object.get(key).getAsBoolean();
|
||||
}
|
||||
|
||||
private static boolean isString(JsonElement element) {
|
||||
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString();
|
||||
}
|
||||
|
||||
private static JsonArray getJsonArray(String url) throws IOException {
|
||||
String body = httpGet(url);
|
||||
JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonArray()) {
|
||||
throw new IOException("Unexpected response from " + url);
|
||||
try {
|
||||
JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonArray()) {
|
||||
throw new IOException("Unexpected response from " + url);
|
||||
}
|
||||
return parsed.getAsJsonArray();
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid JSON response from " + url, e);
|
||||
}
|
||||
return parsed.getAsJsonArray();
|
||||
}
|
||||
|
||||
private static String httpGet(String url) throws IOException {
|
||||
@@ -298,17 +364,36 @@ public final class ModrinthResolver {
|
||||
throw new IOException("HTTP " + code + " from " + url);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (InputStream input = connection.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
|
||||
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String readBoundedApiResponse(Reader reader, String source, int maxChars) throws IOException {
|
||||
if (maxChars < 1) {
|
||||
throw new IllegalArgumentException("Response character limit must be positive");
|
||||
}
|
||||
int bufferSize = maxChars < 8192 ? maxChars + 1 : 8192;
|
||||
char[] buffer = new char[bufferSize];
|
||||
StringBuilder builder = new StringBuilder(Math.min(maxChars, 8192));
|
||||
while (true) {
|
||||
int remaining = maxChars - builder.length();
|
||||
int requested = remaining >= buffer.length ? buffer.length : remaining + 1;
|
||||
int length = reader.read(buffer, 0, requested);
|
||||
if (length == -1) {
|
||||
return builder.toString();
|
||||
}
|
||||
if (length == 0) {
|
||||
continue;
|
||||
}
|
||||
if (length > remaining) {
|
||||
throw new IOException("Oversized response from " + source);
|
||||
}
|
||||
builder.append(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ModrinthRef {
|
||||
@@ -328,14 +413,16 @@ public final class ModrinthResolver {
|
||||
private final String versionId;
|
||||
private final String versionNumber;
|
||||
private final String projectSlug;
|
||||
private final boolean direct;
|
||||
|
||||
public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug) {
|
||||
public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug, boolean direct) {
|
||||
this.downloadUrl = downloadUrl;
|
||||
this.fileName = fileName;
|
||||
this.sha1 = sha1;
|
||||
this.versionId = versionId;
|
||||
this.versionNumber = versionNumber;
|
||||
this.projectSlug = projectSlug;
|
||||
this.direct = direct;
|
||||
}
|
||||
|
||||
public String getDownloadUrl() {
|
||||
@@ -361,5 +448,9 @@ public final class ModrinthResolver {
|
||||
public String getProjectSlug() {
|
||||
return projectSlug;
|
||||
}
|
||||
|
||||
public boolean isDirect() {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,8 @@ final class BukkitPublicBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
private static final Object MUTATION_LOCK = new Object();
|
||||
|
||||
private BukkitWorldConfiguration() {
|
||||
}
|
||||
|
||||
public static Registration register(File configurationFile, String worldName, String dimension, Long seed) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
String requiredDimension = requireName(dimension, "Dimension");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
worlds = configuration.createSection("worlds");
|
||||
}
|
||||
|
||||
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
|
||||
String generator = "Iris:" + requiredDimension;
|
||||
if (existing != null) {
|
||||
String existingGenerator = existing.getString("generator");
|
||||
Long existingSeed = existing.contains("seed") ? existing.getLong("seed") : null;
|
||||
if (!generator.equals(existingGenerator) || !Objects.equals(seed, existingSeed)) {
|
||||
throw new IOException("bukkit.yml already contains a different definition for world \""
|
||||
+ requiredWorldName + "\".");
|
||||
}
|
||||
return Registration.UNCHANGED;
|
||||
}
|
||||
|
||||
ConfigurationSection created = worlds.createSection(requiredWorldName);
|
||||
created.set("generator", generator);
|
||||
if (seed != null) {
|
||||
created.set("seed", seed);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return Registration.CREATED;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean remove(File configurationFile, String worldName) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null || worlds.get(requiredWorldName) == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worlds.set(requiredWorldName, null);
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean removeIfMatching(
|
||||
File configurationFile,
|
||||
String worldName,
|
||||
String dimension,
|
||||
Long seed
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
String requiredDimension = requireName(dimension, "Dimension");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
|
||||
if (existing == null) {
|
||||
return false;
|
||||
}
|
||||
String expectedGenerator = "Iris:" + requiredDimension;
|
||||
String actualGenerator = existing.getString("generator");
|
||||
Long actualSeed = existing.contains("seed") ? existing.getLong("seed") : null;
|
||||
if (!expectedGenerator.equals(actualGenerator) || !Objects.equals(seed, actualSeed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worlds.set(requiredWorldName, null);
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int removeMatching(File configurationFile, Predicate<String> matcher) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
Predicate<String> requiredMatcher = Objects.requireNonNull(matcher, "matcher");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
for (String worldName : new ArrayList<>(worlds.getKeys(false))) {
|
||||
if (!requiredMatcher.test(worldName)) {
|
||||
continue;
|
||||
}
|
||||
worlds.set(worldName, null);
|
||||
removed++;
|
||||
}
|
||||
if (removed == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
|
||||
Path absoluteTarget = target.toAbsolutePath().normalize();
|
||||
Path parent = absoluteTarget.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".bukkit-worlds-", ".yml");
|
||||
try {
|
||||
configuration.save(staged.toFile());
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private static YamlConfiguration load(File configurationFile) throws IOException {
|
||||
YamlConfiguration configuration = new YamlConfiguration();
|
||||
try {
|
||||
configuration.load(configurationFile);
|
||||
return configuration;
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("bukkit.yml is invalid and was not changed.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireWorldName(String value) {
|
||||
String worldName = requireName(value, "World name");
|
||||
if (!worldName.matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("World name must contain only lowercase letters, numbers, underscores, or hyphens.");
|
||||
}
|
||||
return worldName;
|
||||
}
|
||||
|
||||
private static String requireName(String value, String label) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(label + " cannot be empty.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public enum Registration {
|
||||
CREATED,
|
||||
UNCHANGED
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,275 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class LifecycleOperationCoordinator {
|
||||
private static final LifecycleOperationCoordinator INSTANCE = new LifecycleOperationCoordinator();
|
||||
|
||||
private final Object monitor;
|
||||
private final ArrayDeque<Runnable> idleCallbacks;
|
||||
private ActiveOperation mutationOperation;
|
||||
private ActiveOperation restartOperation;
|
||||
private Runnable pendingRestartCallback;
|
||||
private long nextOperationId;
|
||||
|
||||
LifecycleOperationCoordinator() {
|
||||
monitor = new Object();
|
||||
idleCallbacks = new ArrayDeque<>();
|
||||
nextOperationId = 1L;
|
||||
}
|
||||
|
||||
public static LifecycleOperationCoordinator get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public Lease acquire(Domain domain, OperationKind kind, String target) {
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
String normalizedTarget = Objects.requireNonNull(target, "target").trim();
|
||||
if (normalizedTarget.isEmpty()) {
|
||||
throw new IllegalArgumentException("target must not be blank");
|
||||
}
|
||||
|
||||
synchronized (monitor) {
|
||||
if (domain == Domain.SERVER_LIFECYCLE) {
|
||||
throw new IllegalArgumentException("SERVER_LIFECYCLE is reserved for terminal operations");
|
||||
}
|
||||
|
||||
ActiveOperation currentOperation = restartOperation == null ? mutationOperation : restartOperation;
|
||||
if (currentOperation != null) {
|
||||
throw new BusyException(currentOperation);
|
||||
}
|
||||
|
||||
ActiveOperation operation = new ActiveOperation(nextOperationId++, domain, kind, normalizedTarget);
|
||||
mutationOperation = operation;
|
||||
return new LeaseImpl(this, operation);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean quiesceForRestart(Runnable callback) {
|
||||
Runnable restartCallback = Objects.requireNonNull(callback, "callback");
|
||||
ActiveOperation operation;
|
||||
boolean dispatch;
|
||||
synchronized (monitor) {
|
||||
if (restartOperation != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
operation = new ActiveOperation(
|
||||
nextOperationId++,
|
||||
Domain.SERVER_LIFECYCLE,
|
||||
OperationKind.SERVER_RESTART,
|
||||
"server");
|
||||
restartOperation = operation;
|
||||
dispatch = mutationOperation == null;
|
||||
if (!dispatch) {
|
||||
pendingRestartCallback = restartCallback;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
runRestartCallback(restartCallback, operation);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<ActiveOperation> active(Domain domain) {
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
synchronized (monitor) {
|
||||
if (domain == Domain.SERVER_LIFECYCLE) {
|
||||
return Optional.ofNullable(restartOperation);
|
||||
}
|
||||
if (mutationOperation != null && mutationOperation.domain() == domain) {
|
||||
return Optional.of(mutationOperation);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Domain, ActiveOperation> snapshot() {
|
||||
synchronized (monitor) {
|
||||
EnumMap<Domain, ActiveOperation> operations = new EnumMap<>(Domain.class);
|
||||
if (mutationOperation != null) {
|
||||
operations.put(mutationOperation.domain(), mutationOperation);
|
||||
}
|
||||
if (restartOperation != null) {
|
||||
operations.put(Domain.SERVER_LIFECYCLE, restartOperation);
|
||||
}
|
||||
return Collections.unmodifiableMap(operations);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isIdle() {
|
||||
synchronized (monitor) {
|
||||
return mutationOperation == null && restartOperation == null;
|
||||
}
|
||||
}
|
||||
|
||||
public void whenIdle(Runnable callback) {
|
||||
Runnable idleCallback = Objects.requireNonNull(callback, "callback");
|
||||
synchronized (monitor) {
|
||||
if (mutationOperation != null || restartOperation != null) {
|
||||
idleCallbacks.addLast(idleCallback);
|
||||
return;
|
||||
}
|
||||
runIdleCallback(idleCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void release(ActiveOperation operation) {
|
||||
Runnable restartCallback = null;
|
||||
ActiveOperation terminalOperation = null;
|
||||
synchronized (monitor) {
|
||||
if (!operation.equals(mutationOperation)) {
|
||||
throw new IllegalStateException("Lifecycle operation lease is not active: " + operation.id());
|
||||
}
|
||||
|
||||
mutationOperation = null;
|
||||
if (restartOperation != null && pendingRestartCallback != null) {
|
||||
terminalOperation = restartOperation;
|
||||
restartCallback = pendingRestartCallback;
|
||||
pendingRestartCallback = null;
|
||||
} else {
|
||||
runIdleCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
if (restartCallback != null) {
|
||||
runRestartCallback(restartCallback, terminalOperation);
|
||||
}
|
||||
}
|
||||
|
||||
private void runIdleCallbacks() {
|
||||
while (mutationOperation == null && restartOperation == null && !idleCallbacks.isEmpty()) {
|
||||
runIdleCallback(idleCallbacks.removeFirst());
|
||||
}
|
||||
}
|
||||
|
||||
private void runIdleCallback(Runnable callback) {
|
||||
try {
|
||||
callback.run();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Lifecycle idle callback failed.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void runRestartCallback(Runnable callback, ActiveOperation operation) {
|
||||
try {
|
||||
callback.run();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Lifecycle restart dispatch failed for operation " + operation.id() + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Domain {
|
||||
WORLD_MUTATION,
|
||||
PACK_MUTATION,
|
||||
SERVER_LIFECYCLE
|
||||
}
|
||||
|
||||
public enum OperationKind {
|
||||
WORLD_CREATE,
|
||||
WORLD_LOAD,
|
||||
WORLD_UNLOAD,
|
||||
WORLD_REMOVE,
|
||||
WORLD_PROMOTE,
|
||||
STUDIO_OPEN,
|
||||
STUDIO_CLOSE,
|
||||
PACK_CREATE,
|
||||
PACK_DOWNLOAD,
|
||||
PACK_PUBLISH,
|
||||
DATAPACK_COMPILE,
|
||||
SERVER_RESTART
|
||||
}
|
||||
|
||||
public record ActiveOperation(long id, Domain domain, OperationKind kind, String target) {
|
||||
public ActiveOperation {
|
||||
if (id < 1L) {
|
||||
throw new IllegalArgumentException("id must be positive");
|
||||
}
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
target = Objects.requireNonNull(target, "target").trim();
|
||||
if (target.isEmpty()) {
|
||||
throw new IllegalArgumentException("target must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class BusyException extends IllegalStateException {
|
||||
private final ActiveOperation currentOperation;
|
||||
|
||||
private BusyException(ActiveOperation currentOperation) {
|
||||
super("Lifecycle domain " + currentOperation.domain()
|
||||
+ " is busy with " + currentOperation.kind()
|
||||
+ " target=" + currentOperation.target()
|
||||
+ " id=" + currentOperation.id());
|
||||
this.currentOperation = currentOperation;
|
||||
}
|
||||
|
||||
public ActiveOperation currentOperation() {
|
||||
return currentOperation;
|
||||
}
|
||||
|
||||
public long operationId() {
|
||||
return currentOperation.id();
|
||||
}
|
||||
|
||||
public Domain domain() {
|
||||
return currentOperation.domain();
|
||||
}
|
||||
|
||||
public OperationKind operationKind() {
|
||||
return currentOperation.kind();
|
||||
}
|
||||
|
||||
public String target() {
|
||||
return currentOperation.target();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Lease extends AutoCloseable {
|
||||
ActiveOperation operation();
|
||||
|
||||
boolean isClosed();
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
|
||||
private static final class LeaseImpl implements Lease {
|
||||
private final LifecycleOperationCoordinator coordinator;
|
||||
private final ActiveOperation operation;
|
||||
private final AtomicBoolean closed;
|
||||
|
||||
private LeaseImpl(LifecycleOperationCoordinator coordinator, ActiveOperation operation) {
|
||||
this.coordinator = coordinator;
|
||||
this.operation = operation;
|
||||
closed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActiveOperation operation() {
|
||||
return operation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
coordinator.release(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface WorldLifecycleBackend {
|
||||
|
||||
CompletableFuture<World> create(WorldLifecycleRequest request);
|
||||
|
||||
boolean unload(World world, boolean save);
|
||||
CompletableFuture<Boolean> unloadAsync(World world, boolean save);
|
||||
|
||||
String backendName();
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -8,25 +10,43 @@ import org.bukkit.World;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class WorldLifecycleService {
|
||||
private static final long UNLOAD_TIMEOUT_SECONDS = 120L;
|
||||
private static volatile WorldLifecycleService instance;
|
||||
|
||||
private final CapabilitySnapshot capabilities;
|
||||
private final WorldsProviderBackend worldsProviderBackend;
|
||||
private final PaperLikeRuntimeBackend paperLikeRuntimeBackend;
|
||||
private final BukkitPublicBackend bukkitPublicBackend;
|
||||
private final WorldLifecycleBackend worldsProviderBackend;
|
||||
private final WorldLifecycleBackend paperLikeRuntimeBackend;
|
||||
private final WorldLifecycleBackend bukkitPublicBackend;
|
||||
private final List<WorldLifecycleBackend> backends;
|
||||
private final Map<String, String> worldBackendByKey;
|
||||
|
||||
public WorldLifecycleService(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
this.worldsProviderBackend = new WorldsProviderBackend(capabilities);
|
||||
this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities);
|
||||
this.bukkitPublicBackend = new BukkitPublicBackend(capabilities);
|
||||
this(
|
||||
capabilities,
|
||||
new WorldsProviderBackend(capabilities),
|
||||
new PaperLikeRuntimeBackend(capabilities),
|
||||
new BukkitPublicBackend(capabilities)
|
||||
);
|
||||
}
|
||||
|
||||
WorldLifecycleService(
|
||||
CapabilitySnapshot capabilities,
|
||||
WorldLifecycleBackend worldsProviderBackend,
|
||||
WorldLifecycleBackend paperLikeRuntimeBackend,
|
||||
WorldLifecycleBackend bukkitPublicBackend
|
||||
) {
|
||||
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
|
||||
this.worldsProviderBackend = Objects.requireNonNull(worldsProviderBackend, "worldsProviderBackend");
|
||||
this.paperLikeRuntimeBackend = Objects.requireNonNull(paperLikeRuntimeBackend, "paperLikeRuntimeBackend");
|
||||
this.bukkitPublicBackend = Objects.requireNonNull(bukkitPublicBackend, "bukkitPublicBackend");
|
||||
this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend);
|
||||
this.worldBackendByKey = new ConcurrentHashMap<>();
|
||||
}
|
||||
@@ -89,47 +109,83 @@ public final class WorldLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unload(World world, boolean save) {
|
||||
if (!J.isPrimaryThread()) {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
J.s(() -> {
|
||||
try {
|
||||
future.complete(unloadDirect(world, save));
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
return unloadDirect(world, save);
|
||||
}
|
||||
|
||||
private boolean unloadDirect(World world, boolean save) {
|
||||
String worldIdentity = WorldIdentity.serialize(world);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
World requiredWorld = Objects.requireNonNull(world, "world");
|
||||
String worldIdentity = WorldIdentity.serialize(requiredWorld);
|
||||
String worldName = requiredWorld.getName();
|
||||
WorldLifecycleBackend backend = selectUnloadBackend(worldIdentity);
|
||||
IrisLogging.info("WorldLifecycle unload: world=%s, backend=%s",
|
||||
world.getName(),
|
||||
worldName,
|
||||
backend.backendName());
|
||||
boolean unloaded;
|
||||
|
||||
CompletableFuture<Boolean> unloadFuture;
|
||||
try {
|
||||
unloaded = backend.unload(world, save);
|
||||
unloadFuture = backend.unloadAsync(requiredWorld, save);
|
||||
if (unloadFuture == null) {
|
||||
throw new IllegalStateException("World lifecycle backend returned no unload completion future.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + world.getName()
|
||||
+ "\", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", e);
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
unloadFuture = CompletableFuture.failedFuture(e);
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
|
||||
return guardedFuture.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Throwable cause = WorldLifecycleSupport.unwrap(throwable);
|
||||
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName
|
||||
+ "\", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", cause);
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(unloaded)) {
|
||||
worldBackendByKey.remove(worldIdentity, backend.backendName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> guardUnloadCompletion(
|
||||
String worldName,
|
||||
CompletableFuture<Boolean> unloadFuture
|
||||
) {
|
||||
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
|
||||
unloadFuture.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
guarded.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
guarded.completeExceptionally(WorldLifecycleSupport.unwrap(throwable));
|
||||
}
|
||||
});
|
||||
if (!guarded.isDone()) {
|
||||
CompletableFuture.delayedExecutor(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"World unload did not settle within " + UNLOAD_TIMEOUT_SECONDS + " seconds for \""
|
||||
+ worldName + "\".");
|
||||
if (!guarded.completeExceptionally(timeout) || IrisToolbelt.isServerStopping()) {
|
||||
return;
|
||||
}
|
||||
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
|
||||
});
|
||||
}
|
||||
return guarded;
|
||||
}
|
||||
|
||||
public boolean unload(World world, boolean save) {
|
||||
if (J.isPrimaryThread() || (J.isFolia() && WorldLifecycleSupport.isGlobalTickThread())) {
|
||||
throw new IllegalStateException("WorldLifecycle unload cannot block the primary/global tick thread; use unloadAsync instead.");
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(unloadAsync(world, save).join());
|
||||
} catch (CompletionException e) {
|
||||
Throwable cause = WorldLifecycleSupport.unwrap(e);
|
||||
if (cause instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error error) {
|
||||
if (cause instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(e);
|
||||
throw new IllegalStateException(cause);
|
||||
}
|
||||
if (unloaded) {
|
||||
worldBackendByKey.remove(worldIdentity);
|
||||
}
|
||||
return unloaded;
|
||||
}
|
||||
|
||||
public String backendNameForWorld(NamespacedKey worldKey) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -414,36 +416,83 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
static CompletableFuture<Boolean> unloadWorldAsync(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save);
|
||||
if (asyncUnload != null) {
|
||||
return resolveAsyncUnload(asyncUnload);
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> beginUnload(capabilities, world, save, result);
|
||||
boolean folia = J.isFolia();
|
||||
if ((!folia && J.isPrimaryThread()) || (folia && isGlobalTickThread())) {
|
||||
invokeTask.run();
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
return Bukkit.unloadWorld(world, save);
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
|
||||
throw unsupported;
|
||||
CompletableFuture<Void> scheduled = runGlobalAsync(invokeTask);
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
result.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void beginUnload(
|
||||
CapabilitySnapshot capabilities,
|
||||
World world,
|
||||
boolean save,
|
||||
CompletableFuture<Boolean> result
|
||||
) {
|
||||
CompletableFuture<Boolean> operation;
|
||||
try {
|
||||
operation = unloadWorldViaAsyncApi(capabilities, world, save);
|
||||
if (operation == null) {
|
||||
operation = unloadWorldWithoutAsyncApi(capabilities, world, save);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
return;
|
||||
}
|
||||
|
||||
operation.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
result.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
result.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static CompletableFuture<Boolean> unloadWorldWithoutAsyncApi(
|
||||
CapabilitySnapshot capabilities,
|
||||
World world,
|
||||
boolean save
|
||||
) {
|
||||
String worldName = world.getName();
|
||||
try {
|
||||
try {
|
||||
return CompletableFuture.completedFuture(Bukkit.unloadWorld(world, save));
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
|
||||
return CompletableFuture.failedFuture(unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
if (save) {
|
||||
world.save();
|
||||
}
|
||||
|
||||
Method getHandleMethod = world.getClass().getMethod("getHandle");
|
||||
Object serverLevel = getHandleMethod.invoke(world);
|
||||
closeServerLevel(world, serverLevel);
|
||||
detachServerLevel(capabilities, serverLevel, world);
|
||||
return WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty();
|
||||
CompletableFuture<Boolean> operation = closeServerLevelAsync(world, serverLevel)
|
||||
.thenCompose(unused -> detachServerLevelAsync(capabilities, serverLevel, world))
|
||||
.thenApply(unused -> WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty());
|
||||
return contextualizeUnloadFailure(worldName, operation);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e));
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
|
||||
unwrap(e)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,63 +501,70 @@ final class WorldLifecycleSupport {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeAsyncUnload(
|
||||
capabilities.bukkitServer(),
|
||||
capabilities.unloadWorldAsyncMethod(),
|
||||
world,
|
||||
save
|
||||
);
|
||||
}
|
||||
|
||||
static CompletableFuture<Boolean> invokeAsyncUnload(
|
||||
Object bukkitServer,
|
||||
Method unloadWorldAsyncMethod,
|
||||
World world,
|
||||
boolean save
|
||||
) {
|
||||
CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> {
|
||||
Consumer<Boolean> callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result));
|
||||
try {
|
||||
capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (J.isFolia() && !isGlobalTickThread()) {
|
||||
CompletableFuture<Void> scheduled = J.sfut(invokeTask);
|
||||
if (scheduled == null) {
|
||||
callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task."));
|
||||
return callbackFuture;
|
||||
}
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
callbackFuture.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
return callbackFuture;
|
||||
Consumer<Boolean> callback = unloaded -> callbackFuture.complete(Boolean.TRUE.equals(unloaded));
|
||||
try {
|
||||
unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
|
||||
invokeTask.run();
|
||||
return callbackFuture;
|
||||
}
|
||||
|
||||
private static boolean resolveAsyncUnload(CompletableFuture<Boolean> asyncUnload) {
|
||||
if (J.isPrimaryThread()) {
|
||||
if (!asyncUnload.isDone()) {
|
||||
return true;
|
||||
private static CompletableFuture<Boolean> contextualizeUnloadFailure(
|
||||
String worldName,
|
||||
CompletableFuture<Boolean> operation
|
||||
) {
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
operation.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
result.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
|
||||
unwrap(throwable)
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.join());
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS));
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void closeServerLevel(World world, Object serverLevel) throws Throwable {
|
||||
Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0);
|
||||
private static CompletableFuture<Void> closeServerLevelAsync(World world, Object serverLevel) {
|
||||
Method closeMethod;
|
||||
try {
|
||||
closeMethod = CapabilityResolution.resolveMethod(
|
||||
serverLevel.getClass(),
|
||||
"close",
|
||||
method -> method.getParameterCount() == 0
|
||||
);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
if (closeMethod == null) {
|
||||
return;
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
if (!J.isFolia()) {
|
||||
closeMethod.invoke(serverLevel);
|
||||
return;
|
||||
try {
|
||||
closeMethod.invoke(serverLevel);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
Location spawn = world.getSpawnLocation();
|
||||
@@ -524,9 +580,11 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\".");
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Failed to schedule region close task for world \"" + world.getName() + "\"."
|
||||
));
|
||||
}
|
||||
closeFuture.get(90, TimeUnit.SECONDS);
|
||||
return closeFuture.orTimeout(90L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
@@ -545,7 +603,11 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
}
|
||||
|
||||
private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, World world) throws Throwable {
|
||||
private static CompletableFuture<Void> detachServerLevelAsync(
|
||||
CapabilitySnapshot capabilities,
|
||||
Object serverLevel,
|
||||
World world
|
||||
) {
|
||||
Runnable detachTask = () -> {
|
||||
try {
|
||||
capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel);
|
||||
@@ -556,15 +618,41 @@ final class WorldLifecycleSupport {
|
||||
};
|
||||
|
||||
if (!J.isFolia() || isGlobalTickThread()) {
|
||||
detachTask.run();
|
||||
return;
|
||||
try {
|
||||
detachTask.run();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<Void> detachFuture = J.sfut(detachTask);
|
||||
if (detachFuture == null) {
|
||||
throw new IllegalStateException("Failed to schedule global detach task for world \"" + world.getName() + "\".");
|
||||
CompletableFuture<Void> detachFuture = runGlobalAsync(detachTask);
|
||||
return detachFuture.orTimeout(15L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> runGlobalAsync(Runnable task) {
|
||||
if (!J.isFolia()) {
|
||||
return J.sfut(task);
|
||||
}
|
||||
detachFuture.get(15, TimeUnit.SECONDS);
|
||||
|
||||
CompletableFuture<Void> result = new CompletableFuture<>();
|
||||
Runnable settlement = () -> {
|
||||
try {
|
||||
task.run();
|
||||
result.complete(null);
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (!FoliaScheduler.runGlobal(BukkitPlatform.plugin(), settlement)) {
|
||||
result.completeExceptionally(new IllegalStateException("Failed to schedule global world lifecycle task."));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isGlobalTickThread() {
|
||||
|
||||
@@ -46,8 +46,8 @@ final class WorldsProviderBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -22,28 +22,36 @@ import lombok.SneakyThrows;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.mvplugins.multiverse.core.MultiverseCoreApi;
|
||||
import org.mvplugins.multiverse.core.utils.result.Attempt;
|
||||
import org.mvplugins.multiverse.core.world.MultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.WorldManager;
|
||||
import org.mvplugins.multiverse.core.world.options.ImportWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
public class MultiverseCoreLink {
|
||||
public void removeFromConfig(World world) {
|
||||
removeFromConfig(world.getName());
|
||||
public boolean removeFromConfig(World world) {
|
||||
return removeFromConfig(world.getName());
|
||||
}
|
||||
|
||||
public void removeFromConfig(String world) {
|
||||
public boolean removeFromConfig(String world) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
WorldManager manager = worldManager();
|
||||
MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null);
|
||||
if (multiverseWorld == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)).onSuccess(ignored -> manager.saveWorldsConfig());
|
||||
Attempt<String, RemoveFailureReason> removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
|
||||
if (removal.isFailure()) {
|
||||
throw new IllegalStateException("Multiverse refused to remove world \"" + world + "\": "
|
||||
+ removal.getFailureMessage());
|
||||
}
|
||||
manager.saveWorldsConfig().get();
|
||||
return true;
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@@ -72,7 +80,7 @@ public class MultiverseCoreLink {
|
||||
.invoke(config, generator);
|
||||
}
|
||||
|
||||
manager.saveWorldsConfig();
|
||||
manager.saveWorldsConfig().get();
|
||||
}
|
||||
|
||||
private WorldManager worldManager() {
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
@@ -244,15 +245,14 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
}
|
||||
}
|
||||
|
||||
for (File i : Objects.requireNonNull(IrisPlatforms.get().dataFolder("packs").listFiles())) {
|
||||
if (i.isDirectory()) {
|
||||
IrisData dm = get(i);
|
||||
if (dm == nearest) continue;
|
||||
T t = dm.load(type, key, false);
|
||||
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs"))) {
|
||||
IrisData dm = get(i);
|
||||
if (dm == nearest) continue;
|
||||
T t = dm.load(type, key, false);
|
||||
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -106,6 +107,18 @@ public interface INMSBinding {
|
||||
return new KList<>();
|
||||
}
|
||||
|
||||
default JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) {
|
||||
throw new UnsupportedOperationException("The active NMS binding does not expose registered jigsaw metadata");
|
||||
}
|
||||
|
||||
default int getTemplatePoolHorizontalSpan(String templatePoolKey) {
|
||||
throw new UnsupportedOperationException("The active NMS binding does not expose registered template pool spans");
|
||||
}
|
||||
|
||||
default int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
|
||||
return getTemplatePoolHorizontalSpan(templatePoolKey);
|
||||
}
|
||||
|
||||
KList<String> getStructureSetKeys();
|
||||
|
||||
KList<String> getReachableStructureKeys(World world);
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.lang.reflect.Method;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class MinecraftVersion {
|
||||
public final class MinecraftVersion {
|
||||
private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class AtomicDirectoryPublisher {
|
||||
private AtomicDirectoryPublisher() {
|
||||
}
|
||||
|
||||
public static Publication publish(Path stagedDirectory, Path targetDirectory) throws IOException {
|
||||
Path staged = Objects.requireNonNull(stagedDirectory, "stagedDirectory").toAbsolutePath().normalize();
|
||||
Path target = Objects.requireNonNull(targetDirectory, "targetDirectory").toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(staged) || Files.isSymbolicLink(staged)) {
|
||||
throw new IOException("Staged directory is missing or unsafe: " + staged);
|
||||
}
|
||||
if (!Objects.equals(staged.getParent(), target.getParent())) {
|
||||
throw new IOException("Staged and target directories must have the same parent.");
|
||||
}
|
||||
|
||||
Path backup = null;
|
||||
if (Files.exists(target) || Files.isSymbolicLink(target)) {
|
||||
backup = target.resolveSibling("." + target.getFileName() + ".backup-" + UUID.randomUUID());
|
||||
move(target, backup);
|
||||
}
|
||||
try {
|
||||
move(staged, target);
|
||||
return new Publication(target, backup);
|
||||
} catch (IOException failure) {
|
||||
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
|
||||
try {
|
||||
move(backup, target);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void move(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Publication implements AutoCloseable {
|
||||
private final Path target;
|
||||
private final Path backup;
|
||||
private boolean committed;
|
||||
private boolean closed;
|
||||
|
||||
private Publication(Path target, Path backup) {
|
||||
this.target = target;
|
||||
this.backup = backup;
|
||||
}
|
||||
|
||||
public synchronized void commit() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Directory publication is already closed.");
|
||||
}
|
||||
committed = true;
|
||||
}
|
||||
|
||||
public synchronized void cleanupBackup() throws IOException {
|
||||
if (!committed) {
|
||||
throw new IllegalStateException("Directory publication is not committed.");
|
||||
}
|
||||
if (backup != null) {
|
||||
deleteTree(backup);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (committed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteTree(target);
|
||||
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
|
||||
move(backup, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteTree(Path path) throws IOException {
|
||||
if (!Files.exists(path) && !Files.isSymbolicLink(path)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path)) {
|
||||
Files.delete(path);
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(path)) {
|
||||
for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class PackDirectoryResolver {
|
||||
private PackDirectoryResolver() {
|
||||
@@ -18,9 +25,103 @@ public final class PackDirectoryResolver {
|
||||
if (!root.equals(candidate.getParent())) {
|
||||
return null;
|
||||
}
|
||||
if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!isVisiblePackDirectory(candidate.toFile())) {
|
||||
return null;
|
||||
}
|
||||
return candidate.toFile();
|
||||
}
|
||||
|
||||
public static List<File> listVisiblePackDirectories(File packsRoot) {
|
||||
try {
|
||||
return listVisiblePackDirectoriesOrThrow(packsRoot);
|
||||
} catch (IOException exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<File> listVisiblePackDirectoriesOrThrow(File packsRoot) throws IOException {
|
||||
if (packsRoot == null) {
|
||||
return List.of();
|
||||
}
|
||||
Path root = packsRoot.toPath().toAbsolutePath().normalize();
|
||||
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return List.of();
|
||||
}
|
||||
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack workspace is missing or unsafe: " + root);
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
return stream
|
||||
.map(Path::toFile)
|
||||
.filter(PackDirectoryResolver::isVisiblePackDirectory)
|
||||
.sorted(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(File::getName))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isVisiblePackDirectory(File candidate) {
|
||||
if (candidate == null || isHiddenName(candidate.getName())) {
|
||||
return false;
|
||||
}
|
||||
Path path = candidate.toPath();
|
||||
return Files.isDirectory(path);
|
||||
}
|
||||
|
||||
public static void requireSafePackTree(File candidate) throws IOException {
|
||||
if (!isVisiblePackDirectory(candidate)) {
|
||||
throw new IOException("Pack directory is missing or unsafe: " + candidate);
|
||||
}
|
||||
Path root = candidate.toPath().toAbsolutePath().normalize().toRealPath();
|
||||
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack directory target is missing or unsafe: " + candidate);
|
||||
}
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(root) && isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Pack directory contains a symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Pack directory contains an unsupported entry: " + file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean containsHiddenPathSegment(Path root, Path candidate) {
|
||||
if (root == null || candidate == null) {
|
||||
return true;
|
||||
}
|
||||
Path normalizedRoot = root.toAbsolutePath().normalize();
|
||||
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.startsWith(normalizedRoot)) {
|
||||
return true;
|
||||
}
|
||||
Path relative = normalizedRoot.relativize(normalizedCandidate);
|
||||
for (Path segment : relative) {
|
||||
if (isHiddenName(segment.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isHiddenName(String name) {
|
||||
return name != null && name.startsWith(".");
|
||||
}
|
||||
}
|
||||
@@ -23,18 +23,33 @@ import art.arcane.iris.core.localization.PackDownloadMessages;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.util.common.misc.WebCache;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import org.zeroturnaround.zip.ZipUtil;
|
||||
import org.zeroturnaround.zip.commons.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public final class PackDownloader {
|
||||
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
|
||||
@@ -43,7 +58,14 @@ public final class PackDownloader {
|
||||
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}");
|
||||
private static final ConcurrentHashMap<String, Object> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
||||
private static final Pattern PACK_KEY = Pattern.compile("[a-z0-9_-]+");
|
||||
private static final ArchiveLimits ARCHIVE_LIMITS = new ArchiveLimits(
|
||||
512L * 1024L * 1024L,
|
||||
100_000,
|
||||
2L * 1024L * 1024L * 1024L,
|
||||
256L * 1024L * 1024L
|
||||
);
|
||||
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
private PackDownloader() {
|
||||
}
|
||||
@@ -63,15 +85,37 @@ public final class PackDownloader {
|
||||
* partial import (an interrupted copy) and counts as absent so it can be replaced.
|
||||
*/
|
||||
public static boolean isPackPresent(File packsFolder, String key) {
|
||||
if (packsFolder == null || key == null || key.isBlank()) {
|
||||
if (packsFolder == null || !isSafePackKey(key)) {
|
||||
return false;
|
||||
}
|
||||
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
|
||||
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
|
||||
if (resolvedPack == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
PackDirectoryResolver.requireSafePackTree(resolvedPack);
|
||||
} catch (IOException exception) {
|
||||
return false;
|
||||
}
|
||||
Path pack = resolvedPack.toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = pack.resolve("dimensions");
|
||||
if (!Objects.equals(pack.getParent(), packsRoot)
|
||||
|| Files.isSymbolicLink(dimensions)
|
||||
|| !Files.isDirectory(pack)
|
||||
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(dimensions)) {
|
||||
return entries.anyMatch(path -> path.getFileName().toString().endsWith(".json")
|
||||
&& !Files.isSymbolicLink(path)
|
||||
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS));
|
||||
} catch (IOException exception) {
|
||||
return false;
|
||||
}
|
||||
File[] dimensions = new File(new File(packsFolder, key), "dimensions")
|
||||
.listFiles((File dir, String name) -> name.endsWith(".json"));
|
||||
return dimensions != null && dimensions.length > 0;
|
||||
}
|
||||
|
||||
public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -82,136 +126,414 @@ public final class PackDownloader {
|
||||
* per-repo lock keeps concurrent startup triggers (async default-pack install racing world
|
||||
* resolution) from downloading the same archive twice.
|
||||
*/
|
||||
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
// Lock on the destination pack key when known: concurrent triggers for the same pack can
|
||||
// arrive with different refs (release URL vs listing branch) and must still serialize.
|
||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
|
||||
Object lock = DOWNLOAD_LOCKS.computeIfAbsent(lockKey, key -> new Object());
|
||||
synchronized (lock) {
|
||||
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return expectedKey;
|
||||
}
|
||||
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, feedback);
|
||||
public static PackInstallResult download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
Objects.requireNonNull(packsFolder, "packsFolder");
|
||||
Consumer<String> output = feedback == null ? ignored -> {
|
||||
} : feedback;
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||
}
|
||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
|
||||
return withDownloadLock(lockKey, () -> {
|
||||
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
|
||||
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return new PackInstallResult(expectedKey, false, false);
|
||||
}
|
||||
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, expectedKey, lockKey, output);
|
||||
});
|
||||
}
|
||||
|
||||
private static String downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
|
||||
private static PackInstallResult downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl,
|
||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
||||
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
|
||||
File zip = WebCache.getNonCachedFile("pack-" + repo, url);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
|
||||
File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes());
|
||||
File temp = WebCache.getTemp();
|
||||
File work = new File(temp, "dl-" + UUID.randomUUID());
|
||||
|
||||
if (zip == null || !zip.exists()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
|
||||
return null;
|
||||
}
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
|
||||
try {
|
||||
ZipUtil.unpack(zip, work);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
||||
IO.delete(work);
|
||||
return null;
|
||||
}
|
||||
File dir = null;
|
||||
File[] zipFiles = work.listFiles();
|
||||
|
||||
if (zipFiles == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
|
||||
} catch (NullPointerException e) {
|
||||
IrisLogging.reportError(e);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dir == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
||||
return null;
|
||||
}
|
||||
|
||||
IrisData data = IrisData.get(dir);
|
||||
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
|
||||
|
||||
if (dimensions == null || dimensions.length == 0) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dimensions.length != 1) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
return null;
|
||||
}
|
||||
|
||||
IrisDimension d = data.getDimensionLoader().load(dimensions[0]);
|
||||
data.close();
|
||||
|
||||
if (d == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
|
||||
return null;
|
||||
}
|
||||
|
||||
String key = d.getLoadKey();
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.IMPORTING, MessageArgument.untrusted("name", d.getName()), MessageArgument.untrusted("key", key)));
|
||||
File packEntry = new File(packsFolder, key);
|
||||
File[] staleStaging = packsFolder.listFiles((File parent, String name) -> name.startsWith(key + ".importing-"));
|
||||
if (staleStaging != null) {
|
||||
for (File stale : staleStaging) {
|
||||
IO.delete(stale);
|
||||
}
|
||||
}
|
||||
|
||||
if (forceOverwrite) {
|
||||
IO.delete(packEntry);
|
||||
}
|
||||
|
||||
if (IrisData.loadAnyDimension(key, null) != null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DIMENSION_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
|
||||
return null;
|
||||
}
|
||||
|
||||
File[] existingEntries = packEntry.listFiles();
|
||||
if (packEntry.exists() && existingEntries != null && existingEntries.length > 0) {
|
||||
if (isPackPresent(packsFolder, key)) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.PACK_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
|
||||
if (zip == null || !zip.exists()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
|
||||
return null;
|
||||
}
|
||||
// Non-empty but no dimension file: a partial import from an interrupted copy.
|
||||
// Replace it instead of refusing forever.
|
||||
IrisLogging.warn("Replacing partial pack folder " + packEntry.getPath() + " (no dimension files found).");
|
||||
IO.delete(packEntry);
|
||||
}
|
||||
|
||||
// Stage inside the packs folder and move into place so packs/<key> is never partial:
|
||||
// an interrupted copy previously left a folder without dimensions/, which then blocked
|
||||
// every future import as a key conflict.
|
||||
File staging = new File(packsFolder, key + ".importing-" + UUID.randomUUID());
|
||||
try {
|
||||
FileUtils.copyDirectory(dir, staging);
|
||||
if (!staging.renameTo(packEntry)) {
|
||||
throw new IOException("Unable to move imported pack into place: " + packEntry.getPath());
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
|
||||
try {
|
||||
unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS);
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
||||
return null;
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IO.delete(staging);
|
||||
throw e;
|
||||
File[] zipFiles = work.listFiles();
|
||||
if (zipFiles == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
||||
return null;
|
||||
}
|
||||
File directory;
|
||||
try {
|
||||
directory = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
|
||||
} catch (NullPointerException exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
|
||||
return null;
|
||||
}
|
||||
if (directory == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
||||
return null;
|
||||
}
|
||||
return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback);
|
||||
} finally {
|
||||
deleteDirectory(work);
|
||||
}
|
||||
}
|
||||
|
||||
static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
|
||||
String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
Objects.requireNonNull(packsFolder, "packsFolder");
|
||||
Objects.requireNonNull(extractedPack, "extractedPack");
|
||||
Consumer<String> output = feedback == null ? ignored -> {
|
||||
} : feedback;
|
||||
return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output);
|
||||
}
|
||||
|
||||
private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
|
||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||
}
|
||||
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
|
||||
Files.createDirectories(packsRoot);
|
||||
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
|
||||
try {
|
||||
FileUtils.copyDirectory(extractedPack, staging.toFile());
|
||||
PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback);
|
||||
if (prepared == null) {
|
||||
return null;
|
||||
}
|
||||
String destinationLockKey = "key:" + prepared.key();
|
||||
if (destinationLockKey.equals(heldLockKey)) {
|
||||
return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback);
|
||||
}
|
||||
return withDownloadLock(destinationLockKey,
|
||||
() -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback));
|
||||
} finally {
|
||||
deleteDirectory(staging.toFile());
|
||||
}
|
||||
}
|
||||
|
||||
private static PreparedPack prepareStagedPack(File staging, String expectedKey,
|
||||
Consumer<String> feedback) throws IOException {
|
||||
IrisData data = IrisData.openDatapackCompiler(staging);
|
||||
String key;
|
||||
String name;
|
||||
try {
|
||||
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
|
||||
if (dimensions == null || dimensions.length == 0) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
|
||||
return null;
|
||||
}
|
||||
if (dimensions.length != 1) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
return null;
|
||||
}
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
|
||||
if (dimension == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
|
||||
return null;
|
||||
}
|
||||
key = dimension.getLoadKey();
|
||||
name = dimension.getName();
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
if (!isSafePackKey(key)) {
|
||||
throw new IOException("Downloaded pack has unsafe dimension key '" + key + "'");
|
||||
}
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !expectedKey.equals(key)) {
|
||||
throw new IOException("Downloaded pack key '" + key + "' does not match requested key '" + expectedKey + "'");
|
||||
}
|
||||
|
||||
IrisData.getLoaded(packEntry)
|
||||
.ifPresent(IrisData::hotloaded);
|
||||
PackValidationResult stagedValidation;
|
||||
try {
|
||||
stagedValidation = PackValidator.validate(staging);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("Pack validation failed before publication for '" + key + "'", exception);
|
||||
}
|
||||
PackValidationResult validation = new PackValidationResult(
|
||||
key,
|
||||
stagedValidation.getBlockingErrors(),
|
||||
stagedValidation.getWarnings(),
|
||||
stagedValidation.getValidatedAtMillis()
|
||||
);
|
||||
if (!validation.isLoadable()) {
|
||||
sendValidationFeedback(validation, feedback);
|
||||
return null;
|
||||
}
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.IMPORTING,
|
||||
MessageArgument.untrusted("name", name),
|
||||
MessageArgument.untrusted("key", key)
|
||||
));
|
||||
return new PreparedPack(key, name, validation);
|
||||
}
|
||||
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ACQUIRED, MessageArgument.untrusted("name", d.getName())));
|
||||
validateDownloaded(packEntry, feedback);
|
||||
return key;
|
||||
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();
|
||||
if (!Objects.equals(target.getParent(), packsRoot)) {
|
||||
throw new IOException("Pack target escapes the packs folder: " + target);
|
||||
}
|
||||
if (Files.isSymbolicLink(target)) {
|
||||
sendFeedback(feedback, "Pack '" + prepared.key() + "' is a symbolic-link source and cannot be replaced by Iris.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Path conflictingPack = findConflictingPack(packsRoot, staging, target, prepared.key());
|
||||
if (conflictingPack != null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.DIMENSION_KEY_CONFLICT,
|
||||
MessageArgument.untrusted("key", prepared.key())
|
||||
));
|
||||
return null;
|
||||
}
|
||||
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
|
||||
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).");
|
||||
}
|
||||
|
||||
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
|
||||
if (loadedData.isEmpty()) {
|
||||
loadedData = IrisData.getLoaded(target.toFile());
|
||||
}
|
||||
if (loadedData.isPresent()) {
|
||||
sendFeedback(
|
||||
feedback,
|
||||
"Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
|
||||
publication.commit();
|
||||
try {
|
||||
publication.cleanupBackup();
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError(
|
||||
"Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
PackValidationRegistry.publish(prepared.validation());
|
||||
sendValidationFeedback(prepared.validation(), feedback);
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.ACQUIRED,
|
||||
MessageArgument.untrusted("name", prepared.name())
|
||||
));
|
||||
return new PackInstallResult(prepared.key(), true, false);
|
||||
}
|
||||
|
||||
private static Path findConflictingPack(Path packsRoot, Path staging, Path target, String key) throws IOException {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
roots.add(packsRoot);
|
||||
if (IrisPlatforms.isBound()) {
|
||||
roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize());
|
||||
}
|
||||
for (Path root : roots) {
|
||||
if (!Files.isDirectory(root)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
List<Path> candidates = entries.toList();
|
||||
for (Path candidate : candidates) {
|
||||
Path normalized = candidate.toAbsolutePath().normalize();
|
||||
String candidateName = candidate.getFileName().toString();
|
||||
if (normalized.equals(staging)
|
||||
|| normalized.equals(target)
|
||||
|| PackDirectoryResolver.isHiddenName(candidateName)
|
||||
|| !PackDirectoryResolver.isVisiblePackDirectory(candidate.toFile())) {
|
||||
continue;
|
||||
}
|
||||
Path dimension = candidate.resolve("dimensions").resolve(key + ".json");
|
||||
if (Files.isRegularFile(dimension)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isSafePackKey(String key) {
|
||||
return key != null && PACK_KEY.matcher(key).matches();
|
||||
}
|
||||
|
||||
private static PackInstallResult withDownloadLock(String key, DownloadOperation operation) throws IOException {
|
||||
DownloadLock lock = DOWNLOAD_LOCKS.compute(key, (ignored, existing) -> {
|
||||
DownloadLock selected = existing == null ? new DownloadLock() : existing;
|
||||
selected.references++;
|
||||
return selected;
|
||||
});
|
||||
try {
|
||||
synchronized (lock) {
|
||||
return operation.run();
|
||||
}
|
||||
} finally {
|
||||
DOWNLOAD_LOCKS.computeIfPresent(key, (ignored, existing) -> {
|
||||
if (existing != lock) {
|
||||
return existing;
|
||||
}
|
||||
existing.references--;
|
||||
return existing.references == 0 ? null : existing;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static int downloadLockCount() {
|
||||
return DOWNLOAD_LOCKS.size();
|
||||
}
|
||||
|
||||
static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException {
|
||||
Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize();
|
||||
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
|
||||
ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
|
||||
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack archive is missing or unsafe: " + source);
|
||||
}
|
||||
if (Files.size(source) > safety.maxArchiveBytes()) {
|
||||
throw new IOException("Pack archive exceeds the compressed size limit.");
|
||||
}
|
||||
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack extraction target is not a directory: " + root);
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Pack extraction target is unsafe: " + root);
|
||||
}
|
||||
|
||||
int entryCount = 0;
|
||||
long expandedBytes = 0L;
|
||||
Set<String> paths = new HashSet<>();
|
||||
try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entryCount++;
|
||||
if (entryCount > safety.maxEntries()) {
|
||||
throw new IOException("Pack archive contains too many entries.");
|
||||
}
|
||||
String normalizedName = normalizeArchiveEntry(entry.getName());
|
||||
String collisionKey = normalizedName.toLowerCase(Locale.ROOT);
|
||||
if (!paths.add(collisionKey)) {
|
||||
throw new IOException("Pack archive contains a duplicate path: " + normalizedName);
|
||||
}
|
||||
Path output = root.resolve(normalizedName).normalize();
|
||||
if (!output.startsWith(root)) {
|
||||
throw new IOException("Pack archive entry escapes extraction: " + entry.getName());
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(output);
|
||||
zip.closeEntry();
|
||||
continue;
|
||||
}
|
||||
long declaredSize = entry.getSize();
|
||||
if (declaredSize > safety.maxEntryBytes()) {
|
||||
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
|
||||
}
|
||||
Files.createDirectories(output.getParent());
|
||||
long entryBytes = 0L;
|
||||
try (OutputStream file = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = zip.read(buffer)) != -1) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
entryBytes += read;
|
||||
expandedBytes += read;
|
||||
if (entryBytes > safety.maxEntryBytes()) {
|
||||
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
|
||||
}
|
||||
if (expandedBytes > safety.maxExpandedBytes()) {
|
||||
throw new IOException("Pack archive expands beyond the safety limit.");
|
||||
}
|
||||
file.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
if (entryCount == 0) {
|
||||
throw new IOException("Pack archive is empty.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeArchiveEntry(String rawName) throws IOException {
|
||||
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|
||||
|| rawName.startsWith("/") || rawName.startsWith("\\")) {
|
||||
throw new IOException("Pack archive contains an invalid path.");
|
||||
}
|
||||
String slashNormalized = rawName.replace('\\', '/');
|
||||
if (slashNormalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IOException("Pack archive contains an unsafe path: " + rawName);
|
||||
}
|
||||
Path normalized = Path.of(slashNormalized).normalize();
|
||||
String result = normalized.toString().replace('\\', '/');
|
||||
if (normalized.isAbsolute() || normalized.startsWith("..") || result.isBlank() || ".".equals(result)) {
|
||||
throw new IOException("Pack archive contains an unsafe path: " + rawName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void deleteDirectory(File directory) {
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(directory.toPath());
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError("Failed to clean temporary pack directory '" + directory.getPath() + "'", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendFeedback(Consumer<String> feedback, String message) {
|
||||
try {
|
||||
feedback.accept(message);
|
||||
} catch (RuntimeException exception) {
|
||||
IrisLogging.reportError("Pack download feedback delivery failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) {
|
||||
if (!result.isLoadable()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATION_FAILED,
|
||||
MessageArgument.untrusted("pack", result.getPackName())
|
||||
));
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATION_REASON,
|
||||
MessageArgument.untrusted("reason", reason)
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result.getWarnings().isEmpty()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
|
||||
MessageArgument.untrusted("pack", result.getPackName()),
|
||||
MessageArgument.trusted("count", result.getWarnings().size())
|
||||
));
|
||||
return;
|
||||
}
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED,
|
||||
MessageArgument.untrusted("pack", result.getPackName())
|
||||
));
|
||||
}
|
||||
|
||||
static String resolveGithubArchiveUrl(String repo, String ref) {
|
||||
@@ -229,6 +551,9 @@ public final class PackDownloader {
|
||||
if (COMMIT_SHA.matcher(ref).matches()) {
|
||||
return "https://github.com/" + repo + "/archive/" + ref + ".zip";
|
||||
}
|
||||
if ("HEAD".equals(ref)) {
|
||||
return "https://github.com/" + repo + "/archive/HEAD.zip";
|
||||
}
|
||||
if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
|
||||
throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'");
|
||||
}
|
||||
@@ -269,27 +594,26 @@ public final class PackDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDownloaded(File packEntry, Consumer<String> feedback) {
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packEntry);
|
||||
PackValidationRegistry.publish(result);
|
||||
private record PreparedPack(String key, String name, PackValidationResult validation) {
|
||||
}
|
||||
|
||||
if (!result.isLoadable()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_FAILED, MessageArgument.untrusted("pack", result.getPackName())));
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_REASON, MessageArgument.untrusted("reason", reason)));
|
||||
}
|
||||
} else if (!result.getWarnings().isEmpty()) {
|
||||
feedback.accept(IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
|
||||
MessageArgument.untrusted("pack", result.getPackName()),
|
||||
MessageArgument.trusted("count", result.getWarnings().size())
|
||||
));
|
||||
} else {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATED, MessageArgument.untrusted("pack", result.getPackName())));
|
||||
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
|
||||
}
|
||||
|
||||
record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) {
|
||||
ArchiveLimits {
|
||||
if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
|
||||
throw new IllegalArgumentException("Archive limits must be positive.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Pack validation failed for '" + packEntry.getName() + "'", e);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DownloadOperation {
|
||||
PackInstallResult run() throws IOException;
|
||||
}
|
||||
|
||||
private static final class DownloadLock {
|
||||
private int references;
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,10 @@ final class PackObjectSurfaceValidator {
|
||||
}
|
||||
|
||||
static List<String> validateStructureGraph(File packFolder) {
|
||||
return validateStructureGraph(packFolder, true);
|
||||
}
|
||||
|
||||
static List<String> validateStructureGraph(File packFolder, boolean validateLiveRegistries) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
@@ -131,7 +135,8 @@ final class PackObjectSurfaceValidator {
|
||||
Set<String> pieceKeys = ContentKeyValidator.deriveRegistrantKeysExact(piecesFolder);
|
||||
Set<String> objectKeys = ContentKeyValidator.deriveObjectKeysExact(objectsFolder);
|
||||
|
||||
PackStructurePlacementValidator.validateStructurePlacements(packFolder, structureKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateStructurePlacements(
|
||||
packFolder, structureKeys, validateLiveRegistries, blockingErrors);
|
||||
PackStructurePlacementValidator.validateStructureStartPools(structuresFolder, poolKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors);
|
||||
|
||||
+495
-55
@@ -18,7 +18,10 @@
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
@@ -26,22 +29,32 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
final class PackStructurePlacementValidator {
|
||||
private static final Set<String> TERRAIN_ENVELOPE_MODES = Set.of(
|
||||
"BORE", "FORCE_CARVE", "VACUUM", "ENCASE");
|
||||
|
||||
private PackStructurePlacementValidator() {
|
||||
}
|
||||
|
||||
static void validateStructurePlacements(File packFolder,
|
||||
Set<String> structureKeys,
|
||||
boolean validateLiveRegistries,
|
||||
List<String> blockingErrors) {
|
||||
Set<String> registeredStructures = registeredStructureKeys();
|
||||
Set<String> registeredJigsaws = registeredJigsawKeys();
|
||||
Set<String> registeredPools = registeredTemplatePoolKeys();
|
||||
RegistrySnapshot registries = registrySnapshot(validateLiveRegistries, blockingErrors);
|
||||
if (registries == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> placementIds = new HashMap<>();
|
||||
Map<String, String> anonymousGridIdentities = new HashMap<>();
|
||||
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
|
||||
File resourceFolder = new File(packFolder, folderName);
|
||||
if (!resourceFolder.isDirectory()) {
|
||||
@@ -55,22 +68,42 @@ final class PackStructurePlacementValidator {
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
|
||||
if (PackValidator.DIMENSIONS_FOLDER.equals(folderName)) {
|
||||
validateImportedStructureAdjustmentEnvelopes(
|
||||
resourceKey, resource, registries, blockingErrors);
|
||||
}
|
||||
Object rawPlacements = resource.opt("structures");
|
||||
JSONArray placements = resource.optJSONArray("structures");
|
||||
if (placements == null) {
|
||||
if (resource.has("structures") && rawPlacements != JSONObject.NULL) {
|
||||
blockingErrors.add(resourceType + " '" + resourceKey + "'.structures must be an array.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
|
||||
for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) {
|
||||
JSONObject placement = placements.optJSONObject(placementIndex);
|
||||
if (placement == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray references = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
boolean hasIrisStructures = references != null && references.length() > 0;
|
||||
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
|
||||
String placementPath = resourceType + " '" + resourceKey + "' structures["
|
||||
+ placementIndex + "]";
|
||||
if (placement == null) {
|
||||
blockingErrors.add(placementPath + " must be an object.");
|
||||
continue;
|
||||
}
|
||||
validatePlacementConfiguration(placementPath, placement, blockingErrors);
|
||||
validatePlacementIdentity(
|
||||
placementPath, placement, placementIds, anonymousGridIdentities, blockingErrors);
|
||||
JSONArray references = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
if (placement.has("structures") && placement.opt("structures") != JSONObject.NULL
|
||||
&& references == null) {
|
||||
blockingErrors.add(placementPath + ".structures must be an array.");
|
||||
}
|
||||
if (placement.has("nativeStructures") && placement.opt("nativeStructures") != JSONObject.NULL
|
||||
&& nativeStructures == null) {
|
||||
blockingErrors.add(placementPath + ".nativeStructures must be an array.");
|
||||
}
|
||||
boolean hasIrisStructures = references != null && references.length() > 0;
|
||||
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
|
||||
if (hasIrisStructures == hasNativeStructures) {
|
||||
blockingErrors.add(placementPath
|
||||
+ " must declare exactly one non-empty backend: structures or nativeStructures.");
|
||||
@@ -78,14 +111,24 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
if (hasNativeStructures) {
|
||||
validateNativeStructures(
|
||||
placementPath, placement, nativeStructures,
|
||||
registeredStructures, registeredJigsaws,
|
||||
registeredPools, blockingErrors);
|
||||
placementPath, nativeStructures,
|
||||
registries.structures(), registries.jigsaws(),
|
||||
registries.pools(), registries.jigsawMetadataResolver(),
|
||||
registries.hooks(), blockingErrors);
|
||||
continue;
|
||||
}
|
||||
Set<String> editableStructureKeys = new HashSet<>();
|
||||
for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) {
|
||||
Object rawReference = references.opt(referenceIndex);
|
||||
if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) {
|
||||
blockingErrors.add(placementPath + ".structures[" + referenceIndex
|
||||
+ "] must name a non-blank Iris structure.");
|
||||
continue;
|
||||
}
|
||||
String normalizedStructureKey = structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
if (!editableStructureKeys.add(normalizedStructureKey)) {
|
||||
blockingErrors.add(placementPath + ".structures[" + referenceIndex
|
||||
+ "] duplicates Iris structure '" + structureKey + "'.");
|
||||
continue;
|
||||
}
|
||||
if (!structureKeys.contains(structureKey)) {
|
||||
@@ -99,66 +142,240 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredJigsawKeys() {
|
||||
private static RegistrySnapshot registrySnapshot(boolean validateLiveRegistries,
|
||||
List<String> blockingErrors) {
|
||||
if (!validateLiveRegistries || !IrisPlatforms.isBound()) {
|
||||
return new RegistrySnapshot(Set.of(), Set.of(), Set.of(), null, null);
|
||||
}
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().jigsawStructureKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
PlatformStructureHooks hooks = IrisPlatforms.get().structureHooks();
|
||||
if (hooks == null) {
|
||||
throw new IllegalStateException("The active platform did not provide structure registry hooks");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
Set<String> structures = normalizeRegistryKeys(
|
||||
hooks.structureKeys(), "structure");
|
||||
Set<String> jigsaws = normalizeRegistryKeys(
|
||||
hooks.jigsawStructureKeys(), "jigsaw structure");
|
||||
Set<String> pools = normalizeRegistryKeys(
|
||||
hooks.templatePoolKeys(), "template pool");
|
||||
if (structures.isEmpty()) {
|
||||
throw new IllegalStateException("The active structure registry is empty");
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
if (jigsaws.isEmpty()) {
|
||||
throw new IllegalStateException("The active jigsaw structure registry is empty");
|
||||
}
|
||||
if (pools.isEmpty()) {
|
||||
throw new IllegalStateException("The active template pool registry is empty");
|
||||
}
|
||||
return new RegistrySnapshot(
|
||||
structures, jigsaws, pools, new JigsawMetadataResolver(hooks), hooks);
|
||||
} catch (RuntimeException | LinkageError e) {
|
||||
IrisLogging.reportError("Could not read the live structure registries during pack validation", e);
|
||||
blockingErrors.add("Could not validate native structure references against the live registries: "
|
||||
+ failureMessage(e) + ".");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredStructureKeys() {
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().structureKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
private static Set<String> normalizeRegistryKeys(List<String> registered, String registryName) {
|
||||
if (registered == null) {
|
||||
throw new IllegalStateException("The active " + registryName + " registry returned null");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
}
|
||||
|
||||
private static String failureMessage(Throwable throwable) {
|
||||
String message = throwable.getMessage();
|
||||
return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private static void validatePlacementConfiguration(String path, JSONObject placement,
|
||||
List<String> blockingErrors) {
|
||||
PackJsonFieldChecks.validateOptionalEnum(path, placement, "distribution",
|
||||
Set.of("RANDOM_SPREAD", "DENSITY", "CONCENTRIC_RINGS"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, placement, "spacing", 1, 4096, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "separation", 0, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "salt", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalDoubleRange(path, placement, "density", 0D, 1D, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringCount", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringDistance", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringSpread", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "minHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "maxHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underground", blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underwater", blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalEnum(path, placement, "nativeSuppression",
|
||||
Set.of("NONE", "REPLACE_SOURCE"), blockingErrors);
|
||||
|
||||
Integer spacing = integerValue(placement, "spacing", 32);
|
||||
Integer separation = integerValue(placement, "separation", 8);
|
||||
if (spacing != null && separation != null && separation >= spacing) {
|
||||
blockingErrors.add(path + ".separation must be smaller than spacing.");
|
||||
}
|
||||
Integer minimumHeight = integerValue(placement, "minHeight", -2032);
|
||||
Integer maximumHeight = integerValue(placement, "maxHeight", 2032);
|
||||
if (minimumHeight != null && maximumHeight != null && minimumHeight > maximumHeight) {
|
||||
blockingErrors.add(path + " has an inverted height band: minHeight must not exceed maxHeight.");
|
||||
}
|
||||
Integer ringCount = integerValue(placement, "ringCount", 128);
|
||||
Integer ringDistance = integerValue(placement, "ringDistance", 32);
|
||||
Integer ringSpread = integerValue(placement, "ringSpread", 3);
|
||||
if (ringCount != null && ringDistance != null && ringSpread != null
|
||||
&& ringCount > 0 && ringDistance > 0 && ringSpread > 0) {
|
||||
long ringRadius = (long) Math.ceilDiv(ringCount, ringSpread) * ringDistance;
|
||||
if (ringRadius > Integer.MAX_VALUE) {
|
||||
blockingErrors.add(path + " concentric ring radius exceeds the supported chunk coordinate range.");
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
if (placement.has("placementId")) {
|
||||
Object rawPlacementId = placement.opt("placementId");
|
||||
if (!(rawPlacementId instanceof String placementId)) {
|
||||
blockingErrors.add(path + ".placementId must be a string.");
|
||||
} else if (!placementId.isEmpty() && placementId.isBlank()) {
|
||||
blockingErrors.add(path + ".placementId must be omitted or non-blank.");
|
||||
} else if (!placementId.equals(placementId.trim())) {
|
||||
blockingErrors.add(path + ".placementId must not contain leading or trailing whitespace.");
|
||||
}
|
||||
}
|
||||
validateStilt(path, placement, blockingErrors);
|
||||
validateNativeTerrain(path, placement, blockingErrors);
|
||||
}
|
||||
|
||||
private static void validatePlacementIdentity(String path, JSONObject placement,
|
||||
Map<String, String> placementIds,
|
||||
Map<String, String> anonymousGridIdentities,
|
||||
List<String> blockingErrors) {
|
||||
Object rawPlacementId = placement.opt("placementId");
|
||||
if (rawPlacementId instanceof String placementId && !placementId.isBlank()) {
|
||||
String normalizedId = placementId.trim();
|
||||
String existing = placementIds.putIfAbsent(normalizedId, path);
|
||||
if (existing != null) {
|
||||
blockingErrors.add(path + ".placementId duplicates '" + normalizedId
|
||||
+ "' already declared by " + existing + ".");
|
||||
}
|
||||
return;
|
||||
}
|
||||
String gridIdentity = anonymousGridIdentity(placement);
|
||||
String existing = anonymousGridIdentities.putIfAbsent(gridIdentity, path);
|
||||
if (existing != null) {
|
||||
blockingErrors.add(path + " duplicates an anonymous placement grid already declared by "
|
||||
+ existing + "; give distinct placements unique placementId values.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredTemplatePoolKeys() {
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().templatePoolKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
private static String anonymousGridIdentity(JSONObject placement) {
|
||||
StringBuilder identity = new StringBuilder();
|
||||
appendIdentity(identity, placement.optString("distribution", "RANDOM_SPREAD"));
|
||||
appendIdentity(identity, integerValue(placement, "salt", 165745296));
|
||||
appendIdentity(identity, integerValue(placement, "spacing", 32));
|
||||
appendIdentity(identity, integerValue(placement, "separation", 8));
|
||||
Object rawDensity = placement.has("density") ? placement.opt("density") : 0.02D;
|
||||
appendIdentity(identity, rawDensity instanceof Number number
|
||||
? Double.doubleToLongBits(number.doubleValue()) : rawDensity);
|
||||
appendIdentity(identity, integerValue(placement, "ringCount", 128));
|
||||
appendIdentity(identity, integerValue(placement, "ringDistance", 32));
|
||||
appendIdentity(identity, integerValue(placement, "ringSpread", 3));
|
||||
appendIdentity(identity, integerValue(placement, "minHeight", -2032));
|
||||
appendIdentity(identity, integerValue(placement, "maxHeight", 2032));
|
||||
appendIdentity(identity, placement.opt("underground") instanceof Boolean underground && underground);
|
||||
appendIdentity(identity, placement.opt("underwater") instanceof Boolean underwater && underwater);
|
||||
JSONArray structures = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
if (structures != null && structures.length() > 0) {
|
||||
appendIdentity(identity, "iris");
|
||||
List<String> orderedStructures = new ArrayList<>(structures.length());
|
||||
for (int index = 0; index < structures.length(); index++) {
|
||||
orderedStructures.add(String.valueOf(structures.opt(index)));
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
orderedStructures.sort(String::compareTo);
|
||||
for (String structure : orderedStructures) {
|
||||
appendIdentity(identity, structure);
|
||||
}
|
||||
} else {
|
||||
appendIdentity(identity, "native");
|
||||
if (nativeStructures != null) {
|
||||
List<String> orderedSources = new ArrayList<>(nativeStructures.length());
|
||||
for (int index = 0; index < nativeStructures.length(); index++) {
|
||||
JSONObject source = nativeStructures.optJSONObject(index);
|
||||
String sourceKey = source == null ? "null" : source.optString("structure", "");
|
||||
Integer weight = source == null ? null : integerValue(source, "weight", 1);
|
||||
orderedSources.add(sourceKey.length() + ":" + sourceKey + ":" + weight);
|
||||
}
|
||||
orderedSources.sort(String::compareTo);
|
||||
for (String source : orderedSources) {
|
||||
appendIdentity(identity, source);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
}
|
||||
return identity.toString();
|
||||
}
|
||||
|
||||
private static void appendIdentity(StringBuilder identity, Object value) {
|
||||
String text = String.valueOf(value);
|
||||
identity.append(text.length()).append(':').append(text).append('|');
|
||||
}
|
||||
|
||||
private static void validateStilt(String path, JSONObject placement, List<String> blockingErrors) {
|
||||
if (!placement.has("stilt") || placement.opt("stilt") == JSONObject.NULL) {
|
||||
return;
|
||||
}
|
||||
JSONObject stilt = placement.optJSONObject("stilt");
|
||||
if (stilt == null) {
|
||||
blockingErrors.add(path + ".stilt must be an object.");
|
||||
return;
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path + ".stilt", stilt, "maxDepth", 1, 4064, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path + ".stilt", stilt, "spacing", 1, 64, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(
|
||||
path + ".stilt", stilt, "supportNonOccluding", blockingErrors);
|
||||
if (stilt.has("palette") && stilt.opt("palette") != JSONObject.NULL
|
||||
&& stilt.optJSONObject("palette") == null) {
|
||||
blockingErrors.add(path + ".stilt.palette must be an object.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateNativeStructures(String placementPath, JSONObject placement,
|
||||
private static Integer integerValue(JSONObject object, String field, int defaultValue) {
|
||||
if (!object.has(field)) {
|
||||
return defaultValue;
|
||||
}
|
||||
Object rawValue = object.opt(field);
|
||||
if (!(rawValue instanceof Number number)) {
|
||||
return null;
|
||||
}
|
||||
double doubleValue = number.doubleValue();
|
||||
if (!Double.isFinite(doubleValue) || Math.rint(doubleValue) != doubleValue
|
||||
|| doubleValue < Integer.MIN_VALUE || doubleValue > Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return number.intValue();
|
||||
}
|
||||
|
||||
private static void validateNativeStructures(String placementPath,
|
||||
JSONArray nativeStructures,
|
||||
Set<String> registeredStructures,
|
||||
Set<String> registeredJigsaws,
|
||||
Set<String> registeredPools,
|
||||
JigsawMetadataResolver jigsawMetadataResolver,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
Set<String> sourceKeys = new HashSet<>();
|
||||
long totalWeight = 0L;
|
||||
for (int sourceIndex = 0; sourceIndex < nativeStructures.length(); sourceIndex++) {
|
||||
String sourcePath = placementPath + ".nativeStructures[" + sourceIndex + "]";
|
||||
JSONObject source = nativeStructures.optJSONObject(sourceIndex);
|
||||
@@ -174,22 +391,199 @@ final class PackStructurePlacementValidator {
|
||||
blockingErrors.add(sourcePath + ".structure '" + structureKey
|
||||
+ "' is not a registered structure.");
|
||||
}
|
||||
if (!structureKey.isEmpty() && !sourceKeys.add(structureKey.toLowerCase(Locale.ROOT))) {
|
||||
blockingErrors.add(sourcePath + ".structure duplicates native source '" + structureKey + "'.");
|
||||
}
|
||||
Integer weight = PackLootValidator.lootInteger(source, "weight", 1, sourcePath, blockingErrors);
|
||||
PackLootValidator.requireMinimum(sourcePath + ".weight", weight, 1, blockingErrors);
|
||||
if (weight != null && weight > 0) {
|
||||
totalWeight += weight;
|
||||
}
|
||||
String normalizedStructureKey = structureKey.toLowerCase(Locale.ROOT);
|
||||
boolean registeredJigsaw = registeredJigsaws.contains(normalizedStructureKey);
|
||||
JSONObject jigsaw = source.optJSONObject("jigsaw");
|
||||
if (source.has("jigsaw") && source.opt("jigsaw") != JSONObject.NULL && jigsaw == null) {
|
||||
blockingErrors.add(sourcePath + ".jigsaw must be an object.");
|
||||
} else if (jigsaw != null) {
|
||||
if (!registeredJigsaws.isEmpty()
|
||||
&& !registeredJigsaws.contains(structureKey.toLowerCase(Locale.ROOT))) {
|
||||
if (!registeredJigsaws.isEmpty() && !registeredJigsaw) {
|
||||
blockingErrors.add(sourcePath
|
||||
+ ".jigsaw requires a registered jigsaw structure.");
|
||||
}
|
||||
validateJigsawAssembly(
|
||||
sourcePath + ".jigsaw", jigsaw, registeredPools, blockingErrors);
|
||||
}
|
||||
JigsawSourceMetadata sourceMetadata = registeredJigsaw
|
||||
? resolveJigsawMetadata(sourcePath, normalizedStructureKey,
|
||||
jigsawMetadataResolver, blockingErrors)
|
||||
: null;
|
||||
if (registeredJigsaw && sourceMetadata == null) {
|
||||
continue;
|
||||
}
|
||||
if (registeredJigsaw || jigsaw != null) {
|
||||
validateReferenceEnvelope(
|
||||
sourcePath, normalizedStructureKey, jigsaw,
|
||||
sourceMetadata, hooks, blockingErrors);
|
||||
}
|
||||
}
|
||||
validateNativeTerrain(placementPath, placement, blockingErrors);
|
||||
if (totalWeight > Integer.MAX_VALUE) {
|
||||
blockingErrors.add(placementPath + ".nativeStructures total weight exceeds "
|
||||
+ Integer.MAX_VALUE + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateReferenceEnvelope(String sourcePath, String structureKey,
|
||||
JSONObject jigsaw, JigsawSourceMetadata sourceMetadata,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
boolean overriddenDistance = jigsaw != null && jigsaw.has("maxDistanceHorizontal");
|
||||
Integer maximumDistance = overriddenDistance
|
||||
? integerValue(jigsaw, "maxDistanceHorizontal", -1)
|
||||
: sourceMetadata == null ? null : sourceMetadata.maxDistanceHorizontal();
|
||||
if (maximumDistance == null || maximumDistance < 0) {
|
||||
return;
|
||||
}
|
||||
Integer startElementSpan = effectiveStartElementSpan(
|
||||
sourcePath, structureKey, jigsaw, sourceMetadata, hooks, blockingErrors);
|
||||
if (startElementSpan == null) {
|
||||
return;
|
||||
}
|
||||
long assemblySpan = Math.max(maximumDistance, startElementSpan);
|
||||
if (assemblySpan > 128L) {
|
||||
String distancePath = overriddenDistance
|
||||
? ".jigsaw.maxDistanceHorizontal" : " registered jigsaw source max-distance";
|
||||
blockingErrors.add(sourcePath + distancePath + " and its actual structure content"
|
||||
+ " (" + startElementSpan + "-block maximum start element and "
|
||||
+ maximumDistance + "-block maximum assembly distance) must not exceed Minecraft's"
|
||||
+ " 128-block (8-chunk) structure reference range.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer effectiveStartElementSpan(String sourcePath, String structureKey,
|
||||
JSONObject jigsaw,
|
||||
JigsawSourceMetadata sourceMetadata,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
int sourceSpan = sourceMetadata == null ? 0 : sourceMetadata.maxStartElementHorizontalSpan();
|
||||
if (jigsaw == null || !jigsaw.has("startPool")) {
|
||||
return sourceSpan;
|
||||
}
|
||||
String startPool = jigsaw.optString("startPool", "").trim();
|
||||
if (startPool.isEmpty() || hooks == null) {
|
||||
return sourceSpan;
|
||||
}
|
||||
try {
|
||||
int resolvedSpan = hooks.jigsawStartPoolHorizontalSpan(structureKey, startPool);
|
||||
if (resolvedSpan < 0) {
|
||||
throw new IllegalStateException("negative horizontal span " + resolvedSpan);
|
||||
}
|
||||
return resolvedSpan;
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
IrisLogging.reportError("Could not resolve the effective native jigsaw start-pool span", error);
|
||||
blockingErrors.add(sourcePath + ".jigsaw.startPool '" + startPool
|
||||
+ "' could not resolve a bounded live horizontal span: "
|
||||
+ failureMessage(error) + ".");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateImportedStructureAdjustmentEnvelopes(
|
||||
String dimensionKey, JSONObject dimension, RegistrySnapshot registries,
|
||||
List<String> blockingErrors) {
|
||||
JSONObject policy = dimension.optJSONObject("importedStructures");
|
||||
if (policy == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray adjustments = policy.optJSONArray("adjustments");
|
||||
if (adjustments == null) {
|
||||
return;
|
||||
}
|
||||
List<String> orderedStructureKeys = new ArrayList<>(registries.structures());
|
||||
orderedStructureKeys.sort(String::compareTo);
|
||||
Map<String, EffectiveTerrainAdjustment> effective = new HashMap<>();
|
||||
for (int adjustmentIndex = 0; adjustmentIndex < adjustments.length(); adjustmentIndex++) {
|
||||
JSONObject adjustment = adjustments.optJSONObject(adjustmentIndex);
|
||||
if (adjustment == null || adjustment.optJSONObject("terrain") == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray matches = adjustment.optJSONArray("match");
|
||||
if (matches == null || matches.length() == 0) {
|
||||
continue;
|
||||
}
|
||||
String path = "Dimension '" + dimensionKey
|
||||
+ "' importedStructures.adjustments[" + adjustmentIndex + "]";
|
||||
for (String structureKey : orderedStructureKeys) {
|
||||
if (matchesStructure(matches, structureKey)) {
|
||||
effective.put(structureKey, new EffectiveTerrainAdjustment(path, adjustment));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String structureKey : orderedStructureKeys) {
|
||||
EffectiveTerrainAdjustment adjustment = effective.get(structureKey);
|
||||
if (adjustment == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject terrain = adjustment.adjustment().optJSONObject("terrain");
|
||||
if (terrain == null || !TERRAIN_ENVELOPE_MODES.contains(
|
||||
terrain.optString("mode", "SOURCE").toUpperCase(Locale.ROOT))) {
|
||||
continue;
|
||||
}
|
||||
String sourcePath = adjustment.path() + " matched registered structure '"
|
||||
+ structureKey + "'";
|
||||
if (registries.jigsaws().contains(structureKey)) {
|
||||
JigsawSourceMetadata metadata = resolveJigsawMetadata(
|
||||
sourcePath, structureKey,
|
||||
registries.jigsawMetadataResolver(), blockingErrors);
|
||||
if (metadata == null) {
|
||||
continue;
|
||||
}
|
||||
validateReferenceEnvelope(
|
||||
sourcePath, structureKey, null, metadata,
|
||||
registries.hooks(), blockingErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matchesStructure(JSONArray matches, String structureKey) {
|
||||
for (int matchIndex = 0; matchIndex < matches.length(); matchIndex++) {
|
||||
Object rawPattern = matches.opt(matchIndex);
|
||||
if (rawPattern instanceof String pattern
|
||||
&& matchesStructurePattern(pattern, structureKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JigsawSourceMetadata resolveJigsawMetadata(
|
||||
String sourcePath, String structureKey,
|
||||
JigsawMetadataResolver resolver, List<String> blockingErrors) {
|
||||
if (resolver == null) {
|
||||
return null;
|
||||
}
|
||||
MetadataResolution resolution = resolver.resolve(structureKey);
|
||||
if (resolution.metadata() != null) {
|
||||
return resolution.metadata();
|
||||
}
|
||||
blockingErrors.add(sourcePath + " could not resolve live metadata for registered jigsaw source '"
|
||||
+ structureKey + "': " + resolution.failure() + ".");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matchesStructurePattern(String pattern, String structureKey) {
|
||||
String normalizedPattern = pattern == null ? "" : pattern.trim().toLowerCase(Locale.ROOT);
|
||||
String normalizedKey = structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalizedPattern.isEmpty() || !normalizedKey.startsWith(normalizedPattern)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedKey.length() == normalizedPattern.length()) {
|
||||
return true;
|
||||
}
|
||||
char patternEnd = normalizedPattern.charAt(normalizedPattern.length() - 1);
|
||||
if (patternEnd == ':' || patternEnd == '/' || patternEnd == '_') {
|
||||
return true;
|
||||
}
|
||||
char boundary = normalizedKey.charAt(normalizedPattern.length());
|
||||
return boundary == '/' || boundary == '_';
|
||||
}
|
||||
|
||||
private static void validateJigsawAssembly(String path, JSONObject assembly,
|
||||
@@ -234,6 +628,8 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode",
|
||||
Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "shape",
|
||||
Set.of("BOX", "ROUNDED", "ERODED"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
"horizontalPadding", 0, 128, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
@@ -386,4 +782,48 @@ final class PackStructurePlacementValidator {
|
||||
default -> "Resource";
|
||||
};
|
||||
}
|
||||
|
||||
private record RegistrySnapshot(Set<String> structures, Set<String> jigsaws, Set<String> pools,
|
||||
JigsawMetadataResolver jigsawMetadataResolver,
|
||||
PlatformStructureHooks hooks) {
|
||||
}
|
||||
|
||||
private static final class JigsawMetadataResolver {
|
||||
private final PlatformStructureHooks hooks;
|
||||
private final Map<String, MetadataResolution> resolutions = new HashMap<>();
|
||||
|
||||
private JigsawMetadataResolver(PlatformStructureHooks hooks) {
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
private MetadataResolution resolve(String structureKey) {
|
||||
MetadataResolution cached = resolutions.get(structureKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
MetadataResolution resolved;
|
||||
try {
|
||||
JigsawSourceMetadata metadata = hooks.jigsawSourceMetadata(structureKey);
|
||||
if (metadata == null) {
|
||||
throw new IllegalStateException(
|
||||
"The active structure registry returned null jigsaw metadata for '"
|
||||
+ structureKey + "'");
|
||||
}
|
||||
resolved = new MetadataResolution(metadata, null);
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
IrisLogging.reportError(
|
||||
"Could not resolve live jigsaw metadata for registered structure '"
|
||||
+ structureKey + "' during pack validation", error);
|
||||
resolved = new MetadataResolution(null, failureMessage(error));
|
||||
}
|
||||
resolutions.put(structureKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private record MetadataResolution(JigsawSourceMetadata metadata, String failure) {
|
||||
}
|
||||
|
||||
private record EffectiveTerrainAdjustment(String path, JSONObject adjustment) {
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ public final class PackValidator {
|
||||
}
|
||||
|
||||
public static PackValidationResult validate(File packFolder) {
|
||||
return validate(packFolder, true);
|
||||
}
|
||||
|
||||
public static PackValidationResult validateForDatapackBootstrap(File packFolder) {
|
||||
return validate(packFolder, false);
|
||||
}
|
||||
|
||||
private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) {
|
||||
String packName = packFolder == null ? "<unknown>" : packFolder.getName();
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
@@ -72,7 +80,8 @@ public final class PackValidator {
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(
|
||||
packFolder, validateLiveRegistries));
|
||||
StructureGraphPackValidator.Validation compiledStructures =
|
||||
StructureGraphPackValidator.validate(
|
||||
packFolder.toPath(), PackObjectSurfaceValidator.collectPlacedStructureKeys(packFolder));
|
||||
|
||||
@@ -18,52 +18,213 @@
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisProjectCopier {
|
||||
private IrisProjectCopier() {
|
||||
}
|
||||
|
||||
public static void copyProject(File sourcePack, File targetPack, String sourceKey, String targetKey) throws IOException {
|
||||
Path source = sourcePack.toPath();
|
||||
try (Stream<Path> walk = Files.walk(source)) {
|
||||
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) {
|
||||
String relative = source.relativize(path).toString();
|
||||
if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) {
|
||||
continue;
|
||||
}
|
||||
Path destination = targetPack.toPath().resolve(relative);
|
||||
if (Files.isDirectory(path)) {
|
||||
Files.createDirectories(destination);
|
||||
} else {
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void copyProject(File sourcePack, File targetWorkspace, String sourceKey, String targetKey) throws IOException {
|
||||
copyProject(sourcePack, targetWorkspace, sourceKey, targetKey, (source, target) -> {
|
||||
});
|
||||
}
|
||||
|
||||
File oldDimension = new File(targetPack, "dimensions/" + sourceKey + ".json");
|
||||
File newDimension = new File(targetPack, "dimensions/" + targetKey + ".json");
|
||||
if (oldDimension.isFile() && !oldDimension.equals(newDimension)) {
|
||||
Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.delete(oldDimension.toPath());
|
||||
}
|
||||
if (newDimension.isFile()) {
|
||||
JSONObject json = new JSONObject(IO.readAll(newDimension));
|
||||
if (json.has("name")) {
|
||||
json.put("name", Form.capitalizeWords(targetKey.replaceAll("\\Q-\\E", " ")));
|
||||
IO.writeAll(newDimension, json.toString(4));
|
||||
static void copyProject(
|
||||
File sourcePack,
|
||||
File targetWorkspace,
|
||||
String sourceKey,
|
||||
String targetKey,
|
||||
CopyHook copyHook
|
||||
) throws IOException {
|
||||
String validatedSourceKey = requireSafeKey(sourceKey, "source");
|
||||
String validatedTargetKey = requireSafeKey(targetKey, "target");
|
||||
Path source = requireSafeSource(
|
||||
Objects.requireNonNull(sourcePack, "sourcePack").toPath().toAbsolutePath().normalize(),
|
||||
validatedSourceKey
|
||||
);
|
||||
Path workspace = requireTargetWorkspace(targetWorkspace);
|
||||
Path target = workspace.resolve(validatedTargetKey).normalize();
|
||||
requireAvailableTarget(target, workspace, validatedTargetKey);
|
||||
|
||||
Path stage = Files.createTempDirectory(workspace, "." + validatedTargetKey + ".importing-");
|
||||
boolean published = false;
|
||||
Throwable operationFailure = null;
|
||||
try {
|
||||
copyTree(source, stage, Objects.requireNonNull(copyHook, "copyHook"));
|
||||
transformDimension(stage, validatedSourceKey, validatedTargetKey);
|
||||
requireAvailableTarget(target, workspace, validatedTargetKey);
|
||||
publish(stage, target);
|
||||
published = true;
|
||||
} catch (IOException | RuntimeException | Error e) {
|
||||
operationFailure = e;
|
||||
throw e;
|
||||
} finally {
|
||||
if (!published) {
|
||||
try {
|
||||
deleteTree(stage);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (operationFailure != null) {
|
||||
operationFailure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireSafeKey(String value, String purpose) throws IOException {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Project " + purpose + " key cannot be empty.");
|
||||
}
|
||||
|
||||
Path key;
|
||||
try {
|
||||
key = Path.of(value);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid project " + purpose + " key: " + value, e);
|
||||
}
|
||||
if (key.isAbsolute()
|
||||
|| key.getNameCount() != 1
|
||||
|| !key.normalize().equals(key)
|
||||
|| ".".equals(value)
|
||||
|| "..".equals(value)
|
||||
|| value.indexOf('/') >= 0
|
||||
|| value.indexOf('\\') >= 0) {
|
||||
throw new IOException("Invalid project " + purpose + " key: " + value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Path requireTargetWorkspace(File targetWorkspace) throws IOException {
|
||||
Path workspace = Objects.requireNonNull(targetWorkspace, "targetWorkspace").toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(workspace)
|
||||
|| !Files.isDirectory(workspace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Project workspace is missing or unsafe: " + workspace);
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private static Path requireSafeSource(Path source, String sourceKey) throws IOException {
|
||||
if (!Files.isDirectory(source)) {
|
||||
throw new IOException("Source project is missing or unsafe: " + source);
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(source.toFile());
|
||||
Path resolvedSource = source.toRealPath();
|
||||
|
||||
Path dimension = resolvedSource.resolve("dimensions").resolve(sourceKey + ".json").normalize();
|
||||
if (!dimension.startsWith(resolvedSource)
|
||||
|| Files.isSymbolicLink(dimension)
|
||||
|| !Files.isRegularFile(dimension, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Source project dimension is missing or unsafe: " + dimension);
|
||||
}
|
||||
return resolvedSource;
|
||||
}
|
||||
|
||||
private static void requireAvailableTarget(Path target, Path workspace, String targetKey) throws IOException {
|
||||
if (!workspace.equals(target.getParent()) || !targetKey.equals(target.getFileName().toString())) {
|
||||
throw new IOException("Target project must be a direct child of the workspace: " + target);
|
||||
}
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new FileAlreadyExistsException(target.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyTree(Path source, Path stage, CopyHook copyHook) throws IOException {
|
||||
try (Stream<Path> walk = Files.walk(source)) {
|
||||
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) {
|
||||
Path relative = source.relativize(path);
|
||||
if (relative.toString().isEmpty() || shouldSkip(relative)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Source project contains a symbolic link: " + path);
|
||||
}
|
||||
|
||||
Path destination = stage.resolve(relative).normalize();
|
||||
if (!destination.startsWith(stage)) {
|
||||
throw new IOException("Source project entry escapes staging: " + relative);
|
||||
}
|
||||
copyHook.beforeCopy(path, destination);
|
||||
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination);
|
||||
} else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.copy(path, destination);
|
||||
} else {
|
||||
throw new IOException("Source project contains an unsupported entry: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean shouldSkip(Path relative) {
|
||||
return ".git".equals(relative.getName(0).toString())
|
||||
|| relative.getFileName().toString().endsWith(".code-workspace");
|
||||
}
|
||||
|
||||
private static void transformDimension(Path stage, String sourceKey, String targetKey) throws IOException {
|
||||
Path oldDimension = stage.resolve("dimensions").resolve(sourceKey + ".json");
|
||||
Path newDimension = stage.resolve("dimensions").resolve(targetKey + ".json");
|
||||
if (!oldDimension.equals(newDimension)) {
|
||||
Files.move(oldDimension, newDimension);
|
||||
}
|
||||
|
||||
JSONObject json = new JSONObject(IO.readAll(newDimension.toFile()));
|
||||
if (json.has("name")) {
|
||||
json.put("name", Form.capitalizeWords(targetKey.replace('-', ' ')));
|
||||
IO.writeAll(newDimension.toFile(), json.toString(4));
|
||||
}
|
||||
}
|
||||
|
||||
private static void publish(Path stage, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(stage, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
IOException failure = null;
|
||||
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
if (failure == null) {
|
||||
failure = e;
|
||||
} else {
|
||||
failure.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CopyHook {
|
||||
void beforeCopy(Path source, Path target) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -536,7 +536,7 @@ public class SchemaBuilder {
|
||||
} else if (SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
|
||||
} else if (k.getType().isEnum()) {
|
||||
fancyType = addEnum(k.getType(), prop, description, k.getType().getEnumConstants(), o -> ((Enum<?>) o).name());
|
||||
fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity());
|
||||
}
|
||||
}
|
||||
case "object" -> {
|
||||
@@ -738,7 +738,7 @@ public class SchemaBuilder {
|
||||
} else if (SchemaKeyedTypes.isKeyed(t.type())) {
|
||||
fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
|
||||
} else if (t.type().isEnum()) {
|
||||
fancyType = addEnumList(prop, description, t, t.type().getEnumConstants(), o -> ((Enum<?>) o).name());
|
||||
fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,22 @@ public class SchemaBuilder {
|
||||
return "List of " + s + "s";
|
||||
}
|
||||
|
||||
private static String[] enumNames(Class<?> enumType) {
|
||||
try {
|
||||
Object[] constants = enumType.getEnumConstants();
|
||||
String[] names = new String[constants.length];
|
||||
for (int index = 0; index < constants.length; index++) {
|
||||
names[index] = ((Enum<?>) constants[index]).name();
|
||||
}
|
||||
return names;
|
||||
} catch (LinkageError error) {
|
||||
return Arrays.stream(enumType.getDeclaredFields())
|
||||
.filter(Field::isEnumConstant)
|
||||
.map(Field::getName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
|
||||
JSONArray a = new JSONArray();
|
||||
|
||||
@@ -2,9 +2,13 @@ package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.project.IrisCodeWorkspace;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
@@ -14,7 +18,6 @@ import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
@@ -22,17 +25,27 @@ import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class StudioOpenCoordinator {
|
||||
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
|
||||
private static volatile StudioOpenCoordinator instance;
|
||||
|
||||
private StudioOpenCoordinator() {
|
||||
@@ -61,31 +74,20 @@ public final class StudioOpenCoordinator {
|
||||
}
|
||||
|
||||
public CompletableFuture<StudioCloseResult> closeProject(IrisProject project) {
|
||||
CompletableFuture<StudioCloseResult> future = new CompletableFuture<>();
|
||||
J.aBukkit(() -> future.complete(executeClose(project)));
|
||||
return future;
|
||||
}
|
||||
|
||||
private StudioCloseResult executeClose(IrisProject project) {
|
||||
if (project == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
|
||||
}
|
||||
|
||||
PlatformChunkGenerator provider = project.getActiveProvider();
|
||||
if (provider == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
|
||||
}
|
||||
|
||||
World world = BukkitWorldBinding.world(provider.getTarget().getWorld());
|
||||
String worldName = world == null
|
||||
? IrisWorldStorage.logicalName(WorldIdentity.parse(provider.getTarget().getWorld().identity()))
|
||||
: IrisWorldStorage.logicalName(world);
|
||||
try {
|
||||
return closeWorld(provider, worldName, world, true, project);
|
||||
} catch (Throwable e) {
|
||||
project.setActiveProvider(null);
|
||||
return new StudioCloseResult(worldName, false, false, false, e);
|
||||
}
|
||||
return closeWorldCoordinated(provider, worldName, world, true, project);
|
||||
}
|
||||
|
||||
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
|
||||
@@ -200,7 +202,16 @@ public final class StudioOpenCoordinator {
|
||||
if (!request.retainOnFailure()) {
|
||||
try {
|
||||
updateStage(request, "cleanup", 1.00D);
|
||||
closeWorld(provider, request.worldName(), world, true, request.project());
|
||||
StudioCloseResult cleanupResult = closeWorldCoordinated(
|
||||
provider,
|
||||
request.worldName(),
|
||||
world,
|
||||
true,
|
||||
request.project()
|
||||
).get(45L, TimeUnit.SECONDS);
|
||||
if (cleanupResult.failureCause() != null) {
|
||||
throw cleanupResult.failureCause();
|
||||
}
|
||||
} catch (Throwable cleanupError) {
|
||||
IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
|
||||
}
|
||||
@@ -240,164 +251,344 @@ public final class StudioOpenCoordinator {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private StudioCloseResult closeWorld(
|
||||
private CompletableFuture<StudioCloseResult> closeWorldCoordinated(
|
||||
PlatformChunkGenerator provider,
|
||||
String worldName,
|
||||
World world,
|
||||
boolean deleteFolder,
|
||||
IrisProject project
|
||||
) {
|
||||
Throwable failure = null;
|
||||
boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName);
|
||||
boolean folderDeletionCompletedLive = !deleteFolder;
|
||||
boolean startupCleanupQueued = false;
|
||||
CompletableFuture<Void> closeFuture = CompletableFuture.completedFuture(null);
|
||||
|
||||
if (world != null) {
|
||||
try {
|
||||
evacuatePlayers(world);
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
String operationTarget = worldName == null || worldName.isBlank() ? "unknown-studio-world" : worldName;
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.STUDIO_CLOSE,
|
||||
operationTarget
|
||||
);
|
||||
} catch (Throwable failure) {
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(
|
||||
worldName,
|
||||
false,
|
||||
false,
|
||||
queued,
|
||||
failure
|
||||
));
|
||||
}
|
||||
|
||||
CompletableFuture<StudioCloseResult> closeFuture;
|
||||
try {
|
||||
closeFuture = closeWorldReserved(provider, worldName, world, deleteFolder, project);
|
||||
} catch (Throwable failure) {
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
closeFuture = CompletableFuture.completedFuture(new StudioCloseResult(
|
||||
worldName,
|
||||
false,
|
||||
false,
|
||||
queued,
|
||||
failure
|
||||
));
|
||||
}
|
||||
return closeFuture.whenComplete((result, throwable) -> lease.close());
|
||||
}
|
||||
|
||||
private CompletableFuture<StudioCloseResult> closeWorldReserved(
|
||||
PlatformChunkGenerator provider,
|
||||
String worldName,
|
||||
World world,
|
||||
boolean deleteFolder,
|
||||
IrisProject project
|
||||
) {
|
||||
AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
|
||||
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
if (world != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
|
||||
}
|
||||
|
||||
try {
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
}
|
||||
if (provider != null) {
|
||||
closeFuture = provider.closeAsync();
|
||||
}
|
||||
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
requestWorldFamilyUnload(worldName);
|
||||
}
|
||||
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L);
|
||||
CompletableFuture<Void> unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline);
|
||||
try {
|
||||
unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS);
|
||||
unloadCompletedLive = true;
|
||||
} catch (TimeoutException e) {
|
||||
unloadCompletedLive = !isWorldFamilyLoaded(worldName);
|
||||
} catch (Throwable e) {
|
||||
failure = failure == null ? unwrapFailure(e) : failure;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
closeFuture.get(20L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
Throwable cause = unwrapFailure(e);
|
||||
if (failure == null) {
|
||||
failure = cause;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteFolder && worldName != null && !worldName.isBlank()) {
|
||||
WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive);
|
||||
folderDeletionCompletedLive = deleteResult.liveDeleted();
|
||||
startupCleanupQueued = deleteResult.startupCleanupQueued();
|
||||
}
|
||||
} finally {
|
||||
CompletableFuture<Void> sequence = sequenceStudioClose(
|
||||
() -> evacuateWorldFamily(worldName, world),
|
||||
() -> unloadWorldFamily(worldName, world).thenRun(() -> {
|
||||
if (terminalTimeout.get()) {
|
||||
throw new CompletionException(new TimeoutException(
|
||||
"Studio close stopped after its terminal timeout."));
|
||||
}
|
||||
unloadConfirmed.set(true);
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
}
|
||||
}),
|
||||
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
|
||||
() -> deleteFolder
|
||||
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
|
||||
: CompletableFuture.completedFuture(null),
|
||||
terminalTimeout::get
|
||||
);
|
||||
CompletableFuture<StudioCloseResult> operation = guardCloseCompletion(
|
||||
sequence,
|
||||
terminalTimeout,
|
||||
worldName)
|
||||
.thenApply(ignored -> new StudioCloseResult(
|
||||
worldName,
|
||||
true,
|
||||
folderDeleted.get(),
|
||||
false,
|
||||
null
|
||||
))
|
||||
.exceptionally(throwable -> {
|
||||
Throwable failure = unwrapFailure(throwable);
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
return new StudioCloseResult(
|
||||
worldName,
|
||||
unloadConfirmed.get(),
|
||||
folderDeleted.get(),
|
||||
queued,
|
||||
failure
|
||||
);
|
||||
});
|
||||
return operation.whenComplete((result, throwable) -> {
|
||||
if (world != null) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "studio-close");
|
||||
}
|
||||
}
|
||||
|
||||
return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure);
|
||||
});
|
||||
}
|
||||
|
||||
private void evacuatePlayers(World world) throws Exception {
|
||||
if (world == null) {
|
||||
return;
|
||||
static CompletableFuture<Void> sequenceStudioClose(
|
||||
Supplier<CompletableFuture<Void>> evacuate,
|
||||
Supplier<CompletableFuture<Void>> unload,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolders
|
||||
) {
|
||||
return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false);
|
||||
}
|
||||
|
||||
static CompletableFuture<Void> sequenceStudioClose(
|
||||
Supplier<CompletableFuture<Void>> evacuate,
|
||||
Supplier<CompletableFuture<Void>> unload,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolders,
|
||||
BooleanSupplier terminalTimeout
|
||||
) {
|
||||
return invokePhase(evacuate)
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout))
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout))
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout));
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) {
|
||||
try {
|
||||
CompletableFuture<Void> future = phase.get();
|
||||
if (future == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future."));
|
||||
}
|
||||
return future;
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> invokePhaseUnlessTimedOut(
|
||||
Supplier<CompletableFuture<Void>> phase,
|
||||
BooleanSupplier terminalTimeout
|
||||
) {
|
||||
if (terminalTimeout.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"Studio close stopped after its terminal timeout."));
|
||||
}
|
||||
return invokePhase(phase);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> guardCloseCompletion(
|
||||
CompletableFuture<Void> source,
|
||||
AtomicBoolean terminalTimeout,
|
||||
String worldName
|
||||
) {
|
||||
CompletableFuture<Void> guarded = new CompletableFuture<>();
|
||||
AtomicBoolean settled = new AtomicBoolean(false);
|
||||
source.whenComplete((ignored, throwable) -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (throwable == null) {
|
||||
guarded.complete(null);
|
||||
} else {
|
||||
guarded.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
terminalTimeout.set(true);
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS
|
||||
+ " seconds for \"" + worldName + "\".");
|
||||
ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\".");
|
||||
guarded.completeExceptionally(timeout);
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> evacuateWorldFamily(String worldName, World primaryWorld) {
|
||||
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
|
||||
if (loadedWorlds.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future = J.sfut(() -> {
|
||||
IrisToolbelt.evacuate(world);
|
||||
ArrayList<CompletableFuture<Void>> evacuations = new ArrayList<>(loadedWorlds.size());
|
||||
for (World loadedWorld : loadedWorlds) {
|
||||
CompletableFuture<Void> evacuation = J.sfut(() -> IrisToolbelt.evacuateAsync(loadedWorld))
|
||||
.thenCompose(evacuationFuture -> evacuationFuture)
|
||||
.thenCompose(evacuated -> Boolean.TRUE.equals(evacuated)
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Studio player evacuation failed for \"" + loadedWorld.getName() + "\".")));
|
||||
evacuations.add(evacuation);
|
||||
}
|
||||
return CompletableFuture.allOf(evacuations.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> unloadWorldFamily(String worldName, World primaryWorld) {
|
||||
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
|
||||
ArrayList<CompletableFuture<Boolean>> unloads = new ArrayList<>(loadedWorlds.size());
|
||||
for (World loadedWorld : loadedWorlds) {
|
||||
CompletableFuture<Boolean> unload = J.sfut(() ->
|
||||
IrisServices.get(MultiverseCoreLink.class)
|
||||
.removeFromConfig(loadedWorld))
|
||||
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false));
|
||||
unloads.add(unload);
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)).thenApply(ignored -> {
|
||||
for (CompletableFuture<Boolean> unload : unloads) {
|
||||
if (!Boolean.TRUE.equals(unload.join())) {
|
||||
throw new CompletionException(new IllegalStateException(
|
||||
"Studio world family unload returned false for \"" + worldName + "\"."));
|
||||
}
|
||||
}
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
throw new CompletionException(new IllegalStateException(
|
||||
"Studio world family remained loaded after confirmed unload for \"" + worldName + "\"."));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (future != null) {
|
||||
future.get(10L, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestWorldFamilyUnload(String worldName) {
|
||||
private List<World> loadedWorldFamily(String worldName, World primaryWorld) {
|
||||
LinkedHashSet<World> worlds = new LinkedHashSet<>();
|
||||
if (primaryWorld != null) {
|
||||
worlds.add(primaryWorld);
|
||||
}
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return;
|
||||
return List.copyOf(worlds);
|
||||
}
|
||||
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
World familyWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).orElse(null);
|
||||
if (familyWorld == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeFromConfig(familyWorld);
|
||||
WorldLifecycleService.get().unload(familyWorld, false);
|
||||
WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).ifPresent(worlds::add);
|
||||
}
|
||||
return List.copyOf(worlds);
|
||||
}
|
||||
|
||||
private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) {
|
||||
private CompletableFuture<Void> deleteWorldFamily(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Refusing to delete a loaded studio world family for \"" + worldName + "\"."));
|
||||
}
|
||||
|
||||
boolean liveDeleted = true;
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File folder = IrisWorldStorage.dimensionRoot(familyWorldName);
|
||||
if (!folder.exists()) {
|
||||
continue;
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
try {
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
throw new IOException("Studio world family became loaded before deletion for \""
|
||||
+ worldName + "\".");
|
||||
}
|
||||
File folder = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
IrisWorldStorage.managedKeyFromName(familyWorldName));
|
||||
AtomicDirectoryPublisher.deleteTree(folder.toPath());
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
throw new CompletionException(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
liveDeleted = false;
|
||||
IrisLogging.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e));
|
||||
}
|
||||
|
||||
if (folder.exists()) {
|
||||
liveDeleted = false;
|
||||
}
|
||||
private boolean queueStartupCleanup(String worldName, Throwable failure) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (liveDeleted) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
}
|
||||
|
||||
try {
|
||||
IrisServices.get(WorldDeletionQueue.class).queueForStartupDeletion(Collections.singleton(worldName));
|
||||
return new WorldFamilyDeleteResult(false, true);
|
||||
} catch (IOException e) {
|
||||
if (unloadCompletedLive) {
|
||||
IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e);
|
||||
IrisServices.get(WorldDeletionQueue.class).queueFamilyForStartupDeletion(Collections.singleton(worldName));
|
||||
return true;
|
||||
} catch (Throwable queueFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(queueFailure);
|
||||
}
|
||||
return new WorldFamilyDeleteResult(false, false);
|
||||
IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", queueFailure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupStaleTransientWorlds(String worldName) {
|
||||
LinkedHashSet<String> staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot());
|
||||
LinkedHashSet<String> staleWorldNames = collectSafeTransientWorldNames();
|
||||
String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName);
|
||||
if (requestedBaseName != null) {
|
||||
staleWorldNames.add(requestedBaseName);
|
||||
}
|
||||
|
||||
for (String staleWorldName : staleWorldNames) {
|
||||
if (WorldIdentity.resolve(IrisWorldStorage.keyFromName(staleWorldName)).isPresent()) {
|
||||
continue;
|
||||
try {
|
||||
StudioCloseResult cleanupResult = closeWorldCoordinated(
|
||||
null,
|
||||
staleWorldName,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
).get(30L, TimeUnit.SECONDS);
|
||||
if (cleanupResult.failureCause() != null) {
|
||||
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause());
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", unwrapFailure(failure));
|
||||
}
|
||||
|
||||
deleteWorldFamily(staleWorldName, true);
|
||||
}
|
||||
}
|
||||
|
||||
private LinkedHashSet<String> collectSafeTransientWorldNames() {
|
||||
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
|
||||
Path irisNamespace = IrisWorldStorage.levelRoot()
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.resolve("dimensions")
|
||||
.resolve("iris");
|
||||
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return worldNames;
|
||||
}
|
||||
if (Files.isSymbolicLink(irisNamespace) || !Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
IrisLogging.warn("Skipping stale studio cleanup because Iris dimension storage is unsafe: " + irisNamespace);
|
||||
return worldNames;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
|
||||
for (Path child : children) {
|
||||
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
String transientName = TransientWorldCleanupSupport.transientStudioBaseWorldName(
|
||||
child.getFileName().toString());
|
||||
if (transientName != null) {
|
||||
worldNames.add(transientName);
|
||||
}
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
IrisLogging.reportError("Failed to inspect stale studio worlds in \"" + irisNamespace + "\".", failure);
|
||||
}
|
||||
return worldNames;
|
||||
}
|
||||
|
||||
private void updateStage(StudioOpenRequest request, String stage, double progress) {
|
||||
if (request.progressConsumer() != null) {
|
||||
request.progressConsumer().accept(new StudioOpenProgress(progress, stage));
|
||||
@@ -419,37 +610,6 @@ public final class StudioOpenCoordinator {
|
||||
};
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> waitForWorldFamilyUnload(String worldName, long deadline) {
|
||||
if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> deleteWorldFolderAsync(File folder, int attemptsRemaining) {
|
||||
if (folder == null || !folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
IO.delete(folder);
|
||||
if (!folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
if (attemptsRemaining <= 1) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath()));
|
||||
}
|
||||
|
||||
return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> delayFuture(long delayMillis) {
|
||||
long safeDelay = Math.max(0L, delayMillis);
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
}, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
private Throwable unwrapFailure(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
|
||||
@@ -538,7 +698,4 @@ public final class StudioOpenCoordinator {
|
||||
return failureCause == null;
|
||||
}
|
||||
}
|
||||
|
||||
private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) {
|
||||
}
|
||||
}
|
||||
@@ -25,5 +25,7 @@ import java.util.Collection;
|
||||
* Queues world folders for deletion at next startup.
|
||||
*/
|
||||
public interface WorldDeletionQueue {
|
||||
int queueForStartupDeletion(Collection<String> worldNames) throws IOException;
|
||||
int queueExactForStartupDeletion(Collection<String> worldNames) throws IOException;
|
||||
|
||||
int queueFamilyForStartupDeletion(Collection<String> worldNames) throws IOException;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.splash;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
@@ -20,12 +21,12 @@ public final class IrisSplashPackScanner {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
File[] folders = packFolder.listFiles(File::isDirectory);
|
||||
if (folders == null || folders.length == 0) {
|
||||
List<File> folders = PackDirectoryResolver.listVisiblePackDirectories(packFolder);
|
||||
if (folders.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<SplashPackMetadata> packs = new ArrayList<>(folders.length);
|
||||
List<SplashPackMetadata> packs = new ArrayList<>(folders.size());
|
||||
for (File folder : folders) {
|
||||
SplashPackMetadata metadata = read(folder, reporter);
|
||||
if (metadata != null) {
|
||||
|
||||
@@ -30,7 +30,10 @@ import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@@ -38,7 +41,14 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
public final class BulkStructureImporter {
|
||||
public record Report(int total, int imported, int skipped, int failed) {
|
||||
public record Report(int total, int imported, int skipped, int failed, Map<String, String> successfulBundles) {
|
||||
public Report(int total, int imported, int skipped, int failed) {
|
||||
this(total, imported, skipped, failed, Map.of());
|
||||
}
|
||||
|
||||
public Report {
|
||||
successfulBundles = Collections.unmodifiableMap(new TreeMap<>(successfulBundles));
|
||||
}
|
||||
}
|
||||
|
||||
private BulkStructureImporter() {
|
||||
@@ -161,7 +171,7 @@ public final class BulkStructureImporter {
|
||||
templateKeys = enumerateTemplateKeys();
|
||||
} catch (Throwable e) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAILED_ENUMERATE_STRUCTURE_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("e", String.valueOf(e))));
|
||||
return new Report(0, 0, 0, 0);
|
||||
return enumerationFailureReport();
|
||||
}
|
||||
|
||||
List<String> all = new ArrayList<>();
|
||||
@@ -221,24 +231,34 @@ public final class BulkStructureImporter {
|
||||
}
|
||||
|
||||
public static Report importDatapackStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) {
|
||||
KList<String> keys = INMS.get().getStructureKeys();
|
||||
List<String> datapack = new ArrayList<>();
|
||||
for (String k : keys) {
|
||||
if (k != null && !k.isBlank() && !k.startsWith("minecraft:")) {
|
||||
datapack.add(k);
|
||||
}
|
||||
}
|
||||
Collections.sort(datapack);
|
||||
return importDatapackStructures(data, mode, sender, null, null);
|
||||
}
|
||||
|
||||
int total = datapack.size();
|
||||
public static Report importDatapackStructures(
|
||||
IrisData data,
|
||||
StructureImporter.Mode mode,
|
||||
VolmitSender sender,
|
||||
Set<String> allowedStructureKeys,
|
||||
Set<String> allowedTemplateKeys
|
||||
) {
|
||||
KList<String> keys = INMS.get().getStructureKeys();
|
||||
KeySelection structureSelection = selectDatapackKeys(keys, allowedStructureKeys);
|
||||
List<String> datapack = structureSelection.present();
|
||||
|
||||
int structureAttempts = structureSelection.total();
|
||||
int templateAttempts = 0;
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
int failed = structureSelection.missing().size();
|
||||
Map<String, String> successfulBundles = new TreeMap<>();
|
||||
|
||||
if (total == 0) {
|
||||
if (structureAttempts == 0) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_NO_DATAPACK_NON_MINECRAFT_STRUCTURES_ARE_REGISTERED_INGEST_DATAPACK_RESTART_FIRST_THEN));
|
||||
} else {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURES_MODE, MessageArgument.untrusted("total", String.valueOf(total)), MessageArgument.untrusted("mode", String.valueOf(mode))));
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURES_MODE, MessageArgument.untrusted("total", String.valueOf(structureAttempts)), MessageArgument.untrusted("mode", String.valueOf(mode))));
|
||||
for (String missingKey : structureSelection.missing()) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_8, MessageArgument.untrusted("keyString", missingKey), MessageArgument.untrusted("message", "allowed structure key is absent from the live registry")));
|
||||
}
|
||||
for (String keyString : datapack) {
|
||||
NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase());
|
||||
if (nk == null) {
|
||||
@@ -252,6 +272,7 @@ public final class BulkStructureImporter {
|
||||
VillageImporter.Result jigsaw = VillageImporter.importVillage(data, nk, name, mode);
|
||||
if (jigsaw.success()) {
|
||||
imported++;
|
||||
successfulBundles.put(bundleKey(name), keyString);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_JIGSAW_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name))));
|
||||
continue;
|
||||
}
|
||||
@@ -261,6 +282,7 @@ public final class BulkStructureImporter {
|
||||
StructureImporter.Result single = StructureImporter.importStructure(data, nk, name, mode);
|
||||
if (single.success()) {
|
||||
imported++;
|
||||
successfulBundles.put(bundleKey(name), keyString);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SINGLE_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name))));
|
||||
} else if (single.message() != null && single.message().startsWith("Skipped")) {
|
||||
skipped++;
|
||||
@@ -287,50 +309,103 @@ public final class BulkStructureImporter {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> templateKeys = enumerateTemplateKeys();
|
||||
List<String> datapackTemplates = new ArrayList<>();
|
||||
for (String key : templateKeys) {
|
||||
if (key != null && !key.startsWith("minecraft:")) {
|
||||
datapackTemplates.add(key);
|
||||
}
|
||||
if (allowedTemplateKeys == null || !allowedTemplateKeys.isEmpty()) {
|
||||
List<String> templateKeys = List.of();
|
||||
boolean enumerationSucceeded = false;
|
||||
try {
|
||||
templateKeys = enumerateTemplateKeys();
|
||||
enumerationSucceeded = true;
|
||||
} catch (Throwable e) {
|
||||
templateAttempts++;
|
||||
failed++;
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
|
||||
}
|
||||
Collections.sort(datapackTemplates);
|
||||
if (!datapackTemplates.isEmpty()) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURE_TEMPLATES, MessageArgument.untrusted("size", String.valueOf(datapackTemplates.size()))));
|
||||
for (String keyString : datapackTemplates) {
|
||||
NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase());
|
||||
if (nk == null) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
String name = templateNameFor(keyString);
|
||||
try {
|
||||
StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true);
|
||||
if (result.success()) {
|
||||
imported++;
|
||||
} else if (result.message() != null && result.message().startsWith("Skipped")) {
|
||||
skipped++;
|
||||
} else {
|
||||
if (enumerationSucceeded) {
|
||||
KeySelection templateSelection = selectDatapackKeys(templateKeys, allowedTemplateKeys);
|
||||
List<String> datapackTemplates = templateSelection.present();
|
||||
templateAttempts += templateSelection.total();
|
||||
failed += templateSelection.missing().size();
|
||||
for (String missingKey : templateSelection.missing()) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", missingKey), MessageArgument.untrusted("message", "allowed template key is absent from the live server resources")));
|
||||
}
|
||||
if (!datapackTemplates.isEmpty()) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURE_TEMPLATES, MessageArgument.untrusted("size", String.valueOf(datapackTemplates.size()))));
|
||||
for (String keyString : datapackTemplates) {
|
||||
NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase());
|
||||
if (nk == null) {
|
||||
failed++;
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message()))));
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_INVALID_KEY_2, MessageArgument.untrusted("keyString", String.valueOf(keyString))));
|
||||
continue;
|
||||
}
|
||||
String name = templateNameFor(keyString);
|
||||
try {
|
||||
StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true);
|
||||
if (result.success()) {
|
||||
imported++;
|
||||
successfulBundles.put(bundleKey(name), keyString);
|
||||
} else if (result.message() != null && result.message().startsWith("Skipped")) {
|
||||
skipped++;
|
||||
} else {
|
||||
failed++;
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message()))));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
failed++;
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
failed++;
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
|
||||
}
|
||||
|
||||
StructureIndexService.write(data);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_DATAPACK_STRUCTURE_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed))));
|
||||
return new Report(total, imported, skipped, failed);
|
||||
return datapackReport(structureAttempts, templateAttempts, imported, skipped, failed, successfulBundles);
|
||||
}
|
||||
|
||||
static String templateNameFor(String key) {
|
||||
static boolean isAllowedDatapackKey(String key, Set<String> allowedKeys) {
|
||||
return allowedKeys == null ? !key.startsWith("minecraft:") : allowedKeys.contains(key);
|
||||
}
|
||||
|
||||
static KeySelection selectDatapackKeys(Iterable<String> discoveredKeys, Set<String> allowedKeys) {
|
||||
TreeSet<String> discovered = normalizeKeys(discoveredKeys);
|
||||
TreeSet<String> present = new TreeSet<>();
|
||||
TreeSet<String> missing = new TreeSet<>();
|
||||
if (allowedKeys == null) {
|
||||
for (String key : discovered) {
|
||||
if (isAllowedDatapackKey(key, null)) {
|
||||
present.add(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TreeSet<String> allowed = normalizeKeys(allowedKeys);
|
||||
for (String key : allowed) {
|
||||
if (discovered.contains(key)) {
|
||||
present.add(key);
|
||||
} else {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new KeySelection(new ArrayList<>(present), new ArrayList<>(missing));
|
||||
}
|
||||
|
||||
static Report datapackReport(
|
||||
int structureAttempts,
|
||||
int templateAttempts,
|
||||
int imported,
|
||||
int skipped,
|
||||
int failed,
|
||||
Map<String, String> successfulBundles
|
||||
) {
|
||||
return new Report(structureAttempts + templateAttempts, imported, skipped, failed, successfulBundles);
|
||||
}
|
||||
|
||||
static Report enumerationFailureReport() {
|
||||
return new Report(1, 0, 0, 1);
|
||||
}
|
||||
|
||||
public static String templateNameFor(String key) {
|
||||
int colon = key.indexOf(':');
|
||||
String namespace = colon >= 0 ? key.substring(0, colon) : "minecraft";
|
||||
String path = colon >= 0 ? key.substring(colon + 1) : key;
|
||||
@@ -427,6 +502,21 @@ public final class BulkStructureImporter {
|
||||
}
|
||||
}
|
||||
|
||||
private static TreeSet<String> normalizeKeys(Iterable<String> keys) {
|
||||
TreeSet<String> normalized = new TreeSet<>();
|
||||
for (String key : keys) {
|
||||
if (key == null || key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
normalized.add(key.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String bundleKey(String name) {
|
||||
return "iris:" + name;
|
||||
}
|
||||
|
||||
private static String identifierNamespace(Object location) {
|
||||
try {
|
||||
Method m = location.getClass().getMethod("getNamespace");
|
||||
@@ -468,4 +558,15 @@ public final class BulkStructureImporter {
|
||||
}
|
||||
throw new NoSuchMethodException(method + " on " + target.getClass().getName());
|
||||
}
|
||||
|
||||
record KeySelection(List<String> present, List<String> missing) {
|
||||
KeySelection {
|
||||
present = List.copyOf(present);
|
||||
missing = List.copyOf(missing);
|
||||
}
|
||||
|
||||
int total() {
|
||||
return present.size() + missing.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,20 +19,27 @@
|
||||
package art.arcane.iris.core.structure;
|
||||
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.WorldCreatorCompat;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.core.tools.TreePlausibilizer;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.WorldType;
|
||||
@@ -41,16 +48,27 @@ import org.bukkit.block.Block;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
@@ -59,13 +77,18 @@ public final class FeatureImporter {
|
||||
public record Report(int total, int imported, int skipped, int failed) {
|
||||
}
|
||||
|
||||
private static final String SCRATCH_WORLD_NAME = "iris_vanilla_import";
|
||||
private static final String SCRATCH_WORLD_PREFIX = "iris-feature-import-";
|
||||
private static final int SCRATCH_ID_ATTEMPTS = 32;
|
||||
private static final long SCRATCH_CREATE_TIMEOUT_SECONDS = 120L;
|
||||
private static final long SCRATCH_TEARDOWN_TIMEOUT_SECONDS = 120L;
|
||||
private static final int CAPTURE_RADIUS = 16;
|
||||
private static final int CAPTURE_HEIGHT = 40;
|
||||
private static final int CELL_STRIDE = 48;
|
||||
private static final int CELL_COLUMNS = 16;
|
||||
private static final int PLACE_ATTEMPTS = 6;
|
||||
private static final long REGION_TIMEOUT_SECONDS = 30L;
|
||||
private static final Set<String> RESERVED_SCRATCH_NAMES = ConcurrentHashMap.newKeySet();
|
||||
private static final ConcurrentHashMap<String, ScratchWorldState> ACTIVE_SCRATCH_WORLDS = new ConcurrentHashMap<>();
|
||||
|
||||
private FeatureImporter() {
|
||||
}
|
||||
@@ -297,22 +320,123 @@ public final class FeatureImporter {
|
||||
}
|
||||
|
||||
static World createScratchWorld(VolmitSender sender) {
|
||||
LifecycleOperationCoordinator.Lease lease = null;
|
||||
ScratchWorldReservation reservation = null;
|
||||
World createdWorld = null;
|
||||
try {
|
||||
World existing = WorldIdentity.resolve(IrisWorldStorage.keyFromName(SCRATCH_WORLD_NAME)).orElse(null);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(IrisWorldStorage.keyFromName(SCRATCH_WORLD_NAME))
|
||||
reservation = reserveScratchWorld();
|
||||
lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
reservation.key().toString()
|
||||
);
|
||||
requireUnusedReservation(reservation);
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(reservation.key())
|
||||
.environment(World.Environment.NORMAL)
|
||||
.type(WorldType.FLAT)
|
||||
.generateStructures(false);
|
||||
return J.sfut(() -> INMS.get().createWorldAsync(creator))
|
||||
createdWorld = J.sfut(() -> INMS.get().createWorldAsync(creator))
|
||||
.thenCompose(Function.identity())
|
||||
.get();
|
||||
.get(SCRATCH_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (createdWorld == null) {
|
||||
throw new IllegalStateException("Scratch world creation returned no world.");
|
||||
}
|
||||
if (!reservation.key().equals(WorldIdentity.key(createdWorld))) {
|
||||
throw new IllegalStateException("Scratch world creation returned an unexpected world identity.");
|
||||
}
|
||||
Path createdFolder = createdWorld.getWorldFolder().toPath().toAbsolutePath().normalize();
|
||||
if (!reservation.folder().equals(createdFolder)) {
|
||||
throw new IllegalStateException("Scratch world creation returned an unexpected storage folder.");
|
||||
}
|
||||
|
||||
String identity = WorldIdentity.serialize(createdWorld);
|
||||
ScratchWorldState state = new ScratchWorldState(reservation, lease);
|
||||
if (ACTIVE_SCRATCH_WORLDS.putIfAbsent(identity, state) != null) {
|
||||
throw new IllegalStateException("Scratch world identity is already active: " + identity);
|
||||
}
|
||||
lease = null;
|
||||
reservation = null;
|
||||
return createdWorld;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.FEATURE_IMPORTER_COULD_NOT_CREATE_SCRATCH_WORLD_FEATURE_IMPORT_SKIPPING_TREE_OBJECT_PASS, MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
|
||||
Throwable failure = unwrapFailure(e);
|
||||
IrisLogging.reportError(failure);
|
||||
boolean settled = createdWorld != null
|
||||
&& reservation != null
|
||||
&& settleFailedScratchCreation(createdWorld, reservation, failure);
|
||||
if (reservation != null && (!settled || !matchesReservation(createdWorld, reservation))) {
|
||||
queueScratchCleanup(reservation.name(), failure);
|
||||
}
|
||||
if (containsTimeout(failure)) {
|
||||
ServerConfigurator.restart("Feature-import scratch world creation timed out for \""
|
||||
+ (reservation == null ? "unreserved" : reservation.name()) + "\".");
|
||||
}
|
||||
if (sender != null) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.FEATURE_IMPORTER_COULD_NOT_CREATE_SCRATCH_WORLD_FEATURE_IMPORT_SKIPPING_TREE_OBJECT_PASS, MessageArgument.untrusted("error", String.valueOf(failure.getMessage()))));
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (reservation != null) {
|
||||
RESERVED_SCRATCH_NAMES.remove(reservation.name());
|
||||
}
|
||||
if (lease != null) {
|
||||
lease.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean settleFailedScratchCreation(
|
||||
World world,
|
||||
ScratchWorldReservation expectedReservation,
|
||||
Throwable creationFailure
|
||||
) {
|
||||
ScratchWorldReservation actualReservation = reservationForCreatedScratch(world, creationFailure);
|
||||
if (actualReservation == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PlatformChunkGenerator generator = null;
|
||||
try {
|
||||
generator = IrisToolbelt.access(world);
|
||||
} catch (Throwable accessFailure) {
|
||||
creationFailure.addSuppressed(accessFailure);
|
||||
}
|
||||
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
PlatformChunkGenerator capturedGenerator = generator;
|
||||
CompletableFuture<Void> sequence = sequenceScratchTeardown(
|
||||
() -> WorldLifecycleService.get().unloadAsync(world, false),
|
||||
() -> WorldIdentity.resolve(actualReservation.key()).isPresent(),
|
||||
() -> capturedGenerator == null
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: capturedGenerator.closeAsync(),
|
||||
() -> {
|
||||
try {
|
||||
deleteScratchFolder(actualReservation, world);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (IOException deletionFailure) {
|
||||
return CompletableFuture.failedFuture(deletionFailure);
|
||||
}
|
||||
},
|
||||
actualReservation.name(),
|
||||
terminalTimeout::get);
|
||||
try {
|
||||
guardScratchTeardown(sequence, terminalTimeout, actualReservation.name()).join();
|
||||
if (!expectedReservation.name().equals(actualReservation.name())) {
|
||||
queueScratchCleanup(expectedReservation.name(), creationFailure);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable cleanupFailure) {
|
||||
Throwable cause = unwrapFailure(cleanupFailure);
|
||||
creationFailure.addSuppressed(cause);
|
||||
queueScratchCleanup(actualReservation.name(), creationFailure);
|
||||
if (!expectedReservation.name().equals(actualReservation.name())) {
|
||||
queueScratchCleanup(expectedReservation.name(), creationFailure);
|
||||
}
|
||||
if (containsTimeout(cause) || WorldIdentity.resolve(actualReservation.key()).isPresent()) {
|
||||
ServerConfigurator.restart("Feature-import scratch cleanup did not reach a safe boundary for \""
|
||||
+ actualReservation.name() + "\".");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,21 +444,281 @@ public final class FeatureImporter {
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
File folder = world.getWorldFolder();
|
||||
|
||||
String identity = WorldIdentity.serialize(world);
|
||||
ScratchWorldState state = ACTIVE_SCRATCH_WORLDS.remove(identity);
|
||||
if (state == null) {
|
||||
IrisLogging.warn("Refusing to destroy unreserved feature-import scratch world \"" + world.getName() + "\".");
|
||||
return;
|
||||
}
|
||||
|
||||
Throwable failure = null;
|
||||
try {
|
||||
J.sfut(() -> {
|
||||
Bukkit.unloadWorld(world, false);
|
||||
return Boolean.TRUE;
|
||||
}).get();
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
CompletableFuture<Void> sequence = sequenceScratchTeardown(
|
||||
() -> WorldLifecycleService.get().unloadAsync(world, false),
|
||||
() -> WorldIdentity.resolve(state.reservation().key()).isPresent(),
|
||||
() -> generator == null ? CompletableFuture.completedFuture(null) : generator.closeAsync(),
|
||||
() -> {
|
||||
try {
|
||||
deleteScratchFolder(state, world);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (IOException deletionFailure) {
|
||||
return CompletableFuture.failedFuture(deletionFailure);
|
||||
}
|
||||
},
|
||||
state.reservation().name(),
|
||||
terminalTimeout::get
|
||||
);
|
||||
guardScratchTeardown(sequence, terminalTimeout, state.reservation().name()).join();
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
failure = unwrapFailure(e);
|
||||
queueScratchCleanup(state.reservation().name(), failure);
|
||||
if (containsTimeout(failure) || WorldIdentity.resolve(state.reservation().key()).isPresent()) {
|
||||
ServerConfigurator.restart("Feature-import scratch cleanup did not reach a safe boundary for \""
|
||||
+ state.reservation().name() + "\".");
|
||||
}
|
||||
IrisLogging.reportError("Feature-import scratch world cleanup failed for \""
|
||||
+ state.reservation().name() + "\"; startup cleanup was queued.", failure);
|
||||
} finally {
|
||||
RESERVED_SCRATCH_NAMES.remove(state.reservation().name());
|
||||
state.lease().close();
|
||||
}
|
||||
if (failure != null && sender != null) {
|
||||
sender.sendMessage("Feature-import scratch world cleanup was deferred until the next startup: "
|
||||
+ failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static ScratchWorldReservation reserveScratchWorld() throws IOException {
|
||||
for (int attempt = 0; attempt < SCRATCH_ID_ATTEMPTS; attempt++) {
|
||||
String name = SCRATCH_WORLD_PREFIX + UUID.randomUUID();
|
||||
if (!RESERVED_SCRATCH_NAMES.add(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(name);
|
||||
Path folder = IrisWorldStorage.requireSafeManagedDimensionRoot(key)
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
ScratchWorldReservation reservation = new ScratchWorldReservation(name, key, folder);
|
||||
if (isReservationUnused(reservation)) {
|
||||
return reservation;
|
||||
}
|
||||
RESERVED_SCRATCH_NAMES.remove(name);
|
||||
}
|
||||
throw new IOException("Could not reserve a collision-free feature-import scratch world identity.");
|
||||
}
|
||||
|
||||
private static void requireUnusedReservation(ScratchWorldReservation reservation) throws IOException {
|
||||
if (!RESERVED_SCRATCH_NAMES.contains(reservation.name()) || !isReservationUnused(reservation)) {
|
||||
throw new IOException("Feature-import scratch world reservation was claimed before creation: "
|
||||
+ reservation.name());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isReservationUnused(ScratchWorldReservation reservation) {
|
||||
return WorldIdentity.resolve(reservation.key()).isEmpty()
|
||||
&& !Files.exists(reservation.folder(), LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isSymbolicLink(reservation.folder());
|
||||
}
|
||||
|
||||
private static boolean matchesReservation(World world, ScratchWorldReservation reservation) {
|
||||
if (world == null || reservation == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (folder != null && folder.exists()) {
|
||||
IO.delete(folder);
|
||||
return reservation.key().equals(WorldIdentity.key(world))
|
||||
&& reservation.folder().equals(world.getWorldFolder().toPath().toAbsolutePath().normalize());
|
||||
} catch (Throwable failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ScratchWorldReservation reservationForCreatedScratch(World world, Throwable failure) {
|
||||
try {
|
||||
NamespacedKey key = WorldIdentity.key(world);
|
||||
String name = IrisWorldStorage.logicalName(key);
|
||||
if (!isReservedScratchWorldName(name)) {
|
||||
failure.addSuppressed(new IllegalStateException(
|
||||
"Refusing cleanup for unexpected scratch world identity \"" + key + "\"."));
|
||||
return null;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
Path folder = IrisWorldStorage.requireSafeManagedDimensionRoot(key)
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
return new ScratchWorldReservation(name, key, folder);
|
||||
} catch (Throwable identityFailure) {
|
||||
failure.addSuppressed(identityFailure);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteScratchFolder(ScratchWorldState state, World world) throws IOException {
|
||||
deleteScratchFolder(state.reservation(), world);
|
||||
}
|
||||
|
||||
private static void deleteScratchFolder(ScratchWorldReservation reservation, World world) throws IOException {
|
||||
Path actualFolder = world.getWorldFolder().toPath().toAbsolutePath().normalize();
|
||||
if (!reservation.folder().equals(actualFolder)) {
|
||||
throw new IOException("Scratch world storage changed during import; refusing deletion.");
|
||||
}
|
||||
if (WorldIdentity.resolve(reservation.key()).isPresent()) {
|
||||
throw new IOException("Scratch world is still loaded; refusing deletion.");
|
||||
}
|
||||
AtomicDirectoryPublisher.deleteTree(reservation.folder());
|
||||
}
|
||||
|
||||
private static void queueScratchCleanup(String worldName, Throwable failure) {
|
||||
try {
|
||||
WorldDeletionQueue queue = IrisServices.getOrNull(WorldDeletionQueue.class);
|
||||
if (queue == null) {
|
||||
throw new IllegalStateException("World deletion queue is unavailable.");
|
||||
}
|
||||
queue.queueExactForStartupDeletion(List.of(worldName));
|
||||
} catch (Throwable queueFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(queueFailure);
|
||||
}
|
||||
IrisLogging.reportError("Failed to queue startup cleanup for feature-import scratch world \""
|
||||
+ worldName + "\".", queueFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable unwrapFailure(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
|
||||
if (cursor.getCause() == null) {
|
||||
break;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
static CompletableFuture<Void> sequenceScratchTeardown(
|
||||
Supplier<CompletableFuture<Boolean>> unload,
|
||||
BooleanSupplier stillLoaded,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolder,
|
||||
String worldName
|
||||
) {
|
||||
return sequenceScratchTeardown(
|
||||
unload,
|
||||
stillLoaded,
|
||||
closeGenerator,
|
||||
deleteFolder,
|
||||
worldName,
|
||||
() -> false);
|
||||
}
|
||||
|
||||
static CompletableFuture<Void> sequenceScratchTeardown(
|
||||
Supplier<CompletableFuture<Boolean>> unload,
|
||||
BooleanSupplier stillLoaded,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolder,
|
||||
String worldName,
|
||||
BooleanSupplier terminalTimeout
|
||||
) {
|
||||
CompletableFuture<Boolean> unloadFuture;
|
||||
try {
|
||||
unloadFuture = unload.get();
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
if (unloadFuture == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Scratch world unload returned no completion future."));
|
||||
}
|
||||
|
||||
return unloadFuture.thenCompose(unloaded -> {
|
||||
if (terminalTimeout.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"Scratch world cleanup stopped after its terminal timeout."));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(unloaded) || stillLoaded.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Scratch world unload was not confirmed for \"" + worldName + "\"."));
|
||||
}
|
||||
return invokeScratchPhase(closeGenerator, "generator close");
|
||||
}).thenCompose(ignored -> {
|
||||
if (terminalTimeout.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"Scratch world cleanup stopped after its terminal timeout."));
|
||||
}
|
||||
return invokeScratchPhase(deleteFolder, "folder deletion");
|
||||
});
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> guardScratchTeardown(
|
||||
CompletableFuture<Void> source,
|
||||
AtomicBoolean terminalTimeout,
|
||||
String worldName
|
||||
) {
|
||||
CompletableFuture<Void> guarded = new CompletableFuture<>();
|
||||
AtomicBoolean settled = new AtomicBoolean(false);
|
||||
source.whenComplete((ignored, throwable) -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (throwable == null) {
|
||||
guarded.complete(null);
|
||||
} else {
|
||||
guarded.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
CompletableFuture.delayedExecutor(SCRATCH_TEARDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
terminalTimeout.set(true);
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"Scratch world cleanup did not settle within " + SCRATCH_TEARDOWN_TIMEOUT_SECONDS
|
||||
+ " seconds for \"" + worldName + "\".");
|
||||
ServerConfigurator.restart("Feature-import scratch cleanup timed out for \"" + worldName + "\".");
|
||||
guarded.completeExceptionally(timeout);
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> invokeScratchPhase(
|
||||
Supplier<CompletableFuture<Void>> phase,
|
||||
String phaseName
|
||||
) {
|
||||
try {
|
||||
CompletableFuture<Void> future = phase.get();
|
||||
if (future == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Scratch world " + phaseName + " returned no completion future."));
|
||||
}
|
||||
return future;
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsTimeout(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor != null) {
|
||||
if (cursor instanceof TimeoutException) {
|
||||
return true;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isReservedScratchWorldName(String worldName) {
|
||||
if (worldName == null || !worldName.startsWith(SCRATCH_WORLD_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String identifier = worldName.substring(SCRATCH_WORLD_PREFIX.length());
|
||||
return UUID.fromString(identifier).toString().equals(identifier);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,4 +727,13 @@ public final class FeatureImporter {
|
||||
|
||||
private record CaptureResult(boolean placed, IrisObject object) {
|
||||
}
|
||||
|
||||
private record ScratchWorldReservation(String name, NamespacedKey key, Path folder) {
|
||||
}
|
||||
|
||||
private record ScratchWorldState(
|
||||
ScratchWorldReservation reservation,
|
||||
LifecycleOperationCoordinator.Lease lease
|
||||
) {
|
||||
}
|
||||
}
|
||||
+12
@@ -64,6 +64,11 @@ interface StructureFileOperations {
|
||||
}
|
||||
}
|
||||
|
||||
default List<Path> list(Path root, int maxEntries) throws IOException {
|
||||
List<Path> entries = list(root);
|
||||
return entries.size() <= maxEntries ? entries : entries.subList(0, maxEntries + 1);
|
||||
}
|
||||
|
||||
default void forceFile(Path path) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
@@ -145,4 +150,11 @@ final class NioStructureFileOperations implements StructureFileOperations {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Path> list(Path root, int maxEntries) throws IOException {
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
return stream.limit(maxEntries + 1L).sorted().toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -41,6 +41,7 @@ record StructureTransactionJournal(
|
||||
static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
static final String FILE_NAME = "transaction.json";
|
||||
static final String NEXT_FILE_NAME = "transaction.json.next";
|
||||
private static final int MAX_TARGETS = 100_000;
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final Pattern MANIFEST_PATH = Pattern.compile(
|
||||
@@ -57,6 +58,9 @@ record StructureTransactionJournal(
|
||||
if (targets.isEmpty()) {
|
||||
throw new IllegalArgumentException("Structure transaction must contain at least one target");
|
||||
}
|
||||
if (targets.size() > MAX_TARGETS) {
|
||||
throw new IllegalArgumentException("Structure transaction contains too many targets");
|
||||
}
|
||||
ArrayList<Target> orderedTargets = new ArrayList<>(targets);
|
||||
orderedTargets.sort(Comparator.comparing(Target::relativePath));
|
||||
Set<String> portablePaths = new HashSet<>(orderedTargets.size());
|
||||
|
||||
+746
-5
@@ -18,7 +18,15 @@
|
||||
|
||||
package art.arcane.iris.core.structure.authoring;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
@@ -39,11 +47,19 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class StructureTransactionWriter {
|
||||
private static final String STAGING_RELATIVE_PATH = ".iris/structure-staging";
|
||||
private static final String PROCESS_LOCK_RELATIVE_PATH = ".iris/structure-authoring.lock";
|
||||
private static final String RECOVERY_CLAIM_FILE = "external-coordinator.json";
|
||||
private static final int RECOVERY_CLAIM_SCHEMA = 1;
|
||||
private static final int MAX_RECOVERY_CLAIM_BYTES = 64 * 1024;
|
||||
private static final int MAX_COORDINATOR_JOURNAL_BYTES = 4 * 1024 * 1024;
|
||||
private static final int MAX_STRUCTURE_STATE_BYTES = 64 * 1024 * 1024;
|
||||
private static final int MAX_RECOVERY_TRANSACTIONS = 1_024;
|
||||
private static final ConcurrentMap<Path, ReentrantLock> ROOT_LOCKS = new ConcurrentHashMap<>();
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final Path packRoot;
|
||||
private final StructureFileOperations files;
|
||||
@@ -88,6 +104,389 @@ public final class StructureTransactionWriter {
|
||||
return write(bundle, new StructureWriteOptions(mode, false));
|
||||
}
|
||||
|
||||
public Optional<StructureSource> ownedSource(StructureKey key) throws IOException {
|
||||
Objects.requireNonNull(key, "key");
|
||||
rootLock.lock();
|
||||
try (ProcessLock ignored = acquireProcessLock()) {
|
||||
StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked();
|
||||
if (!recovery.successful()) {
|
||||
throw recoveryFailure(recovery);
|
||||
}
|
||||
Path manifestPath = ownershipManifestPath(key);
|
||||
if (!files.exists(manifestPath)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!files.isRegularFile(manifestPath)) {
|
||||
throw new IOException("Structure ownership manifest is not a regular file: " + manifestPath);
|
||||
}
|
||||
StructureOwnershipManifest manifest;
|
||||
try {
|
||||
manifest = StructureOwnershipManifest.fromJson(readBoundedBytes(
|
||||
manifestPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Structure ownership manifest"
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid structure ownership manifest at " + manifestPath, e);
|
||||
}
|
||||
if (!manifest.structure().equals(key)) {
|
||||
throw new IOException("Structure ownership manifest belongs to " + manifest.structure());
|
||||
}
|
||||
return Optional.of(manifest.source());
|
||||
} finally {
|
||||
rootLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean removeOwned(StructureKey key, StructureSource.Kind sourceKind, StructureKey sourceKey) throws IOException {
|
||||
OwnedRemoval request = new OwnedRemoval(key, sourceKind, sourceKey);
|
||||
try (PreparedRemoval removal = prepareOwnedRemovals(List.of(request))) {
|
||||
boolean changed = removal.changed();
|
||||
removal.markCommitted();
|
||||
removal.finishCommit();
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedRemoval prepareOwnedRemovals(List<OwnedRemoval> removals) throws IOException {
|
||||
return prepareOwnedRemovals(removals, false);
|
||||
}
|
||||
|
||||
public PreparedRemoval prepareMatchingOwnedRemovals(List<OwnedRemoval> removals) throws IOException {
|
||||
return prepareOwnedRemovals(removals, true);
|
||||
}
|
||||
|
||||
private PreparedRemoval prepareOwnedRemovals(
|
||||
List<OwnedRemoval> removals,
|
||||
boolean skipOwnershipMismatches
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(removals, "removals");
|
||||
List<OwnedRemoval> requests = List.copyOf(removals);
|
||||
rootLock.lock();
|
||||
ProcessLock processLock = null;
|
||||
Path transactionRoot = null;
|
||||
LinkedHashMap<Path, Path> backups = new LinkedHashMap<>();
|
||||
try {
|
||||
processLock = acquireProcessLock();
|
||||
StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked();
|
||||
if (!recovery.successful()) {
|
||||
throw recoveryFailure(recovery);
|
||||
}
|
||||
RemovalPlan plan = buildRemovalPlan(requests, skipOwnershipMismatches);
|
||||
if (plan.targets().isEmpty()) {
|
||||
return new PreparedRemoval(null, null, backups, processLock, false);
|
||||
}
|
||||
|
||||
UUID transactionId = UUID.randomUUID();
|
||||
transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize();
|
||||
Path backupRoot = transactionRoot.resolve("backup");
|
||||
StructureTransactionJournal journal = StructureTransactionJournal.prepared(
|
||||
transactionId,
|
||||
plan.targets()
|
||||
);
|
||||
files.createDirectories(backupRoot);
|
||||
writeJournal(transactionRoot, journal);
|
||||
files.forceDirectory(transactionRoot);
|
||||
files.forceDirectory(stagingRoot());
|
||||
verifyTargetSnapshot(journal);
|
||||
backupTargets(journal, backupRoot, backups);
|
||||
return new PreparedRemoval(transactionRoot, journal, backups, processLock, true);
|
||||
} catch (IOException | RuntimeException preparationFailure) {
|
||||
Optional<Throwable> rollbackFailure = rollback(backups, List.of());
|
||||
if (rollbackFailure.isPresent()) {
|
||||
preparationFailure.addSuppressed(rollbackFailure.get());
|
||||
} else if (transactionRoot != null) {
|
||||
cleanupAfterFailure(transactionRoot, preparationFailure);
|
||||
}
|
||||
if (processLock != null) {
|
||||
try {
|
||||
processLock.close();
|
||||
} catch (IOException closeFailure) {
|
||||
preparationFailure.addSuppressed(closeFailure);
|
||||
}
|
||||
}
|
||||
rootLock.unlock();
|
||||
if (preparationFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw new IOException("Failed preparing owned structure removal", preparationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
public record OwnedRemoval(
|
||||
StructureKey key,
|
||||
StructureSource.Kind sourceKind,
|
||||
StructureKey sourceKey
|
||||
) {
|
||||
public OwnedRemoval {
|
||||
Objects.requireNonNull(key, "key");
|
||||
Objects.requireNonNull(sourceKind, "sourceKind");
|
||||
Objects.requireNonNull(sourceKey, "sourceKey");
|
||||
}
|
||||
}
|
||||
|
||||
public record PreparedRemovalToken(Path packRoot, UUID transactionId) {
|
||||
public PreparedRemovalToken {
|
||||
packRoot = canonicalPackRoot(Objects.requireNonNull(packRoot, "packRoot"));
|
||||
Objects.requireNonNull(transactionId, "transactionId");
|
||||
}
|
||||
}
|
||||
|
||||
public record RecoveryOwner(Path transactionRoot, UUID transactionId, UUID claimId) {
|
||||
public RecoveryOwner {
|
||||
transactionRoot = validateRecoveryOwnerRoot(Objects.requireNonNull(transactionRoot, "transactionRoot"));
|
||||
Objects.requireNonNull(transactionId, "transactionId");
|
||||
Objects.requireNonNull(claimId, "claimId");
|
||||
if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) {
|
||||
throw new IllegalArgumentException("Recovery owner transaction id does not match its directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean verifyRecoveryOwner(
|
||||
PreparedRemovalToken token,
|
||||
RecoveryOwner owner,
|
||||
boolean verifyPreparedState
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(token, "token");
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
if (!packRoot.equals(token.packRoot())) {
|
||||
throw new IOException("Prepared removal token belongs to a different pack root");
|
||||
}
|
||||
Path transactionRoot = stagingRoot().resolve(token.transactionId().toString()).normalize();
|
||||
if (!files.exists(transactionRoot)) {
|
||||
return false;
|
||||
}
|
||||
verifyRecoveryClaim(transactionRoot, token.transactionId(), owner);
|
||||
if (verifyPreparedState) {
|
||||
verifyPreparedRemovalAuthority(transactionRoot, token.transactionId());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void resolvePreparedRemoval(PreparedRemovalToken token, boolean commit) throws IOException {
|
||||
resolvePreparedRemoval(token, null, commit);
|
||||
}
|
||||
|
||||
public void resolvePreparedRemoval(
|
||||
PreparedRemovalToken token,
|
||||
RecoveryOwner owner,
|
||||
boolean commit
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(token, "token");
|
||||
if (!packRoot.equals(token.packRoot())) {
|
||||
throw new IOException("Prepared removal token belongs to a different pack root");
|
||||
}
|
||||
rootLock.lock();
|
||||
try (ProcessLock ignored = acquireProcessLock()) {
|
||||
Path transactionRoot = stagingRoot().resolve(token.transactionId().toString()).normalize();
|
||||
if (!files.exists(transactionRoot)) {
|
||||
return;
|
||||
}
|
||||
if (owner != null) {
|
||||
verifyRecoveryClaim(transactionRoot, token.transactionId(), owner);
|
||||
}
|
||||
Path journalPath = recoveryJournalPath(transactionRoot);
|
||||
if (journalPath == null || !files.isRegularFile(journalPath)) {
|
||||
throw new IOException("Missing prepared removal journal at " + transactionRoot);
|
||||
}
|
||||
StructureTransactionJournal journal;
|
||||
try {
|
||||
journal = StructureTransactionJournal.fromJson(readBoundedBytes(
|
||||
journalPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Prepared removal journal"
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid prepared removal journal at " + journalPath, e);
|
||||
}
|
||||
if (!journal.transactionId().equals(token.transactionId())) {
|
||||
throw new IOException("Prepared removal journal id does not match " + token.transactionId());
|
||||
}
|
||||
if (commit) {
|
||||
verifyCommittedTransaction(journal);
|
||||
} else {
|
||||
restorePreparedTransaction(transactionRoot, journal);
|
||||
}
|
||||
cleanupTransaction(transactionRoot);
|
||||
} finally {
|
||||
rootLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public final class PreparedRemoval implements AutoCloseable {
|
||||
private final Path transactionRoot;
|
||||
private final StructureTransactionJournal journal;
|
||||
private final LinkedHashMap<Path, Path> backups;
|
||||
private final ProcessLock processLock;
|
||||
private final boolean changed;
|
||||
private boolean committed;
|
||||
private boolean closed;
|
||||
|
||||
private PreparedRemoval(
|
||||
Path transactionRoot,
|
||||
StructureTransactionJournal journal,
|
||||
LinkedHashMap<Path, Path> backups,
|
||||
ProcessLock processLock,
|
||||
boolean changed
|
||||
) {
|
||||
this.transactionRoot = transactionRoot;
|
||||
this.journal = journal;
|
||||
this.backups = backups;
|
||||
this.processLock = processLock;
|
||||
this.changed = changed;
|
||||
}
|
||||
|
||||
public boolean changed() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
public Optional<PreparedRemovalToken> recoveryToken() {
|
||||
if (transactionRoot == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
UUID transactionId = UUID.fromString(Objects.requireNonNull(
|
||||
transactionRoot.getFileName(),
|
||||
"prepared removal transaction directory"
|
||||
).toString());
|
||||
return Optional.of(new PreparedRemovalToken(packRoot, transactionId));
|
||||
}
|
||||
|
||||
public void claimRecoveryOwner(RecoveryOwner owner) throws IOException {
|
||||
requireOpen();
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
if (transactionRoot == null) {
|
||||
return;
|
||||
}
|
||||
RecoveryClaim claim = new RecoveryClaim(
|
||||
RECOVERY_CLAIM_SCHEMA,
|
||||
owner.transactionRoot().toString(),
|
||||
owner.transactionId(),
|
||||
owner.claimId()
|
||||
);
|
||||
byte[] claimContent = GSON.toJson(claim).getBytes(StandardCharsets.UTF_8);
|
||||
if (claimContent.length > MAX_RECOVERY_CLAIM_BYTES) {
|
||||
throw new IOException("External recovery claim exceeds " + MAX_RECOVERY_CLAIM_BYTES + " bytes");
|
||||
}
|
||||
Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE);
|
||||
files.writeNew(claimPath, claimContent);
|
||||
files.forceFile(claimPath);
|
||||
files.forceDirectory(transactionRoot);
|
||||
}
|
||||
|
||||
public void markCommitted() throws IOException {
|
||||
requireOpen();
|
||||
if (transactionRoot == null) {
|
||||
committed = true;
|
||||
return;
|
||||
}
|
||||
boolean committedJournalWritten = false;
|
||||
try {
|
||||
writeJournal(transactionRoot, journal.committed());
|
||||
committedJournalWritten = true;
|
||||
files.forceDirectory(transactionRoot);
|
||||
committed = true;
|
||||
} catch (IOException | RuntimeException commitFailure) {
|
||||
if (committedJournalWritten || isCommittedJournal(transactionRoot, commitFailure)) {
|
||||
committed = true;
|
||||
}
|
||||
if (commitFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw new IOException("Failed marking owned structure removal committed", commitFailure);
|
||||
}
|
||||
}
|
||||
|
||||
public void finishCommit() throws IOException {
|
||||
requireOpen();
|
||||
if (!committed) {
|
||||
throw new IllegalStateException("Owned structure removal has not been marked committed");
|
||||
}
|
||||
IOException failure = null;
|
||||
if (transactionRoot != null) {
|
||||
try {
|
||||
cleanupTransaction(transactionRoot);
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
failure = new IOException("Owned structure removal committed but cleanup remains at "
|
||||
+ transactionRoot, cleanupFailure);
|
||||
}
|
||||
}
|
||||
IOException releaseFailure = release();
|
||||
if (failure == null) {
|
||||
failure = releaseFailure;
|
||||
} else if (releaseFailure != null) {
|
||||
failure.addSuppressed(releaseFailure);
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public void rollback() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
if (transactionRoot != null) {
|
||||
Optional<Throwable> rollbackFailure = StructureTransactionWriter.this.rollback(backups, List.of());
|
||||
if (rollbackFailure.isPresent()) {
|
||||
failure = new IOException("Failed restoring prepared owned structure removal at "
|
||||
+ transactionRoot, rollbackFailure.get());
|
||||
} else {
|
||||
try {
|
||||
cleanupTransaction(transactionRoot);
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
failure = new IOException("Restored owned structure removal but cleanup remains at "
|
||||
+ transactionRoot, cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
IOException releaseFailure = release();
|
||||
if (failure == null) {
|
||||
failure = releaseFailure;
|
||||
} else if (releaseFailure != null) {
|
||||
failure.addSuppressed(releaseFailure);
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public void leaveForRecovery() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException releaseFailure = release();
|
||||
if (releaseFailure != null) {
|
||||
throw releaseFailure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
rollback();
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Owned structure removal transaction is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private IOException release() {
|
||||
IOException failure = null;
|
||||
try {
|
||||
processLock.close();
|
||||
} catch (IOException e) {
|
||||
failure = e;
|
||||
} finally {
|
||||
closed = true;
|
||||
rootLock.unlock();
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
public StructureWriteResult preview(StructureResourceBundle bundle, StructureWriteMode mode) {
|
||||
return write(bundle, StructureWriteOptions.preview(mode));
|
||||
}
|
||||
@@ -134,6 +533,81 @@ public final class StructureTransactionWriter {
|
||||
return commit(plan);
|
||||
}
|
||||
|
||||
private RemovalPlan buildRemovalPlan(
|
||||
List<OwnedRemoval> removals,
|
||||
boolean skipOwnershipMismatches
|
||||
) throws IOException {
|
||||
TreeMap<String, StructureTransactionJournal.Target> targets = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (OwnedRemoval removal : removals) {
|
||||
Path manifestPath = ownershipManifestPath(removal.key());
|
||||
if (!files.exists(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
if (!files.isRegularFile(manifestPath)) {
|
||||
throw new IOException("Structure ownership manifest is not a regular file: " + manifestPath);
|
||||
}
|
||||
byte[] manifestContent = readBoundedBytes(
|
||||
manifestPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Structure ownership manifest"
|
||||
);
|
||||
StructureOwnershipManifest manifest;
|
||||
try {
|
||||
manifest = StructureOwnershipManifest.fromJson(manifestContent);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid structure ownership manifest at " + manifestPath, e);
|
||||
}
|
||||
if (!manifest.structure().equals(removal.key())) {
|
||||
throw new IOException("Structure ownership manifest belongs to " + manifest.structure());
|
||||
}
|
||||
if (manifest.source().kind() != removal.sourceKind()
|
||||
|| !manifest.source().key().equals(removal.sourceKey())) {
|
||||
if (skipOwnershipMismatches) {
|
||||
continue;
|
||||
}
|
||||
throw new IOException("Structure '" + removal.key() + "' is owned by source "
|
||||
+ manifest.source().key() + " (" + manifest.source().kind() + "), not "
|
||||
+ removal.sourceKey() + " (" + removal.sourceKind() + ")");
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
|
||||
Path target = resolveTarget(resource.getKey());
|
||||
verifyRemovalTarget(target, resource.getKey(), resource.getValue());
|
||||
addRemovalTarget(targets, resource.getKey(), resource.getValue());
|
||||
}
|
||||
addRemovalTarget(targets, manifest.relativePath(), StructureHash.sha256(manifestContent));
|
||||
}
|
||||
return new RemovalPlan(List.copyOf(targets.values()));
|
||||
}
|
||||
|
||||
private void addRemovalTarget(
|
||||
Map<String, StructureTransactionJournal.Target> targets,
|
||||
String relativePath,
|
||||
String contentHash
|
||||
) throws IOException {
|
||||
StructureTransactionJournal.Target target = new StructureTransactionJournal.Target(
|
||||
relativePath,
|
||||
true,
|
||||
contentHash,
|
||||
""
|
||||
);
|
||||
StructureTransactionJournal.Target existing = targets.putIfAbsent(relativePath, target);
|
||||
if (existing != null && (!existing.relativePath().equals(relativePath)
|
||||
|| !existing.originalHash().equals(contentHash))) {
|
||||
throw new IOException("Owned structure removals overlap at incompatible resource path " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyRemovalTarget(Path target, String relativePath, String expectedHash) throws IOException {
|
||||
if (!files.isRegularFile(target)) {
|
||||
throw new IOException("Owned structure resource is missing or not a regular file: " + relativePath);
|
||||
}
|
||||
String actualHash = files.sha256(target);
|
||||
if (!expectedHash.equals(actualHash)) {
|
||||
throw new IOException("Owned structure resource was modified and will not be removed: " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
private StructureRecoveryResult recoverIncompleteTransactionsLocked() {
|
||||
Path stagingRoot = stagingRoot();
|
||||
ArrayList<StructureRecoveryResult.Failure> failures = new ArrayList<>();
|
||||
@@ -155,7 +629,7 @@ public final class StructureTransactionWriter {
|
||||
|
||||
List<Path> transactionRoots;
|
||||
try {
|
||||
transactionRoots = files.list(stagingRoot);
|
||||
transactionRoots = files.list(stagingRoot, MAX_RECOVERY_TRANSACTIONS);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return new StructureRecoveryResult(
|
||||
0,
|
||||
@@ -164,6 +638,16 @@ public final class StructureTransactionWriter {
|
||||
List.of(new StructureRecoveryResult.Failure(stagingRoot, e))
|
||||
);
|
||||
}
|
||||
if (transactionRoots.size() > MAX_RECOVERY_TRANSACTIONS) {
|
||||
IOException failure = new IOException("Structure recovery transaction count exceeds "
|
||||
+ MAX_RECOVERY_TRANSACTIONS);
|
||||
return new StructureRecoveryResult(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
List.of(new StructureRecoveryResult.Failure(stagingRoot, failure))
|
||||
);
|
||||
}
|
||||
|
||||
for (Path transactionRoot : transactionRoots) {
|
||||
try {
|
||||
@@ -213,7 +697,11 @@ public final class StructureTransactionWriter {
|
||||
|
||||
StructureTransactionJournal journal;
|
||||
try {
|
||||
journal = StructureTransactionJournal.fromJson(files.readAllBytes(journalPath));
|
||||
journal = StructureTransactionJournal.fromJson(readBoundedBytes(
|
||||
journalPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Structure transaction journal"
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid structure transaction journal at " + journalPath, e);
|
||||
}
|
||||
@@ -223,6 +711,10 @@ public final class StructureTransactionWriter {
|
||||
throw new IOException("Structure transaction journal id " + journal.transactionId()
|
||||
+ " does not match directory " + directoryName);
|
||||
}
|
||||
if (hasActiveRecoveryOwner(normalizedRoot, journal.transactionId())) {
|
||||
throw new IOException("Structure transaction recovery is owned by an active datapack coordinator: "
|
||||
+ normalizedRoot);
|
||||
}
|
||||
|
||||
return switch (journal.phase()) {
|
||||
case PREPARED -> {
|
||||
@@ -254,6 +746,193 @@ public final class StructureTransactionWriter {
|
||||
return nextJournalPath;
|
||||
}
|
||||
|
||||
private boolean hasActiveRecoveryOwner(Path transactionRoot, UUID transactionId) throws IOException {
|
||||
Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE);
|
||||
if (!files.exists(claimPath)) {
|
||||
return false;
|
||||
}
|
||||
RecoveryClaim claim = readRecoveryClaim(claimPath);
|
||||
RecoveryOwner owner;
|
||||
try {
|
||||
owner = new RecoveryOwner(
|
||||
Path.of(claim.coordinatorTransactionRoot()),
|
||||
claim.coordinatorTransactionId(),
|
||||
claim.claimId()
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid external recovery claim at " + claimPath, e);
|
||||
}
|
||||
return coordinatorReferencesClaim(
|
||||
new PreparedRemovalToken(packRoot, transactionId),
|
||||
owner
|
||||
);
|
||||
}
|
||||
|
||||
private void verifyRecoveryClaim(
|
||||
Path transactionRoot,
|
||||
UUID transactionId,
|
||||
RecoveryOwner owner
|
||||
) throws IOException {
|
||||
Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE);
|
||||
RecoveryClaim claim = readRecoveryClaim(claimPath);
|
||||
if (!Objects.equals(claim.coordinatorTransactionRoot(), owner.transactionRoot().toString())
|
||||
|| !Objects.equals(claim.coordinatorTransactionId(), owner.transactionId())
|
||||
|| !Objects.equals(claim.claimId(), owner.claimId())) {
|
||||
throw new IOException("Prepared removal recovery claim does not match its datapack coordinator");
|
||||
}
|
||||
if (!coordinatorReferencesClaim(new PreparedRemovalToken(packRoot, transactionId), owner)) {
|
||||
throw new IOException("Prepared removal recovery coordinator was not durably published");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyPreparedRemovalAuthority(Path transactionRoot, UUID transactionId) throws IOException {
|
||||
Path journalPath = recoveryJournalPath(transactionRoot);
|
||||
if (journalPath == null || !files.isRegularFile(journalPath)) {
|
||||
throw new IOException("Missing prepared removal journal at " + transactionRoot);
|
||||
}
|
||||
StructureTransactionJournal journal;
|
||||
try {
|
||||
journal = StructureTransactionJournal.fromJson(readBoundedBytes(
|
||||
journalPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Prepared removal journal"
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid prepared removal journal at " + journalPath, e);
|
||||
}
|
||||
if (!journal.transactionId().equals(transactionId)
|
||||
|| journal.phase() != StructureTransactionJournal.Phase.PREPARED) {
|
||||
throw new IOException("Prepared removal journal does not match its datapack coordinator");
|
||||
}
|
||||
boolean ownershipManifestPresent = false;
|
||||
Path backupRoot = transactionRoot.resolve("backup").normalize();
|
||||
for (StructureTransactionJournal.Target state : journal.targets()) {
|
||||
if (!state.hadOriginal() || !state.replacementHash().isEmpty()) {
|
||||
throw new IOException("External coordinator claimed a non-removal structure transaction");
|
||||
}
|
||||
ownershipManifestPresent |= state.relativePath().startsWith(".iris/structure-manifests/");
|
||||
Path target = resolveTarget(state.relativePath());
|
||||
if (files.exists(target)) {
|
||||
throw new IOException("Prepared removal target reappeared before coordinator recovery: "
|
||||
+ state.relativePath());
|
||||
}
|
||||
Path backup = resolveTransactionPath(backupRoot, state.relativePath());
|
||||
if (!files.isRegularFile(backup)) {
|
||||
throw new IOException("Prepared removal backup is missing or not a regular file: "
|
||||
+ state.relativePath());
|
||||
}
|
||||
verifyOriginalContent(backup, state);
|
||||
}
|
||||
if (!ownershipManifestPresent) {
|
||||
throw new IOException("External coordinator removal has no structure ownership manifest");
|
||||
}
|
||||
}
|
||||
|
||||
private RecoveryClaim readRecoveryClaim(Path claimPath) throws IOException {
|
||||
if (!files.isRegularFile(claimPath)) {
|
||||
throw new IOException("Invalid external recovery claim " + claimPath);
|
||||
}
|
||||
byte[] content = readBoundedBytes(
|
||||
claimPath,
|
||||
MAX_RECOVERY_CLAIM_BYTES,
|
||||
"External recovery claim"
|
||||
);
|
||||
try {
|
||||
RecoveryClaim claim = GSON.fromJson(
|
||||
new String(content, StandardCharsets.UTF_8),
|
||||
RecoveryClaim.class
|
||||
);
|
||||
if (claim == null || claim.schemaVersion() != RECOVERY_CLAIM_SCHEMA
|
||||
|| claim.coordinatorTransactionRoot() == null
|
||||
|| claim.coordinatorTransactionId() == null || claim.claimId() == null) {
|
||||
throw new IOException("Incomplete external recovery claim " + claimPath);
|
||||
}
|
||||
return claim;
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid external recovery claim " + claimPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean coordinatorReferencesClaim(
|
||||
PreparedRemovalToken token,
|
||||
RecoveryOwner owner
|
||||
) throws IOException {
|
||||
Path ownerRoot = owner.transactionRoot();
|
||||
Path ownerParent = Objects.requireNonNull(ownerRoot.getParent(), "recovery owner parent");
|
||||
if (Files.isSymbolicLink(ownerParent) || Files.isSymbolicLink(ownerRoot)) {
|
||||
throw new IOException("External recovery owner path contains a symbolic link: " + ownerRoot);
|
||||
}
|
||||
if (!Files.exists(ownerRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isDirectory(ownerRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("External recovery owner is not a directory: " + ownerRoot);
|
||||
}
|
||||
Path committed = ownerRoot.resolve("journal.json");
|
||||
Path next = ownerRoot.resolve("journal.next.json");
|
||||
Path journalPath;
|
||||
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
|
||||
journalPath = committed;
|
||||
} else if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
|
||||
journalPath = next;
|
||||
} else {
|
||||
try (Stream<Path> contents = Files.list(ownerRoot)) {
|
||||
if (contents.findAny().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
throw new IOException("External recovery owner has no transaction journal: " + ownerRoot);
|
||||
}
|
||||
if (Files.isSymbolicLink(journalPath)
|
||||
|| !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid external recovery owner journal " + journalPath);
|
||||
}
|
||||
JsonObject journal;
|
||||
try {
|
||||
journal = JsonParser.parseString(new String(
|
||||
readBoundedBytes(
|
||||
journalPath,
|
||||
MAX_COORDINATOR_JOURNAL_BYTES,
|
||||
"External recovery owner journal"
|
||||
),
|
||||
StandardCharsets.UTF_8
|
||||
)).getAsJsonObject();
|
||||
} catch (RuntimeException e) {
|
||||
if (journalPath.equals(next) && isOnlyOwnerArtifact(ownerRoot, next)) {
|
||||
return false;
|
||||
}
|
||||
throw new IOException("Invalid external recovery owner journal " + journalPath, e);
|
||||
}
|
||||
if (!journal.has("schemaVersion") || journal.get("schemaVersion").getAsInt() != 2
|
||||
|| !journal.has("transactionId")
|
||||
|| !owner.transactionId().toString().equals(journal.get("transactionId").getAsString())
|
||||
|| !journal.has("operation") || !"REMOVE".equals(journal.get("operation").getAsString())
|
||||
|| !journal.has("editables") || !journal.get("editables").isJsonArray()) {
|
||||
throw new IOException("External recovery owner journal does not match its claim");
|
||||
}
|
||||
JsonArray editables = journal.getAsJsonArray("editables");
|
||||
for (JsonElement element : editables) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject editable = element.getAsJsonObject();
|
||||
if (editable.has("packRoot") && editable.has("transactionId") && editable.has("claimId")
|
||||
&& packRoot.toString().equals(editable.get("packRoot").getAsString())
|
||||
&& token.transactionId().toString().equals(editable.get("transactionId").getAsString())
|
||||
&& owner.claimId().toString().equals(editable.get("claimId").getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw new IOException("External recovery owner journal does not contain its claimed structure transaction");
|
||||
}
|
||||
|
||||
private boolean isOnlyOwnerArtifact(Path ownerRoot, Path artifact) throws IOException {
|
||||
try (Stream<Path> contents = Files.list(ownerRoot)) {
|
||||
List<Path> entries = contents.limit(2).toList();
|
||||
return entries.size() == 1 && Objects.equals(entries.getFirst(), artifact);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyCommittedTransaction(StructureTransactionJournal journal) throws IOException {
|
||||
for (StructureTransactionJournal.Target state : journal.targets()) {
|
||||
Path target = resolveTarget(state.relativePath());
|
||||
@@ -408,7 +1087,11 @@ public final class StructureTransactionWriter {
|
||||
|
||||
StructureOwnershipManifest previousManifest;
|
||||
try {
|
||||
previousManifest = StructureOwnershipManifest.fromJson(files.readAllBytes(manifestPath));
|
||||
previousManifest = StructureOwnershipManifest.fromJson(readBoundedBytes(
|
||||
manifestPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Structure ownership manifest"
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
conflicts.add(StructureWriteResult.Conflict.invalidManifest(manifestRelativePath, e.toString()));
|
||||
return createPlan(
|
||||
@@ -610,10 +1293,15 @@ public final class StructureTransactionWriter {
|
||||
Path transactionRoot,
|
||||
StructureTransactionJournal journal
|
||||
) throws IOException {
|
||||
byte[] content = journal.toJson();
|
||||
if (content.length > MAX_STRUCTURE_STATE_BYTES) {
|
||||
throw new IOException("Structure transaction journal exceeds "
|
||||
+ MAX_STRUCTURE_STATE_BYTES + " bytes");
|
||||
}
|
||||
Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME);
|
||||
Path nextJournalPath = transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME);
|
||||
files.deleteIfExists(nextJournalPath);
|
||||
files.writeNew(nextJournalPath, journal.toJson());
|
||||
files.writeNew(nextJournalPath, content);
|
||||
files.forceFile(nextJournalPath);
|
||||
files.move(nextJournalPath, journalPath);
|
||||
}
|
||||
@@ -624,7 +1312,11 @@ public final class StructureTransactionWriter {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return StructureTransactionJournal.fromJson(files.readAllBytes(journalPath)).phase()
|
||||
return StructureTransactionJournal.fromJson(readBoundedBytes(
|
||||
journalPath,
|
||||
MAX_STRUCTURE_STATE_BYTES,
|
||||
"Structure transaction journal"
|
||||
)).phase()
|
||||
== StructureTransactionJournal.Phase.COMMITTED;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
commitPhaseFailure.addSuppressed(e);
|
||||
@@ -909,6 +1601,21 @@ public final class StructureTransactionWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] readBoundedBytes(Path path, int maxBytes, String purpose) throws IOException {
|
||||
byte[] content;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
path,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
)) {
|
||||
content = input.readNBytes(maxBytes + 1);
|
||||
}
|
||||
if (content.length > maxBytes) {
|
||||
throw new IOException(purpose + " exceeds " + maxBytes + " bytes");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
private Path resolveWithin(Path root, String relativePath, String errorPrefix) {
|
||||
Path target = root.resolve(relativePath).normalize();
|
||||
if (!target.startsWith(root) || target.equals(root)) {
|
||||
@@ -941,6 +1648,37 @@ public final class StructureTransactionWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private static Path validateRecoveryOwnerRoot(Path root) {
|
||||
Path normalized = root.toAbsolutePath().normalize();
|
||||
Path parent = Objects.requireNonNull(normalized.getParent(), "recovery owner parent");
|
||||
Path parentName = Objects.requireNonNull(parent.getFileName(), "recovery owner directory");
|
||||
Path transactionName = Objects.requireNonNull(normalized.getFileName(), "recovery owner transaction");
|
||||
if (!".iris-datapack-transactions".equals(parentName.toString())) {
|
||||
throw new IllegalArgumentException("Recovery owner is outside the datapack transaction directory");
|
||||
}
|
||||
try {
|
||||
UUID.fromString(transactionName.toString());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("Recovery owner has an invalid transaction directory", e);
|
||||
}
|
||||
try {
|
||||
if (Files.isSymbolicLink(parent) || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("Recovery owner transaction parent is unsafe");
|
||||
}
|
||||
return parent.toRealPath().resolve(transactionName.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Unable to resolve recovery owner transaction parent", e);
|
||||
}
|
||||
}
|
||||
|
||||
private record RecoveryClaim(
|
||||
int schemaVersion,
|
||||
String coordinatorTransactionRoot,
|
||||
UUID coordinatorTransactionId,
|
||||
UUID claimId
|
||||
) {
|
||||
}
|
||||
|
||||
private enum RecoveryOutcome {
|
||||
RESTORED_PREPARED,
|
||||
CLEANED_COMMITTED,
|
||||
@@ -956,6 +1694,9 @@ public final class StructureTransactionWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private record RemovalPlan(List<StructureTransactionJournal.Target> targets) {
|
||||
}
|
||||
|
||||
private record ProcessLock(FileChannel channel, FileLock lock) implements AutoCloseable {
|
||||
private ProcessLock {
|
||||
Objects.requireNonNull(channel, "channel");
|
||||
|
||||
@@ -26,11 +26,13 @@ import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
|
||||
import art.arcane.iris.core.DatapackInstallResult;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.WorldCreatorCompat;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
@@ -38,6 +40,8 @@ import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.RuntimeProgressMessages;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
@@ -49,6 +53,7 @@ import art.arcane.volmlib.util.hud.HudSlotClaim;
|
||||
import art.arcane.volmlib.util.hud.HudSlotRequest;
|
||||
import art.arcane.volmlib.util.hud.HudSurface;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
@@ -56,19 +61,24 @@ import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.boss.BarColor;
|
||||
import org.bukkit.boss.BarStyle;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
@@ -84,6 +94,9 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
|
||||
@Data
|
||||
@Accessors(fluent = true, chain = true)
|
||||
public class IrisCreator {
|
||||
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
|
||||
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
|
||||
|
||||
/**
|
||||
* Specify an area to pregenerate during creation
|
||||
*/
|
||||
@@ -118,39 +131,13 @@ public class IrisCreator {
|
||||
private BiConsumer<Double, String> studioProgressConsumer;
|
||||
|
||||
public static boolean removeFromBukkitYml(String name) throws IOException {
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
ConfigurationSection section = yml.getConfigurationSection("worlds");
|
||||
if (section == null) {
|
||||
return false;
|
||||
}
|
||||
section.set(name, null);
|
||||
if (section.getValues(false).keySet().stream().noneMatch(k -> section.get(k) != null)) {
|
||||
yml.set("worlds", null);
|
||||
}
|
||||
yml.save(BUKKIT_YML);
|
||||
return true;
|
||||
return BukkitWorldConfiguration.remove(BUKKIT_YML, name);
|
||||
}
|
||||
|
||||
public static int removeTransientStudioWorldsFromBukkitYml() throws IOException {
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
ConfigurationSection section = yml.getConfigurationSection("worlds");
|
||||
if (section == null) {
|
||||
return 0;
|
||||
}
|
||||
int removed = 0;
|
||||
for (String name : new java.util.ArrayList<>(section.getKeys(false))) {
|
||||
if (TransientWorldCleanupSupport.isTransientStudioWorldName(name)) {
|
||||
section.set(name, null);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
if (section.getKeys(false).isEmpty()) {
|
||||
yml.set("worlds", null);
|
||||
}
|
||||
yml.save(BUKKIT_YML);
|
||||
}
|
||||
return removed;
|
||||
return BukkitWorldConfiguration.removeMatching(
|
||||
BUKKIT_YML,
|
||||
TransientWorldCleanupSupport::isTransientStudioWorldName);
|
||||
}
|
||||
public static boolean worldLoaded(){
|
||||
return true;
|
||||
@@ -167,117 +154,182 @@ public class IrisCreator {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
throw new IrisException("You cannot invoke create() on the main thread.");
|
||||
}
|
||||
name = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name));
|
||||
|
||||
long createStart = System.currentTimeMillis();
|
||||
reportStudioProgress(0.02D, "resolve_dimension");
|
||||
reportStudioProgress(0.08D, "resolve_dimension");
|
||||
IrisDimension d = IrisToolbelt.getDimension(dimension());
|
||||
|
||||
if (d == null) {
|
||||
throw new IrisException("Dimension cannot be found null for id " + dimension());
|
||||
}
|
||||
|
||||
if (sender == null)
|
||||
sender = BukkitPlatform.console();
|
||||
|
||||
reportStudioProgress(0.16D, "prepare_world_pack");
|
||||
if (!studio() || benchmark) {
|
||||
d = IrisServices.get(StudioSVC.class).installIntoWorld(sender, d, IrisWorldStorage.dimensionRoot(name()));
|
||||
if (d == null) {
|
||||
throw new IrisException("Failed to install dimension pack for " + dimension());
|
||||
}
|
||||
dimension = d.getLoadKey();
|
||||
}
|
||||
if (studio()) {
|
||||
IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen());
|
||||
IrisLogging.debug("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT)
|
||||
+ ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer()));
|
||||
}
|
||||
|
||||
reportStudioProgress(0.28D, "install_datapacks");
|
||||
AtomicDouble pp = new AtomicDouble(0);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
WorldCreator wc = new IrisWorldCreator()
|
||||
.dimension(d)
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.studio(studio)
|
||||
.create();
|
||||
if (!studio()) {
|
||||
IrisWorlds.get().put(WorldCreatorCompat.keyOf(wc).toString(), dimension());
|
||||
}
|
||||
ServerConfigurator.installDataPacksIfChanged(!studio());
|
||||
IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)");
|
||||
reportStudioProgress(0.40D, "install_datapacks");
|
||||
|
||||
PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator();
|
||||
if (access == null) throw new IrisException("Access is null. Something bad happened.");
|
||||
HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer()
|
||||
? openLoaderClaim("iris:world-create")
|
||||
: null;
|
||||
AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim);
|
||||
|
||||
|
||||
World world;
|
||||
reportStudioProgress(0.46D, "create_world");
|
||||
long nmsStart = System.currentTimeMillis();
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE;
|
||||
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind);
|
||||
world = J.sfut(() -> INMS.get().createWorldAsync(wc, request))
|
||||
.thenCompose(Function.identity())
|
||||
.get();
|
||||
IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms");
|
||||
} catch (Throwable e) {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IrisException(e.getMessage(), e);
|
||||
}
|
||||
name = IrisWorldStorage.logicalName(worldKey);
|
||||
|
||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||
LifecycleOperationCoordinator.Lease worldLease = null;
|
||||
try {
|
||||
reportStudioProgress(0.02D, "resolve_dimension");
|
||||
IrisDimension resolvedDimension = IrisToolbelt.getDimension(dimension());
|
||||
if (resolvedDimension == null) {
|
||||
throw new IrisException("Dimension cannot be found for id " + dimension());
|
||||
}
|
||||
worldLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
worldKey.toString());
|
||||
return createReserved(worldKey, resolvedDimension);
|
||||
} catch (LifecycleOperationCoordinator.BusyException e) {
|
||||
throw new IrisException(e.getMessage(), e);
|
||||
} finally {
|
||||
if (worldLease != null) {
|
||||
worldLease.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException {
|
||||
long createStart = System.currentTimeMillis();
|
||||
File dimensionRoot;
|
||||
try {
|
||||
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IrisException(e.getMessage(), e);
|
||||
}
|
||||
if (Files.exists(dimensionRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) {
|
||||
throw new IrisException("World \"" + name + "\" already exists or is loaded.");
|
||||
}
|
||||
if (sender == null) {
|
||||
sender = BukkitPlatform.console();
|
||||
}
|
||||
|
||||
World world = null;
|
||||
boolean bukkitRegistered = false;
|
||||
try {
|
||||
reportStudioProgress(0.08D, "resolve_dimension");
|
||||
reportStudioProgress(0.16D, "prepare_world_pack");
|
||||
DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true);
|
||||
if (!datapackResult.succeeded()) {
|
||||
throw new IrisException("Failed to compile datapacks for dimension \"" + dimension() + "\".");
|
||||
}
|
||||
if (datapackResult.restartRequired() || !ServerConfigurator.verifyDataPackInstalled(resolvedDimension)) {
|
||||
ServerConfigurator.restart();
|
||||
throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded yet. "
|
||||
+ "Iris queued a restart; run the command again after the server returns.");
|
||||
}
|
||||
|
||||
IrisDimension installedDimension = resolvedDimension;
|
||||
if (!studio() || benchmark) {
|
||||
installedDimension = IrisServices.get(StudioSVC.class)
|
||||
.installIntoWorld(sender, resolvedDimension, dimensionRoot);
|
||||
if (installedDimension == null) {
|
||||
throw new IrisException("Failed to install dimension pack for " + dimension());
|
||||
}
|
||||
dimension = installedDimension.getLoadKey();
|
||||
}
|
||||
if (studio()) {
|
||||
IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen());
|
||||
IrisLogging.debug("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT)
|
||||
+ ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer()));
|
||||
}
|
||||
|
||||
reportStudioProgress(0.28D, "install_datapacks");
|
||||
AtomicDouble pp = new AtomicDouble(0);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
WorldCreator wc = new IrisWorldCreator()
|
||||
.dimension(installedDimension)
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.studio(studio)
|
||||
.create();
|
||||
IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)");
|
||||
reportStudioProgress(0.40D, "install_datapacks");
|
||||
|
||||
PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator();
|
||||
if (access == null) {
|
||||
throw new IrisException("Access is null. Something bad happened.");
|
||||
}
|
||||
HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer()
|
||||
? openLoaderClaim("iris:world-create")
|
||||
: null;
|
||||
AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim);
|
||||
|
||||
reportStudioProgress(0.46D, "create_world");
|
||||
long nmsStart = System.currentTimeMillis();
|
||||
try {
|
||||
WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE;
|
||||
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind);
|
||||
world = J.sfut(() -> INMS.get().createWorldAsync(wc, request))
|
||||
.thenCompose(Function.identity())
|
||||
.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms");
|
||||
} catch (Throwable e) {
|
||||
done.set(true);
|
||||
cancelRepeatingTask(createProgressTask);
|
||||
releaseLoaderClaim(createClaim, "iris:world-create");
|
||||
if (e instanceof TimeoutException) {
|
||||
ServerConfigurator.restart("World creation timed out for \"" + name + "\".");
|
||||
}
|
||||
if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) {
|
||||
throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e);
|
||||
}
|
||||
if (containsMissingDimensionTypes(e)) {
|
||||
ServerConfigurator.restart();
|
||||
throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded on this server yet. "
|
||||
+ "Iris queued a restart; run the command again after the server returns.", e);
|
||||
}
|
||||
throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e);
|
||||
}
|
||||
|
||||
done.set(true);
|
||||
cancelRepeatingTask(createProgressTask);
|
||||
releaseLoaderClaim(createClaim, "iris:world-create");
|
||||
if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) {
|
||||
throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e);
|
||||
reportStudioProgress(0.86D, "create_world");
|
||||
|
||||
if (!studio && !benchmark) {
|
||||
BukkitWorldConfiguration.register(BUKKIT_YML, name, dimension, seed);
|
||||
bukkitRegistered = true;
|
||||
World createdWorld = world;
|
||||
CompletableFuture<Void> multiverseRegistration = J.sfut(
|
||||
() -> IrisServices.get(MultiverseCoreLink.class).updateWorld(createdWorld, dimension)
|
||||
);
|
||||
if (multiverseRegistration == null) {
|
||||
throw new IrisException("Failed to schedule Multiverse registration for world \"" + name + "\".");
|
||||
}
|
||||
try {
|
||||
multiverseRegistration.get(30L, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
ServerConfigurator.restart("Multiverse registration timed out for \"" + name + "\".");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (containsMissingDimensionTypes(e)) {
|
||||
throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded on this server yet. "
|
||||
+ "Iris installed its datapack files - restart the server, then run the command again.", e);
|
||||
awaitSenderTeleport(world);
|
||||
|
||||
if (pregen != null) {
|
||||
CompletableFuture<Boolean> ff = new CompletableFuture<>();
|
||||
IrisToolbelt.pregenerate(pregen, access)
|
||||
.onProgress(pp::set)
|
||||
.whenDone(() -> ff.complete(true));
|
||||
|
||||
AtomicBoolean dx = new AtomicBoolean(false);
|
||||
HudSlotClaim pregenClaim = sender.isPlayer() ? openLoaderClaim("iris:pregen") : null;
|
||||
AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx, pregenClaim);
|
||||
try {
|
||||
ff.get();
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
releaseLoaderClaim(pregenClaim, "iris:pregen");
|
||||
} catch (Throwable e) {
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
releaseLoaderClaim(pregenClaim, "iris:pregen");
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e);
|
||||
}
|
||||
|
||||
done.set(true);
|
||||
cancelRepeatingTask(createProgressTask);
|
||||
releaseLoaderClaim(createClaim, "iris:world-create");
|
||||
reportStudioProgress(0.86D, "create_world");
|
||||
|
||||
if (!studio && !benchmark) {
|
||||
addToBukkitYml();
|
||||
J.s(() -> IrisServices.get(MultiverseCoreLink.class).updateWorld(world, dimension));
|
||||
}
|
||||
scheduleSenderTeleport(world);
|
||||
|
||||
if (pregen != null) {
|
||||
CompletableFuture<Boolean> ff = new CompletableFuture<>();
|
||||
|
||||
IrisToolbelt.pregenerate(pregen, access)
|
||||
.onProgress(pp::set)
|
||||
.whenDone(() -> ff.complete(true));
|
||||
|
||||
AtomicBoolean dx = new AtomicBoolean(false);
|
||||
HudSlotClaim pregenClaim = sender.isPlayer() ? openLoaderClaim("iris:pregen") : null;
|
||||
AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx, pregenClaim);
|
||||
try {
|
||||
ff.get();
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
releaseLoaderClaim(pregenClaim, "iris:pregen");
|
||||
} catch (Throwable e) {
|
||||
dx.set(true);
|
||||
cancelRepeatingTask(pregenProgressTask);
|
||||
releaseLoaderClaim(pregenClaim, "iris:pregen");
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
return world;
|
||||
} catch (Throwable failure) {
|
||||
rollbackWorldCreation(worldKey, world, dimensionRoot, bukkitRegistered, failure);
|
||||
if (failure instanceof IrisException irisException) {
|
||||
throw irisException;
|
||||
}
|
||||
throw new IrisException("Failed to create world \"" + name + "\".", failure);
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
static Player createTeleportTarget(VolmitSender sender, boolean studio, boolean benchmark) {
|
||||
@@ -314,7 +366,7 @@ public class IrisCreator {
|
||||
});
|
||||
}
|
||||
|
||||
private void scheduleSenderTeleport(World world) {
|
||||
private void awaitSenderTeleport(World world) {
|
||||
Player player = createTeleportTarget(sender, studio, benchmark);
|
||||
if (player == null) {
|
||||
return;
|
||||
@@ -328,16 +380,18 @@ public class IrisCreator {
|
||||
return;
|
||||
}
|
||||
|
||||
teleportFuture.whenComplete((success, throwable) -> {
|
||||
if (throwable != null) {
|
||||
reportSenderTeleportFailure(player, world, throwable);
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
try {
|
||||
Boolean teleported = teleportFuture.get(60L, TimeUnit.SECONDS);
|
||||
if (!Boolean.TRUE.equals(teleported)) {
|
||||
reportSenderTeleportFailure(player, world, new IllegalStateException(
|
||||
"The runtime teleport operation returned false for player \"" + player.getName() + "\"."));
|
||||
}
|
||||
});
|
||||
} catch (TimeoutException e) {
|
||||
ServerConfigurator.restart("World entry teleport timed out for \"" + world.getName() + "\".");
|
||||
reportSenderTeleportFailure(player, world, e);
|
||||
} catch (Throwable e) {
|
||||
reportSenderTeleportFailure(player, world, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void reportSenderTeleportFailure(Player player, World world, Throwable throwable) {
|
||||
@@ -562,20 +616,115 @@ public class IrisCreator {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void addToBukkitYml() {
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
String gen = "Iris:" + dimension;
|
||||
ConfigurationSection section = yml.contains("worlds") ? yml.getConfigurationSection("worlds") : yml.createSection("worlds");
|
||||
if (!section.contains(name)) {
|
||||
section.createSection(name).set("generator", gen);
|
||||
private void rollbackWorldCreation(
|
||||
NamespacedKey worldKey,
|
||||
World createdWorld,
|
||||
File dimensionRoot,
|
||||
boolean bukkitRegistered,
|
||||
Throwable failure
|
||||
) {
|
||||
World activeWorld = createdWorld == null ? WorldIdentity.resolve(worldKey).orElse(null) : createdWorld;
|
||||
boolean safeToDelete = activeWorld != null || !containsTimeout(failure);
|
||||
if (activeWorld != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true);
|
||||
try {
|
||||
yml.save(BUKKIT_YML);
|
||||
IrisLogging.info("Registered \"" + name + "\" in bukkit.yml");
|
||||
} catch (IOException e) {
|
||||
IrisLogging.error("Failed to update bukkit.yml!");
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(activeWorld);
|
||||
boolean evacuated = Boolean.TRUE.equals(IrisToolbelt.evacuateAsync(activeWorld)
|
||||
.get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (!evacuated) {
|
||||
safeToDelete = false;
|
||||
failure.addSuppressed(new IllegalStateException(
|
||||
"Rollback could not evacuate world \"" + name + "\"."));
|
||||
}
|
||||
boolean unloaded = safeToDelete && Boolean.TRUE.equals(WorldLifecycleService.get()
|
||||
.unloadAsync(activeWorld, true)
|
||||
.get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (!unloaded) {
|
||||
safeToDelete = false;
|
||||
failure.addSuppressed(new IllegalStateException("Rollback could not unload world \"" + name + "\"."));
|
||||
}
|
||||
if (safeToDelete && generator != null) {
|
||||
generator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (Throwable rollbackFailure) {
|
||||
Throwable cause = unwrapFailure(rollbackFailure);
|
||||
failure.addSuppressed(cause);
|
||||
safeToDelete = false;
|
||||
if (cause instanceof TimeoutException) {
|
||||
ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\".");
|
||||
}
|
||||
} finally {
|
||||
IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback");
|
||||
}
|
||||
}
|
||||
|
||||
if (bukkitRegistered) {
|
||||
try {
|
||||
CompletableFuture<Boolean> multiverseRemoval = J.sfut(
|
||||
() -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig(name)
|
||||
);
|
||||
if (multiverseRemoval == null) {
|
||||
throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\".");
|
||||
}
|
||||
try {
|
||||
multiverseRemoval.get(30L, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
ServerConfigurator.restart("Multiverse rollback timed out for \"" + name + "\".");
|
||||
throw e;
|
||||
}
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
try {
|
||||
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
IrisWorlds.get().remove(worldKey.toString());
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
if (!safeToDelete) {
|
||||
queueRollbackDeletion(name, failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(dimensionRoot.toPath());
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
queueRollbackDeletion(name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void queueRollbackDeletion(String worldName, Throwable failure) {
|
||||
try {
|
||||
IrisServices.get(WorldDeletionQueue.class).queueExactForStartupDeletion(List.of(worldName));
|
||||
} catch (Throwable queueFailure) {
|
||||
failure.addSuppressed(queueFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsTimeout(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor != null) {
|
||||
if (cursor instanceof TimeoutException) {
|
||||
return true;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Throwable unwrapFailure(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
|
||||
if (cursor.getCause() == null) {
|
||||
break;
|
||||
}
|
||||
cursor = cursor.getCause();
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
@@ -24,6 +25,7 @@ import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
|
||||
public class IrisPackBenchmarking {
|
||||
@@ -106,11 +108,28 @@ public class IrisPackBenchmarking {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
J.s(() -> {
|
||||
J.sfut(() -> {
|
||||
World world = benchmarkWorld();
|
||||
if (world == null) return;
|
||||
if (world == null) {
|
||||
return null;
|
||||
}
|
||||
IrisToolbelt.evacuate(world);
|
||||
WorldLifecycleService.get().unload(world, true);
|
||||
return world;
|
||||
}).thenCompose(world -> {
|
||||
if (world == null) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
CompletableFuture<Void> closeFuture = generator == null
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: generator.closeAsync();
|
||||
return closeFuture.thenCompose(unused -> WorldLifecycleService.get().unloadAsync(world, true));
|
||||
}).whenComplete((unloaded, throwable) -> {
|
||||
if (throwable != null) {
|
||||
IrisLogging.reportError("Failed to close the benchmark world.", throwable);
|
||||
} else if (!Boolean.TRUE.equals(unloaded)) {
|
||||
IrisLogging.error("Failed to unload the benchmark world.");
|
||||
}
|
||||
});
|
||||
|
||||
stopwatch.end();
|
||||
|
||||
@@ -32,6 +32,7 @@ import art.arcane.iris.core.pregenerator.PregenPerformanceProfile;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
import art.arcane.iris.core.pregenerator.cache.PregenCache;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.pregenerator.methods.CachedPregenMethod;
|
||||
import art.arcane.iris.core.pregenerator.methods.HybridPregenMethod;
|
||||
@@ -57,6 +58,7 @@ import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@@ -94,23 +96,27 @@ public class IrisToolbelt {
|
||||
}
|
||||
|
||||
File packsFolder = IrisPlatforms.get().dataFolder("packs");
|
||||
File pack = new File(packsFolder, reference.pack());
|
||||
if (!pack.exists()) {
|
||||
File pack = PackDirectoryResolver.resolveExisting(packsFolder, reference.pack());
|
||||
if (pack == null) {
|
||||
File found = findCaseInsensitivePack(packsFolder, reference.pack());
|
||||
if (found != null) {
|
||||
pack = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pack.exists()) {
|
||||
if (pack == null) {
|
||||
IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), reference.pack(), false);
|
||||
File found = findCaseInsensitivePack(packsFolder, reference.pack());
|
||||
String installedPackName = installedPackName(reference.pack());
|
||||
File found = PackDirectoryResolver.resolveExisting(packsFolder, installedPackName);
|
||||
if (found == null) {
|
||||
found = findCaseInsensitivePack(packsFolder, installedPackName);
|
||||
}
|
||||
if (found != null) {
|
||||
pack = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pack.exists()) {
|
||||
if (pack == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -155,24 +161,64 @@ public class IrisToolbelt {
|
||||
}
|
||||
int separator = requested.indexOf(':');
|
||||
if (separator < 0) {
|
||||
return new PackReference(requested, requested, false);
|
||||
if (!isSafePackDescriptor(requested)) {
|
||||
return null;
|
||||
}
|
||||
return new PackReference(requested, installedPackName(requested), false);
|
||||
}
|
||||
String pack = requested.substring(0, separator).trim();
|
||||
String dimension = requested.substring(separator + 1).trim();
|
||||
if (pack.isEmpty() || dimension.isEmpty()) {
|
||||
if (!isSafePackDescriptor(pack) || !isSafeDimensionKey(dimension)) {
|
||||
return null;
|
||||
}
|
||||
return new PackReference(pack, dimension, true);
|
||||
}
|
||||
|
||||
private static File findCaseInsensitivePack(File packsFolder, String requested) {
|
||||
File[] children = packsFolder.listFiles();
|
||||
if (children == null) {
|
||||
return null;
|
||||
private static boolean isSafePackDescriptor(String value) {
|
||||
String[] segments = value.split("/", -1);
|
||||
if (segments.length < 1 || segments.length > 3) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty()
|
||||
|| segment.equals(".")
|
||||
|| segment.equals("..")
|
||||
|| segment.startsWith(".")
|
||||
|| !segment.matches("[A-Za-z0-9_-]+")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (File child : children) {
|
||||
if (child.isDirectory() && child.getName().equalsIgnoreCase(requested)) {
|
||||
private static boolean isSafeDimensionKey(String value) {
|
||||
if (value.isEmpty() || value.length() > 256) {
|
||||
return false;
|
||||
}
|
||||
String[] segments = value.split("/", -1);
|
||||
if (segments.length > 16) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty()
|
||||
|| segment.equals(".")
|
||||
|| segment.equals("..")
|
||||
|| segment.startsWith(".")
|
||||
|| !segment.matches("[A-Za-z0-9_-]+")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String installedPackName(String descriptor) {
|
||||
String[] segments = descriptor.split("/");
|
||||
return segments.length > 1 ? segments[1] : segments[0];
|
||||
}
|
||||
|
||||
private static File findCaseInsensitivePack(File packsFolder, String requested) {
|
||||
for (File child : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
|
||||
if (child.getName().equalsIgnoreCase(requested)) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -357,26 +403,11 @@ public class IrisToolbelt {
|
||||
* @param world the world to evac
|
||||
*/
|
||||
public static boolean evacuate(World world) {
|
||||
if (world == null || isServerStopping()) {
|
||||
return false;
|
||||
}
|
||||
return beginEvacuation(evacuateAsync(world));
|
||||
}
|
||||
|
||||
for (World i : Bukkit.getWorlds()) {
|
||||
if (!WorldIdentity.key(i).equals(WorldIdentity.key(world))) {
|
||||
for (Player j : new ArrayList<>(world.getPlayers())) {
|
||||
new VolmitSender(j, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD));
|
||||
Location target = i.getSpawnLocation();
|
||||
Runnable teleportTask = () -> teleportAsyncSafely(j, target);
|
||||
if (!J.runEntity(j, teleportTask)) {
|
||||
teleportTask.run();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
public static CompletableFuture<Boolean> evacuateAsync(World world) {
|
||||
return evacuateAsync(world, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,25 +418,11 @@ public class IrisToolbelt {
|
||||
* @return true if it was evacuated.
|
||||
*/
|
||||
public static boolean evacuate(World world, String m) {
|
||||
if (world == null || isServerStopping()) {
|
||||
return false;
|
||||
}
|
||||
return beginEvacuation(evacuateAsync(world, m));
|
||||
}
|
||||
|
||||
for (World i : Bukkit.getWorlds()) {
|
||||
if (!WorldIdentity.key(i).equals(WorldIdentity.key(world))) {
|
||||
for (Player j : new ArrayList<>(world.getPlayers())) {
|
||||
new VolmitSender(j, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD_2, MessageArgument.untrusted("m", String.valueOf(m))));
|
||||
Location target = i.getSpawnLocation();
|
||||
Runnable teleportTask = () -> teleportAsyncSafely(j, target);
|
||||
if (!J.runEntity(j, teleportTask)) {
|
||||
teleportTask.run();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
public static CompletableFuture<Boolean> evacuateAsync(World world, String message) {
|
||||
return evacuateAsync(world, message, true);
|
||||
}
|
||||
|
||||
public static boolean isStudio(World i) {
|
||||
@@ -417,25 +434,125 @@ public class IrisToolbelt {
|
||||
return generator != null && generator.isStudio();
|
||||
}
|
||||
|
||||
private static void teleportAsyncSafely(Player player, Location target) {
|
||||
static CompletableFuture<Boolean> settleEvacuations(List<CompletableFuture<Boolean>> evacuations) {
|
||||
List<CompletableFuture<Boolean>> pending = List.copyOf(evacuations);
|
||||
if (pending.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(true);
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(pending.toArray(CompletableFuture[]::new))
|
||||
.handle((ignored, throwable) -> {
|
||||
if (throwable != null) {
|
||||
return false;
|
||||
}
|
||||
for (CompletableFuture<Boolean> evacuation : pending) {
|
||||
if (!Boolean.TRUE.equals(evacuation.getNow(false))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static CompletableFuture<Boolean> evacuateAsync(World world, String message, boolean customMessage) {
|
||||
if (world == null || isServerStopping()) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
ArrayList<Player> players = new ArrayList<>(world.getPlayers());
|
||||
if (players.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(true);
|
||||
}
|
||||
|
||||
World targetWorld = null;
|
||||
for (World candidate : Bukkit.getWorlds()) {
|
||||
if (!WorldIdentity.key(candidate).equals(WorldIdentity.key(world))) {
|
||||
targetWorld = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetWorld == null) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
Location target = targetWorld.getSpawnLocation();
|
||||
ArrayList<CompletableFuture<Boolean>> evacuations = new ArrayList<>(players.size());
|
||||
for (Player player : players) {
|
||||
CompletableFuture<Boolean> evacuation = new CompletableFuture<>();
|
||||
Runnable teleportTask = () -> {
|
||||
try {
|
||||
if (customMessage) {
|
||||
new VolmitSender(player, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(
|
||||
BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD_2,
|
||||
MessageArgument.untrusted("m", String.valueOf(message))));
|
||||
} else {
|
||||
new VolmitSender(player, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(
|
||||
BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD));
|
||||
}
|
||||
teleportAsyncSafely(player, target).whenComplete((teleported, throwable) -> {
|
||||
if (throwable == null) {
|
||||
evacuation.complete(Boolean.TRUE.equals(teleported));
|
||||
} else {
|
||||
evacuation.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
evacuation.completeExceptionally(failure);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!J.runEntity(player, teleportTask)) {
|
||||
teleportTask.run();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
evacuation.completeExceptionally(failure);
|
||||
}
|
||||
evacuations.add(evacuation);
|
||||
}
|
||||
return settleEvacuations(evacuations);
|
||||
}
|
||||
|
||||
private static boolean beginEvacuation(CompletableFuture<Boolean> evacuation) {
|
||||
if (evacuation.isDone()) {
|
||||
try {
|
||||
return Boolean.TRUE.equals(evacuation.getNow(false));
|
||||
} catch (Throwable failure) {
|
||||
if (!isServerStopping()) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
evacuation.exceptionally(throwable -> {
|
||||
if (!isServerStopping()) {
|
||||
IrisLogging.reportError(throwable);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CompletableFuture<Boolean> teleportAsyncSafely(Player player, Location target) {
|
||||
if (player == null || target == null || isServerStopping()) {
|
||||
return;
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
try {
|
||||
CompletableFuture<Boolean> teleportFuture = PaperLib.teleportAsync(player, target);
|
||||
if (teleportFuture != null) {
|
||||
teleportFuture.exceptionally(throwable -> {
|
||||
if (!isServerStopping()) {
|
||||
IrisLogging.reportError(throwable);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (teleportFuture == null) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
return teleportFuture.exceptionally(throwable -> {
|
||||
if (!isServerStopping()) {
|
||||
IrisLogging.reportError(throwable);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} catch (Throwable throwable) {
|
||||
if (!isServerStopping()) {
|
||||
IrisLogging.reportError(throwable);
|
||||
}
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain;
|
||||
import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly;
|
||||
import art.arcane.iris.engine.IrisEngine.LifecycleState;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
@@ -67,26 +68,31 @@ final class EngineShutdownSequence {
|
||||
}
|
||||
|
||||
if (backgroundDrain.allowsResourceRelease()) {
|
||||
Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce);
|
||||
Throwable engineDataFailure = runCleanup(null, engine::saveEngineData);
|
||||
failure = appendFailure(failure, prefetchFailure);
|
||||
failure = appendFailure(failure, engineDataFailure);
|
||||
failure = releaseRuntime(failure);
|
||||
if (runtimeReleased) {
|
||||
failure = releaseTarget(failure);
|
||||
}
|
||||
if (targetReleased) {
|
||||
failure = releaseMantle(failure);
|
||||
}
|
||||
if (prefetchFailure == null
|
||||
&& engineDataFailure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& mantleReleased) {
|
||||
failure = releaseEngineDataForShutdown(failure);
|
||||
}
|
||||
if (engineDataReleased) {
|
||||
failure = releasePreservation(failure);
|
||||
Throwable ownershipFailure = runCleanup(null,
|
||||
() -> NativeStructureOwnershipStore.close(engine));
|
||||
failure = appendFailure(failure, ownershipFailure);
|
||||
if (ownershipFailure == null) {
|
||||
Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce);
|
||||
Throwable engineDataFailure = runCleanup(null, engine::saveEngineData);
|
||||
failure = appendFailure(failure, prefetchFailure);
|
||||
failure = appendFailure(failure, engineDataFailure);
|
||||
failure = releaseRuntime(failure);
|
||||
if (runtimeReleased) {
|
||||
failure = releaseTarget(failure);
|
||||
}
|
||||
if (targetReleased) {
|
||||
failure = releaseMantle(failure);
|
||||
}
|
||||
if (prefetchFailure == null
|
||||
&& engineDataFailure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& mantleReleased) {
|
||||
failure = releaseEngineDataForShutdown(failure);
|
||||
}
|
||||
if (engineDataReleased) {
|
||||
failure = releasePreservation(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure == null
|
||||
@@ -130,18 +136,23 @@ final class EngineShutdownSequence {
|
||||
}
|
||||
return;
|
||||
}
|
||||
cleanupFailure = closeRuntime(engine.runtime, cleanupFailure);
|
||||
engine.runtime = null;
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getTarget()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData);
|
||||
engine.closed = true;
|
||||
cleanupFailure = runCleanup(cleanupFailure, () -> {
|
||||
PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (registry != null) {
|
||||
registry.dereference();
|
||||
}
|
||||
});
|
||||
Throwable ownershipFailure = runCleanup(null,
|
||||
() -> NativeStructureOwnershipStore.close(engine));
|
||||
cleanupFailure = appendFailure(cleanupFailure, ownershipFailure);
|
||||
if (ownershipFailure == null) {
|
||||
cleanupFailure = closeRuntime(engine.runtime, cleanupFailure);
|
||||
engine.runtime = null;
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getTarget()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData);
|
||||
engine.closed = true;
|
||||
cleanupFailure = runCleanup(cleanupFailure, () -> {
|
||||
PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (registry != null) {
|
||||
registry.dereference();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (cleanupFailure != null && cleanupFailure != original) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
}
|
||||
|
||||
@@ -180,12 +180,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
EngineMetrics getMetrics();
|
||||
|
||||
default void save() {
|
||||
NativeStructureOwnershipStore.flush(this);
|
||||
getMantle().save();
|
||||
getWorldManager().onSave();
|
||||
saveEngineData();
|
||||
}
|
||||
|
||||
default void saveNow() {
|
||||
NativeStructureOwnershipStore.flush(this);
|
||||
getMantle().saveAllNow();
|
||||
saveEngineData();
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@ import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -61,7 +63,7 @@ import java.util.regex.Pattern;
|
||||
* placements enumerate their finite configured starts directly.
|
||||
*/
|
||||
public final class IrisStructureLocator {
|
||||
private static final int DENSITY_CANDIDATE_BUDGET = 4_096;
|
||||
private static final int CANDIDATE_BUDGET = 4_096;
|
||||
private static final int MAX_BURIAL_COLUMNS = 2_000_000;
|
||||
private static final int UNDERGROUND_SURFACE_CLEARANCE = 1;
|
||||
private static final Pattern NAMESPACED_RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
@@ -69,7 +71,7 @@ public final class IrisStructureLocator {
|
||||
private static final Cache<Engine, PlacementIndex> INDEX_CACHE = Caffeine.newBuilder().weakKeys().build();
|
||||
private static final PlacementIndex EMPTY_INDEX = new PlacementIndex(
|
||||
Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(),
|
||||
Collections.emptyList());
|
||||
Collections.emptySet(), Collections.emptyList());
|
||||
private static final LocateResult NOT_FOUND_RESULT = new LocateResult(LocateStatus.NOT_FOUND, 0, 0, 0);
|
||||
private static final LocateResult SEARCH_LIMIT_RESULT =
|
||||
new LocateResult(LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0);
|
||||
@@ -95,6 +97,25 @@ public final class IrisStructureLocator {
|
||||
|| placementIndex.vanillaAliases.contains(normalizedKey);
|
||||
}
|
||||
|
||||
public static boolean hasNativePlacement(Engine engine, String key) {
|
||||
if (engine == null || key == null || key.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return index(engine).nativeKeys.contains(normalize(key));
|
||||
}
|
||||
|
||||
public static boolean hasEditablePlacement(Engine engine, String key) {
|
||||
if (engine == null || key == null || key.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (IrisStructurePlacement placement : index(engine).placements) {
|
||||
if (placement.hasIrisStructures() && matches(placement, key, engine.getData())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean suppressesVanilla(Engine engine, String vanillaKey) {
|
||||
if (engine == null || vanillaKey == null || vanillaKey.isEmpty()) {
|
||||
return false;
|
||||
@@ -116,13 +137,180 @@ public final class IrisStructureLocator {
|
||||
}
|
||||
|
||||
public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) {
|
||||
return locate(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, (chunkX, chunkZ) -> true);
|
||||
}
|
||||
|
||||
public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int maxRadiusChunks, CandidateFilter candidateFilter) {
|
||||
try {
|
||||
return locateFiltered(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, candidateFilter);
|
||||
} catch (CandidateSearchLimitException ignored) {
|
||||
return SEARCH_LIMIT_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
public static LocateResult locateInPlacementRings(
|
||||
Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int maxSearchRadius, CandidateFilter candidateFilter) {
|
||||
try {
|
||||
return locateInPlacementRingsFiltered(
|
||||
engine, key, fromBlockX, fromBlockZ, maxSearchRadius, candidateFilter);
|
||||
} catch (CandidateSearchLimitException ignored) {
|
||||
return SEARCH_LIMIT_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
private static LocateResult locateInPlacementRingsFiltered(
|
||||
Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int maxSearchRadius, CandidateFilter candidateFilter) {
|
||||
if (!isPlaced(engine, key)) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
int max = Math.max(1, Math.min(maxRadiusChunks, 2048));
|
||||
CandidateFilter activeFilter = Objects.requireNonNull(
|
||||
candidateFilter, "Structure locate filter must not be null");
|
||||
int maximumRing = Math.max(0, Math.min(maxSearchRadius, 2048));
|
||||
int centerChunkX = fromBlockX >> 4;
|
||||
int centerChunkZ = fromBlockZ >> 4;
|
||||
PlacementCatalog catalog = collectPlacementCatalog(engine, key);
|
||||
if (catalog.randomSpread().isEmpty()
|
||||
&& catalog.concentricRings().isEmpty() && !catalog.hasDensity()) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
SeedManager seedManager = engine.getSeedManager();
|
||||
if (seedManager == null) {
|
||||
throw new IllegalStateException(
|
||||
"Iris structure locate requires a bound seed manager for '" + key + "'");
|
||||
}
|
||||
long seed = seedManager.getMantle();
|
||||
CandidateBudget candidateBudget = new CandidateBudget();
|
||||
LocatedCandidate best = nearestConcentricCandidate(
|
||||
engine, key, fromBlockX, fromBlockZ, seed, catalog, activeFilter, candidateBudget);
|
||||
|
||||
for (int ring = 0; ring <= maximumRing; ring++) {
|
||||
boolean foundInRing = false;
|
||||
for (RandomSpreadParameters parameters : catalog.randomSpread()) {
|
||||
LocatedCandidate candidate = firstRandomSpreadCandidateInRing(
|
||||
engine, key, fromBlockX, fromBlockZ,
|
||||
centerChunkX, centerChunkZ, ring, seed,
|
||||
parameters, activeFilter, candidateBudget);
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
foundInRing = true;
|
||||
if (best == null || candidate.distanceSquared() < best.distanceSquared()) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
if (catalog.hasDensity()) {
|
||||
LocatedCandidate candidate = firstDensityCandidateInRing(
|
||||
engine, key, fromBlockX, fromBlockZ,
|
||||
centerChunkX, centerChunkZ, ring,
|
||||
activeFilter, candidateBudget);
|
||||
if (candidate != null) {
|
||||
foundInRing = true;
|
||||
if (best == null || candidate.distanceSquared() < best.distanceSquared()) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (foundInRing) {
|
||||
return found(best);
|
||||
}
|
||||
}
|
||||
return best == null ? NOT_FOUND_RESULT : found(best);
|
||||
}
|
||||
|
||||
private static LocatedCandidate nearestConcentricCandidate(
|
||||
Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
long seed, PlacementCatalog catalog, CandidateFilter candidateFilter,
|
||||
CandidateBudget candidateBudget) {
|
||||
LocatedCandidate best = null;
|
||||
Set<Long> checkedChunks = new LinkedHashSet<>();
|
||||
for (IrisStructurePlacement placement : catalog.concentricRings()) {
|
||||
int count = Math.max(1, placement.getRingCount());
|
||||
for (int placementIndex = 0; placementIndex < count; placementIndex++) {
|
||||
candidateBudget.claim();
|
||||
int[] candidate = StructurePlacementGrid.concentricRingChunk(
|
||||
placement, placementIndex, seed);
|
||||
if (candidate == null || !checkedChunks.add(chunkKey(candidate[0], candidate[1]))) {
|
||||
continue;
|
||||
}
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]);
|
||||
if (resolved == null || !candidateFilter.accept(candidate[0], candidate[1])) {
|
||||
continue;
|
||||
}
|
||||
long distanceSquared = blockDistanceSquared(
|
||||
resolved, fromBlockX, fromBlockZ);
|
||||
if (best == null || distanceSquared < best.distanceSquared()) {
|
||||
best = new LocatedCandidate(resolved, distanceSquared);
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static LocatedCandidate firstRandomSpreadCandidateInRing(
|
||||
Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int centerChunkX, int centerChunkZ, int ring, long seed,
|
||||
RandomSpreadParameters parameters, CandidateFilter candidateFilter,
|
||||
CandidateBudget candidateBudget) {
|
||||
int spacing = Math.max(1, parameters.spacing());
|
||||
int centerCellX = Math.floorDiv(centerChunkX, spacing);
|
||||
int centerCellZ = Math.floorDiv(centerChunkZ, spacing);
|
||||
for (int offsetX = -ring; offsetX <= ring; offsetX++) {
|
||||
boolean edgeX = offsetX == -ring || offsetX == ring;
|
||||
for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) {
|
||||
boolean edgeZ = offsetZ == -ring || offsetZ == ring;
|
||||
if (!edgeX && !edgeZ) {
|
||||
continue;
|
||||
}
|
||||
candidateBudget.claim();
|
||||
int[] candidate = StructurePlacementGrid.randomSpreadCellChunk(
|
||||
centerCellX + offsetX, centerCellZ + offsetZ,
|
||||
spacing, parameters.separation(), parameters.salt(), seed);
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]);
|
||||
if (resolved != null && candidateFilter.accept(candidate[0], candidate[1])) {
|
||||
return new LocatedCandidate(
|
||||
resolved, blockDistanceSquared(resolved, fromBlockX, fromBlockZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LocatedCandidate firstDensityCandidateInRing(
|
||||
Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int centerChunkX, int centerChunkZ, int ring,
|
||||
CandidateFilter candidateFilter, CandidateBudget candidateBudget) {
|
||||
for (int offsetX = -ring; offsetX <= ring; offsetX++) {
|
||||
boolean edgeX = offsetX == -ring || offsetX == ring;
|
||||
for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) {
|
||||
boolean edgeZ = offsetZ == -ring || offsetZ == ring;
|
||||
if (!edgeX && !edgeZ) {
|
||||
continue;
|
||||
}
|
||||
int chunkX = centerChunkX + offsetX;
|
||||
int chunkZ = centerChunkZ + offsetZ;
|
||||
candidateBudget.claim();
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, chunkX, chunkZ);
|
||||
if (resolved != null && candidateFilter.accept(chunkX, chunkZ)) {
|
||||
return new LocatedCandidate(
|
||||
resolved, blockDistanceSquared(resolved, fromBlockX, fromBlockZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LocateResult locateFiltered(Engine engine, String key, int fromBlockX, int fromBlockZ,
|
||||
int maxRadiusChunks, CandidateFilter candidateFilter) {
|
||||
if (!isPlaced(engine, key)) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
CandidateFilter activeFilter = Objects.requireNonNull(candidateFilter, "Structure locate filter must not be null");
|
||||
int max = Math.max(0, Math.min(maxRadiusChunks, 2048));
|
||||
int pcx = fromBlockX >> 4;
|
||||
int pcz = fromBlockZ >> 4;
|
||||
long maxDistSq = (long) max * (long) max;
|
||||
LocatedCandidate best = null;
|
||||
PlacementCatalog catalog = collectPlacementCatalog(engine, key);
|
||||
if (catalog.randomSpread().isEmpty() && catalog.concentricRings().isEmpty() && !catalog.hasDensity()) {
|
||||
@@ -133,6 +321,7 @@ public final class IrisStructureLocator {
|
||||
throw new IllegalStateException("Iris structure locate requires a bound seed manager for '" + key + "'");
|
||||
}
|
||||
long seed = seedManager.getMantle();
|
||||
CandidateBudget candidateBudget = new CandidateBudget();
|
||||
|
||||
for (RandomSpreadParameters parameters : catalog.randomSpread) {
|
||||
int spacing = Math.max(1, parameters.spacing());
|
||||
@@ -141,8 +330,10 @@ public final class IrisStructureLocator {
|
||||
int cellRadius = (max / spacing) + 2;
|
||||
|
||||
for (int r = 0; r <= cellRadius; r++) {
|
||||
long lowerBound = cellRingDistanceLowerBound(r, spacing, pcx, pcz, centerCellX, centerCellZ);
|
||||
if (best != null && lowerBound * lowerBound >= best.distanceSquared()) {
|
||||
long chunkLowerBound = cellRingDistanceLowerBound(
|
||||
r, spacing, pcx, pcz, centerCellX, centerCellZ);
|
||||
long blockLowerBound = chunkDeltaBlockDistanceLowerBound(chunkLowerBound);
|
||||
if (best != null && squareSaturated(blockLowerBound) >= best.distanceSquared()) {
|
||||
break;
|
||||
}
|
||||
for (int dx = -r; dx <= r; dx++) {
|
||||
@@ -150,17 +341,23 @@ public final class IrisStructureLocator {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dz)) != r) {
|
||||
continue;
|
||||
}
|
||||
candidateBudget.claim();
|
||||
int[] candidate = StructurePlacementGrid.randomSpreadCellChunk(
|
||||
centerCellX + dx, centerCellZ + dz, spacing, parameters.separation(), parameters.salt(), seed);
|
||||
int cx = candidate[0];
|
||||
int cz = candidate[1];
|
||||
long distSq = distanceSquared(cx, cz, pcx, pcz);
|
||||
if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) {
|
||||
if (!withinRadius(cx, cz, pcx, pcz, max)
|
||||
|| best != null && chunkBlockDistanceSquaredLowerBound(
|
||||
cx, cz, fromBlockX, fromBlockZ) >= best.distanceSquared()) {
|
||||
continue;
|
||||
}
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, cx, cz);
|
||||
if (resolved != null) {
|
||||
best = new LocatedCandidate(resolved, distSq);
|
||||
if (resolved != null && activeFilter.accept(cx, cz)) {
|
||||
long distanceSquared = blockDistanceSquared(
|
||||
resolved, fromBlockX, fromBlockZ);
|
||||
if (best == null || distanceSquared < best.distanceSquared()) {
|
||||
best = new LocatedCandidate(resolved, distanceSquared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,25 +368,31 @@ public final class IrisStructureLocator {
|
||||
for (IrisStructurePlacement placement : catalog.concentricRings) {
|
||||
int count = Math.max(1, placement.getRingCount());
|
||||
for (int placementIndex = 0; placementIndex < count; placementIndex++) {
|
||||
candidateBudget.claim();
|
||||
int[] candidate = StructurePlacementGrid.concentricRingChunk(placement, placementIndex, seed);
|
||||
if (candidate == null || !checkedRingChunks.add(chunkKey(candidate[0], candidate[1]))) {
|
||||
continue;
|
||||
}
|
||||
long distSq = distanceSquared(candidate[0], candidate[1], pcx, pcz);
|
||||
if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) {
|
||||
if (!withinRadius(candidate[0], candidate[1], pcx, pcz, max)
|
||||
|| best != null && chunkBlockDistanceSquaredLowerBound(
|
||||
candidate[0], candidate[1], fromBlockX, fromBlockZ) >= best.distanceSquared()) {
|
||||
continue;
|
||||
}
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]);
|
||||
if (resolved != null) {
|
||||
best = new LocatedCandidate(resolved, distSq);
|
||||
if (resolved != null && activeFilter.accept(candidate[0], candidate[1])) {
|
||||
long distanceSquared = blockDistanceSquared(
|
||||
resolved, fromBlockX, fromBlockZ);
|
||||
if (best == null || distanceSquared < best.distanceSquared()) {
|
||||
best = new LocatedCandidate(resolved, distanceSquared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog.hasDensity) {
|
||||
int checkedDensityCandidates = 0;
|
||||
for (int r = 0; r <= max; r++) {
|
||||
if (best != null && (long) r * r > best.distanceSquared()) {
|
||||
long ringBlockLowerBound = chunkDeltaBlockDistanceLowerBound(r);
|
||||
if (best != null && squareSaturated(ringBlockLowerBound) >= best.distanceSquared()) {
|
||||
break;
|
||||
}
|
||||
for (int dx = -r; dx <= r; dx++) {
|
||||
@@ -199,17 +402,18 @@ public final class IrisStructureLocator {
|
||||
}
|
||||
int cx = pcx + dx;
|
||||
int cz = pcz + dz;
|
||||
long distSq = distanceSquared(cx, cz, pcx, pcz);
|
||||
if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) {
|
||||
if (best != null && chunkBlockDistanceSquaredLowerBound(
|
||||
cx, cz, fromBlockX, fromBlockZ) >= best.distanceSquared()) {
|
||||
continue;
|
||||
}
|
||||
if (checkedDensityCandidates >= DENSITY_CANDIDATE_BUDGET) {
|
||||
return SEARCH_LIMIT_RESULT;
|
||||
}
|
||||
checkedDensityCandidates++;
|
||||
candidateBudget.claim();
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, cx, cz);
|
||||
if (resolved != null) {
|
||||
best = new LocatedCandidate(resolved, distSq);
|
||||
if (resolved != null && activeFilter.accept(cx, cz)) {
|
||||
long distanceSquared = blockDistanceSquared(
|
||||
resolved, fromBlockX, fromBlockZ);
|
||||
if (best == null || distanceSquared < best.distanceSquared()) {
|
||||
best = new LocatedCandidate(resolved, distanceSquared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +423,13 @@ public final class IrisStructureLocator {
|
||||
if (best == null) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
ResolvedStart resolved = best.resolved();
|
||||
return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ());
|
||||
return found(best);
|
||||
}
|
||||
|
||||
static boolean withinRadius(int chunkX, int chunkZ,
|
||||
int centerChunkX, int centerChunkZ, int radius) {
|
||||
return Math.abs((long) chunkX - centerChunkX) <= radius
|
||||
&& Math.abs((long) chunkZ - centerChunkZ) <= radius;
|
||||
}
|
||||
|
||||
public static ResolvedPlacement resolvePlacement(Engine engine, IrisStructurePlacement placement, int cx, int cz) {
|
||||
@@ -341,18 +550,20 @@ public final class IrisStructureLocator {
|
||||
|
||||
private static ResolvedStart resolveInChunk(Engine engine, String key, int cx, int cz) {
|
||||
IrisData data = engine.getData();
|
||||
String normalizedKey = normalize(key);
|
||||
if (hasNativePlacement(engine, key)) {
|
||||
for (NativeStructureStartPlan plan : NativeStructurePlacementPlanner.plansAt(engine, cx, cz)) {
|
||||
if (normalize(plan.source().getStructure()).equals(normalizedKey)) {
|
||||
return new ResolvedStart(cx << 4, plan.baseY(), cz << 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
KList<IrisStructurePlacement> placements = StructurePlacementScope.placementsAt(engine, cx, cz);
|
||||
for (IrisStructurePlacement placement : placements) {
|
||||
if (!matches(placement, key, data)) {
|
||||
continue;
|
||||
}
|
||||
if (placement.hasNativeStructures()) {
|
||||
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.planAt(
|
||||
engine, placement, cx, cz);
|
||||
if (plan != null && normalize(plan.source().getStructure()).equals(normalize(key))) {
|
||||
return new ResolvedStart(
|
||||
cx << 4, plan.baseY(), cz << 4);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ResolvedPlacement resolved = resolvePlacement(engine, placement, cx, cz);
|
||||
@@ -370,6 +581,10 @@ public final class IrisStructureLocator {
|
||||
if (worldMin > worldMax) {
|
||||
return null;
|
||||
}
|
||||
if (!placement.isUnderwater()
|
||||
&& NativeStructurePlacementPlanner.isSubmerged(engine, originX, originZ)) {
|
||||
return null;
|
||||
}
|
||||
if (!placement.isUnderground()) {
|
||||
int surfaceY = engine.getHeight(originX, originZ, true) + engine.getMinHeight();
|
||||
return surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight() ? null : surfaceY;
|
||||
@@ -613,10 +828,56 @@ public final class IrisStructureLocator {
|
||||
return configuredMin <= configuredMax && configuredMin <= worldMax && configuredMax >= worldMin;
|
||||
}
|
||||
|
||||
private static long distanceSquared(int cx, int cz, int pcx, int pcz) {
|
||||
long dx = (long) cx - pcx;
|
||||
long dz = (long) cz - pcz;
|
||||
return dx * dx + dz * dz;
|
||||
private static long blockDistanceSquared(
|
||||
ResolvedStart resolved, int fromBlockX, int fromBlockZ) {
|
||||
long dx = (long) resolved.originX() - fromBlockX;
|
||||
long dz = (long) resolved.originZ() - fromBlockZ;
|
||||
return distanceSquaredSaturated(dx, dz);
|
||||
}
|
||||
|
||||
static long chunkBlockDistanceSquaredLowerBound(
|
||||
int chunkX, int chunkZ, int fromBlockX, int fromBlockZ) {
|
||||
long minimumX = (long) chunkX << 4;
|
||||
long minimumZ = (long) chunkZ << 4;
|
||||
long dx = axisDistance(fromBlockX, minimumX, minimumX + 15L);
|
||||
long dz = axisDistance(fromBlockZ, minimumZ, minimumZ + 15L);
|
||||
return distanceSquaredSaturated(dx, dz);
|
||||
}
|
||||
|
||||
private static long axisDistance(long point, long minimum, long maximum) {
|
||||
if (point < minimum) {
|
||||
return minimum - point;
|
||||
}
|
||||
if (point > maximum) {
|
||||
return point - maximum;
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private static long chunkDeltaBlockDistanceLowerBound(long chunkDelta) {
|
||||
return chunkDelta <= 0L ? 0L : chunkDelta * 16L - 15L;
|
||||
}
|
||||
|
||||
private static long distanceSquaredSaturated(long dx, long dz) {
|
||||
long xSquared = squareSaturated(dx);
|
||||
long zSquared = squareSaturated(dz);
|
||||
if (Long.MAX_VALUE - xSquared < zSquared) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return xSquared + zSquared;
|
||||
}
|
||||
|
||||
private static long squareSaturated(long value) {
|
||||
long absolute = Math.abs(value);
|
||||
if (absolute > 3_037_000_499L) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return absolute * absolute;
|
||||
}
|
||||
|
||||
private static LocateResult found(LocatedCandidate candidate) {
|
||||
ResolvedStart resolved = candidate.resolved();
|
||||
return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ());
|
||||
}
|
||||
|
||||
static long cellRingDistanceLowerBound(int ring, int spacing, int pcx, int pcz,
|
||||
@@ -700,22 +961,46 @@ public final class IrisStructureLocator {
|
||||
List<IrisStructurePlacement> placements = new ArrayList<>();
|
||||
collect(engine.getDimension().getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases,
|
||||
suppressedVanillaSources, placements, true);
|
||||
for (IrisRegion region : engine.getDimension().getAllRegions(engine)) {
|
||||
collect(region.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases,
|
||||
suppressedVanillaSources, placements, false);
|
||||
KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
|
||||
if (regions != null) {
|
||||
for (IrisRegion region : regions) {
|
||||
collect(region == null ? null : region.getStructures(), data,
|
||||
loadKeys, normalizedLoadKeys, vanillaAliases,
|
||||
suppressedVanillaSources, placements, false);
|
||||
}
|
||||
}
|
||||
for (IrisBiome biome : engine.getDimension().getReachableBiomes(engine)) {
|
||||
collect(biome.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases,
|
||||
suppressedVanillaSources, placements, false);
|
||||
KList<IrisBiome> biomes = engine.getDimension().getReachableBiomes(engine);
|
||||
if (biomes != null) {
|
||||
for (IrisBiome biome : biomes) {
|
||||
collect(biome == null ? null : biome.getStructures(), data,
|
||||
loadKeys, normalizedLoadKeys, vanillaAliases,
|
||||
suppressedVanillaSources, placements, false);
|
||||
}
|
||||
}
|
||||
return new PlacementIndex(
|
||||
Collections.unmodifiableSet(loadKeys),
|
||||
Collections.unmodifiableSet(normalizedLoadKeys),
|
||||
Collections.unmodifiableSet(vanillaAliases),
|
||||
Collections.unmodifiableSet(suppressedVanillaSources),
|
||||
nativeKeys(placements),
|
||||
List.copyOf(placements));
|
||||
}
|
||||
|
||||
private static Set<String> nativeKeys(List<IrisStructurePlacement> placements) {
|
||||
Set<String> nativeKeys = new LinkedHashSet<>();
|
||||
for (IrisStructurePlacement placement : placements) {
|
||||
if (!placement.hasNativeStructures()) {
|
||||
continue;
|
||||
}
|
||||
for (IrisNativeStructure source : placement.getNativeStructures()) {
|
||||
if (source != null) {
|
||||
nativeKeys.add(normalize(source.getStructure()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableSet(nativeKeys);
|
||||
}
|
||||
|
||||
private static void collect(KList<IrisStructurePlacement> source, IrisData data, Set<String> loadKeys,
|
||||
Set<String> normalizedLoadKeys, Set<String> vanillaAliases,
|
||||
Set<String> suppressedVanillaSources,
|
||||
@@ -809,6 +1094,17 @@ public final class IrisStructureLocator {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CandidateFilter {
|
||||
boolean accept(int chunkX, int chunkZ);
|
||||
}
|
||||
|
||||
public static final class CandidateSearchLimitException extends RuntimeException {
|
||||
public CandidateSearchLimitException() {
|
||||
super(null, null, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
private record RandomSpreadParameters(int spacing, int separation, int salt) {
|
||||
}
|
||||
|
||||
@@ -822,20 +1118,34 @@ public final class IrisStructureLocator {
|
||||
private record LocatedCandidate(ResolvedStart resolved, long distanceSquared) {
|
||||
}
|
||||
|
||||
private static final class CandidateBudget {
|
||||
private int checked;
|
||||
|
||||
private void claim() {
|
||||
if (checked >= CANDIDATE_BUDGET) {
|
||||
throw new CandidateSearchLimitException();
|
||||
}
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PlacementIndex {
|
||||
private final Set<String> loadKeys;
|
||||
private final Set<String> normalizedLoadKeys;
|
||||
private final Set<String> vanillaAliases;
|
||||
private final Set<String> suppressedVanillaSources;
|
||||
private final Set<String> nativeKeys;
|
||||
private final List<IrisStructurePlacement> placements;
|
||||
|
||||
private PlacementIndex(Set<String> loadKeys, Set<String> normalizedLoadKeys,
|
||||
Set<String> vanillaAliases, Set<String> suppressedVanillaSources,
|
||||
Set<String> nativeKeys,
|
||||
List<IrisStructurePlacement> placements) {
|
||||
this.loadKeys = loadKeys;
|
||||
this.normalizedLoadKeys = normalizedLoadKeys;
|
||||
this.vanillaAliases = vanillaAliases;
|
||||
this.suppressedVanillaSources = suppressedVanillaSources;
|
||||
this.nativeKeys = nativeKeys;
|
||||
this.placements = placements;
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord.OwnershipKey;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NativeStructureOwnershipBundle(Map<OwnershipKey, NativeStructureOwnershipRecord> records) {
|
||||
public static final int MAX_RECORDS = 16_384;
|
||||
public static final int MAX_ENCODED_BYTES = 2 * 1024 * 1024;
|
||||
private static final Comparator<NativeStructureOwnershipRecord> RECORD_ORDER = Comparator
|
||||
.comparing(NativeStructureOwnershipRecord::structureKey)
|
||||
.thenComparingInt(NativeStructureOwnershipRecord::originChunkX)
|
||||
.thenComparingInt(NativeStructureOwnershipRecord::originChunkZ);
|
||||
|
||||
public NativeStructureOwnershipBundle {
|
||||
records = Map.copyOf(Objects.requireNonNull(records,
|
||||
"Native structure ownership records must not be null"));
|
||||
if (records.size() > MAX_RECORDS) {
|
||||
throw new IllegalArgumentException("Native structure ownership bundle exceeds "
|
||||
+ MAX_RECORDS + " records");
|
||||
}
|
||||
for (Map.Entry<OwnershipKey, NativeStructureOwnershipRecord> entry : records.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().ownershipKey())) {
|
||||
throw new IllegalArgumentException("Native structure ownership key does not match its record");
|
||||
}
|
||||
}
|
||||
try {
|
||||
encodePayload(records);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException("Native structure ownership bundle is not encodable", error);
|
||||
}
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipBundle empty() {
|
||||
return new NativeStructureOwnershipBundle(Map.of());
|
||||
}
|
||||
|
||||
public NativeStructureOwnershipRecord find(String structureKey, int originChunkX, int originChunkZ) {
|
||||
return records.get(new OwnershipKey(structureKey, originChunkX, originChunkZ));
|
||||
}
|
||||
|
||||
public NativeStructureOwnershipBundle with(NativeStructureOwnershipRecord record) {
|
||||
NativeStructureOwnershipRecord resolved = Objects.requireNonNull(
|
||||
record, "Native structure ownership record must not be null");
|
||||
Map<OwnershipKey, NativeStructureOwnershipRecord> updated = new LinkedHashMap<>(records);
|
||||
updated.put(resolved.ownershipKey(), resolved);
|
||||
return new NativeStructureOwnershipBundle(updated);
|
||||
}
|
||||
|
||||
public NativeStructureOwnershipBundle without(String structureKey, int originChunkX, int originChunkZ) {
|
||||
Map<OwnershipKey, NativeStructureOwnershipRecord> updated = new LinkedHashMap<>(records);
|
||||
updated.remove(new OwnershipKey(structureKey, originChunkX, originChunkZ));
|
||||
return new NativeStructureOwnershipBundle(updated);
|
||||
}
|
||||
|
||||
public void write(DataOutputStream output) throws IOException {
|
||||
byte[] encoded = encodePayload(records);
|
||||
output.writeInt(encoded.length);
|
||||
output.write(encoded);
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipBundle read(DataInputStream input) throws IOException {
|
||||
int byteCount = input.readInt();
|
||||
if (byteCount < 0 || byteCount > MAX_ENCODED_BYTES) {
|
||||
throw new IOException("Native structure ownership bundle has invalid length " + byteCount);
|
||||
}
|
||||
byte[] encoded = input.readNBytes(byteCount);
|
||||
if (encoded.length != byteCount) {
|
||||
throw new IOException("Native structure ownership bundle ended early");
|
||||
}
|
||||
try (DataInputStream payload = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
int count = payload.readInt();
|
||||
if (count < 0 || count > MAX_RECORDS) {
|
||||
throw new IOException("Native structure ownership bundle has invalid record count " + count);
|
||||
}
|
||||
Map<OwnershipKey, NativeStructureOwnershipRecord> records = new LinkedHashMap<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
NativeStructureOwnershipRecord record = NativeStructureOwnershipRecord.read(payload);
|
||||
if (records.put(record.ownershipKey(), record) != null) {
|
||||
throw new IOException("Native structure ownership bundle contains a duplicate record");
|
||||
}
|
||||
}
|
||||
if (payload.available() != 0) {
|
||||
throw new IOException("Native structure ownership bundle contains trailing data");
|
||||
}
|
||||
return new NativeStructureOwnershipBundle(records);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encodePayload(
|
||||
Map<OwnershipKey, NativeStructureOwnershipRecord> records) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream payload = new DataOutputStream(bytes)) {
|
||||
payload.writeInt(records.size());
|
||||
List<NativeStructureOwnershipRecord> ordered = new ArrayList<>(records.values());
|
||||
ordered.sort(RECORD_ORDER);
|
||||
for (NativeStructureOwnershipRecord record : ordered) {
|
||||
record.write(payload);
|
||||
}
|
||||
}
|
||||
if (bytes.size() > MAX_ENCODED_BYTES) {
|
||||
throw new IOException("Native structure ownership bundle exceeds "
|
||||
+ MAX_ENCODED_BYTES + " bytes");
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructureStiltSettings;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NativeStructureOwnershipRecord(
|
||||
int schema,
|
||||
String structureKey,
|
||||
int originChunkX,
|
||||
int originChunkZ,
|
||||
long placementIdentity,
|
||||
int baseY,
|
||||
int contentMinX,
|
||||
int contentMinY,
|
||||
int contentMinZ,
|
||||
int contentMaxX,
|
||||
int contentMaxY,
|
||||
int contentMaxZ,
|
||||
int locatorY,
|
||||
int referenceMinChunkX,
|
||||
int referenceMaxChunkX,
|
||||
int referenceMinChunkZ,
|
||||
int referenceMaxChunkZ,
|
||||
String contentFingerprint,
|
||||
DecisionSnapshot decision
|
||||
) {
|
||||
public static final int CURRENT_SCHEMA = 1;
|
||||
public static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8;
|
||||
private static final int MAX_KEY_BYTES = 512;
|
||||
private static final int MAX_FINGERPRINT_BYTES = 128;
|
||||
|
||||
public NativeStructureOwnershipRecord {
|
||||
if (schema != CURRENT_SCHEMA) {
|
||||
throw new IllegalArgumentException("Unsupported native structure ownership schema " + schema);
|
||||
}
|
||||
structureKey = normalizeKey(structureKey);
|
||||
requireUtf8Limit(structureKey, MAX_KEY_BYTES, "structure key");
|
||||
if (contentMinX > contentMaxX || contentMinY > contentMaxY || contentMinZ > contentMaxZ) {
|
||||
throw new IllegalArgumentException("Native structure content bounds are inverted");
|
||||
}
|
||||
if ((long) originChunkX - referenceMinChunkX > MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
|| (long) referenceMaxChunkX - originChunkX > MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
|| (long) originChunkZ - referenceMinChunkZ > MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
|| (long) referenceMaxChunkZ - originChunkZ > MAX_REFERENCE_DISTANCE_CHUNKS) {
|
||||
throw new IllegalArgumentException("Native structure ownership exceeds Minecraft's reference range");
|
||||
}
|
||||
long referenceMinBlockX = (long) referenceMinChunkX << 4;
|
||||
long referenceMaxBlockX = ((long) referenceMaxChunkX << 4) + 15L;
|
||||
long referenceMinBlockZ = (long) referenceMinChunkZ << 4;
|
||||
long referenceMaxBlockZ = ((long) referenceMaxChunkZ << 4) + 15L;
|
||||
if (contentMinX < referenceMinBlockX || contentMaxX > referenceMaxBlockX
|
||||
|| contentMinZ < referenceMinBlockZ || contentMaxZ > referenceMaxBlockZ) {
|
||||
throw new IllegalArgumentException(
|
||||
"Native structure content bounds exceed their reference envelope");
|
||||
}
|
||||
contentFingerprint = Objects.requireNonNull(contentFingerprint,
|
||||
"Native structure content fingerprint must not be null").toLowerCase(Locale.ROOT);
|
||||
requireUtf8Limit(contentFingerprint, MAX_FINGERPRINT_BYTES, "content fingerprint");
|
||||
if (!contentFingerprint.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Native structure content fingerprint must be SHA-256 hex");
|
||||
}
|
||||
decision = Objects.requireNonNull(decision, "Native structure decision snapshot must not be null");
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipRecord create(String structureKey,
|
||||
int originChunkX,
|
||||
int originChunkZ,
|
||||
long placementIdentity,
|
||||
int baseY,
|
||||
int contentMinX,
|
||||
int contentMinY,
|
||||
int contentMinZ,
|
||||
int contentMaxX,
|
||||
int contentMaxY,
|
||||
int contentMaxZ,
|
||||
int locatorY,
|
||||
int referenceMinChunkX,
|
||||
int referenceMaxChunkX,
|
||||
int referenceMinChunkZ,
|
||||
int referenceMaxChunkZ,
|
||||
String contentFingerprint,
|
||||
IrisNativeStructureDecision decision) {
|
||||
return new NativeStructureOwnershipRecord(
|
||||
CURRENT_SCHEMA,
|
||||
structureKey,
|
||||
originChunkX,
|
||||
originChunkZ,
|
||||
placementIdentity,
|
||||
baseY,
|
||||
contentMinX,
|
||||
contentMinY,
|
||||
contentMinZ,
|
||||
contentMaxX,
|
||||
contentMaxY,
|
||||
contentMaxZ,
|
||||
locatorY,
|
||||
referenceMinChunkX,
|
||||
referenceMaxChunkX,
|
||||
referenceMinChunkZ,
|
||||
referenceMaxChunkZ,
|
||||
contentFingerprint,
|
||||
DecisionSnapshot.capture(decision)
|
||||
);
|
||||
}
|
||||
|
||||
public boolean covers(int chunkX, int chunkZ) {
|
||||
return chunkX >= referenceMinChunkX && chunkX <= referenceMaxChunkX
|
||||
&& chunkZ >= referenceMinChunkZ && chunkZ <= referenceMaxChunkZ;
|
||||
}
|
||||
|
||||
public OwnershipKey ownershipKey() {
|
||||
return new OwnershipKey(structureKey, originChunkX, originChunkZ);
|
||||
}
|
||||
|
||||
public IrisNativeStructureDecision restoredDecision() {
|
||||
return decision.restore();
|
||||
}
|
||||
|
||||
void write(DataOutputStream output) throws IOException {
|
||||
output.writeInt(schema);
|
||||
writeString(output, structureKey, MAX_KEY_BYTES, "structure key");
|
||||
output.writeInt(originChunkX);
|
||||
output.writeInt(originChunkZ);
|
||||
output.writeLong(placementIdentity);
|
||||
output.writeInt(baseY);
|
||||
output.writeInt(contentMinX);
|
||||
output.writeInt(contentMinY);
|
||||
output.writeInt(contentMinZ);
|
||||
output.writeInt(contentMaxX);
|
||||
output.writeInt(contentMaxY);
|
||||
output.writeInt(contentMaxZ);
|
||||
output.writeInt(locatorY);
|
||||
output.writeInt(referenceMinChunkX);
|
||||
output.writeInt(referenceMaxChunkX);
|
||||
output.writeInt(referenceMinChunkZ);
|
||||
output.writeInt(referenceMaxChunkZ);
|
||||
writeString(output, contentFingerprint, MAX_FINGERPRINT_BYTES, "content fingerprint");
|
||||
decision.write(output);
|
||||
}
|
||||
|
||||
static NativeStructureOwnershipRecord read(DataInputStream input) throws IOException {
|
||||
int schema = input.readInt();
|
||||
if (schema != CURRENT_SCHEMA) {
|
||||
throw new IOException("Unsupported native structure ownership schema " + schema);
|
||||
}
|
||||
try {
|
||||
return new NativeStructureOwnershipRecord(
|
||||
schema,
|
||||
readString(input, MAX_KEY_BYTES, "structure key"),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readLong(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
input.readInt(),
|
||||
readString(input, MAX_FINGERPRINT_BYTES, "content fingerprint"),
|
||||
DecisionSnapshot.read(input)
|
||||
);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Native structure ownership record is invalid", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeKey(String key) {
|
||||
String normalized = Objects.requireNonNull(key,
|
||||
"Native structure key must not be null").trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("Native structure key must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static void writeString(DataOutputStream output, String value, int maximumBytes,
|
||||
String fieldName) throws IOException {
|
||||
byte[] encoded = Objects.requireNonNull(value, fieldName + " must not be null")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
if (encoded.length > maximumBytes) {
|
||||
throw new IOException("Native structure " + fieldName + " exceeds " + maximumBytes + " bytes");
|
||||
}
|
||||
output.writeInt(encoded.length);
|
||||
output.write(encoded);
|
||||
}
|
||||
|
||||
static String readString(DataInputStream input, int maximumBytes, String fieldName) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 0 || length > maximumBytes) {
|
||||
throw new IOException("Native structure " + fieldName + " has invalid length " + length);
|
||||
}
|
||||
byte[] encoded = input.readNBytes(length);
|
||||
if (encoded.length != length) {
|
||||
throw new IOException("Native structure " + fieldName + " ended early");
|
||||
}
|
||||
return new String(encoded, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void requireUtf8Limit(String value, int maximumBytes, String fieldName) {
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > maximumBytes) {
|
||||
throw new IllegalArgumentException("Native structure " + fieldName
|
||||
+ " exceeds " + maximumBytes + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
public record OwnershipKey(String structureKey, int originChunkX, int originChunkZ) {
|
||||
public OwnershipKey {
|
||||
structureKey = normalizeKey(structureKey);
|
||||
}
|
||||
}
|
||||
|
||||
public record DecisionSnapshot(boolean clearVegetation, String stiltJson, String terrainJson) {
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final int MAX_JSON_BYTES = 65_536;
|
||||
|
||||
public DecisionSnapshot {
|
||||
stiltJson = Objects.requireNonNull(stiltJson, "Native structure stilt snapshot must not be null");
|
||||
terrainJson = Objects.requireNonNull(terrainJson, "Native structure terrain snapshot must not be null");
|
||||
requireUtf8Limit(stiltJson, MAX_JSON_BYTES, "stilt snapshot");
|
||||
requireUtf8Limit(terrainJson, MAX_JSON_BYTES, "terrain snapshot");
|
||||
}
|
||||
|
||||
public static DecisionSnapshot capture(IrisNativeStructureDecision decision) {
|
||||
IrisNativeStructureDecision resolved = Objects.requireNonNull(
|
||||
decision, "Native structure decision must not be null");
|
||||
if (!resolved.generate()) {
|
||||
throw new IllegalArgumentException("Only generated native structure decisions can be persisted");
|
||||
}
|
||||
return new DecisionSnapshot(
|
||||
resolved.clearVegetation(),
|
||||
GSON.toJson(resolved.stilt()),
|
||||
GSON.toJson(Objects.requireNonNullElseGet(
|
||||
resolved.terrain(), IrisStructureTerrain::new))
|
||||
);
|
||||
}
|
||||
|
||||
public IrisNativeStructureDecision restore() {
|
||||
IrisStructureStiltSettings stilt = "null".equals(stiltJson)
|
||||
? null : GSON.fromJson(stiltJson, IrisStructureStiltSettings.class);
|
||||
IrisStructureTerrain terrain = GSON.fromJson(terrainJson, IrisStructureTerrain.class);
|
||||
if (terrain == null) {
|
||||
throw new IllegalStateException("Persisted native structure terrain snapshot is null");
|
||||
}
|
||||
return new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
0,
|
||||
null,
|
||||
false,
|
||||
clearVegetation,
|
||||
stilt,
|
||||
terrain
|
||||
);
|
||||
}
|
||||
|
||||
void write(DataOutputStream output) throws IOException {
|
||||
output.writeBoolean(clearVegetation);
|
||||
writeString(output, stiltJson, MAX_JSON_BYTES, "stilt snapshot");
|
||||
writeString(output, terrainJson, MAX_JSON_BYTES, "terrain snapshot");
|
||||
}
|
||||
|
||||
static DecisionSnapshot read(DataInputStream input) throws IOException {
|
||||
return new DecisionSnapshot(
|
||||
input.readBoolean(),
|
||||
readString(input, MAX_JSON_BYTES, "stilt snapshot"),
|
||||
readString(input, MAX_JSON_BYTES, "terrain snapshot")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
public final class NativeStructureOwnershipStore {
|
||||
private static final int MAX_CACHED_AUTHORITIES = 65_536;
|
||||
private static final Cache<Engine, State> STATES = Caffeine.newBuilder().weakKeys().build();
|
||||
|
||||
private NativeStructureOwnershipStore() {
|
||||
}
|
||||
|
||||
public static void record(Engine engine, NativeStructureOwnershipRecord record) {
|
||||
state(engine).record(record);
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipRecord find(Engine engine, int targetChunkX, int targetChunkZ,
|
||||
String structureKey, int originChunkX, int originChunkZ) {
|
||||
return state(engine).find(targetChunkX, targetChunkZ,
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipRecord findPersisted(
|
||||
Engine engine, String structureKey, int originChunkX, int originChunkZ) {
|
||||
return state(engine).findPersisted(structureKey, originChunkX, originChunkZ);
|
||||
}
|
||||
|
||||
public static void discard(Engine engine, String structureKey, int originChunkX, int originChunkZ) {
|
||||
state(engine).discard(structureKey, originChunkX, originChunkZ);
|
||||
}
|
||||
|
||||
public static void flush(Engine engine) {
|
||||
State state = STATES.getIfPresent(Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine"));
|
||||
if (state != null) {
|
||||
state.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static void close(Engine engine) {
|
||||
Engine resolved = Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine");
|
||||
State state = STATES.get(resolved, State::new);
|
||||
state.close();
|
||||
}
|
||||
|
||||
private static State state(Engine engine) {
|
||||
Engine resolved = Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine");
|
||||
return STATES.get(resolved, State::new);
|
||||
}
|
||||
|
||||
static final class State {
|
||||
private final WeakReference<Engine> engine;
|
||||
private final Storage storage;
|
||||
private final Cache<NativeStructureOwnershipRecord.OwnershipKey, Authority> authorities;
|
||||
private final ReentrantReadWriteLock lifecycleLock;
|
||||
private volatile boolean dirty;
|
||||
private volatile boolean closed;
|
||||
|
||||
private State(Engine engine) {
|
||||
this(engine, new MantleStorage(engine));
|
||||
}
|
||||
|
||||
State(Engine engine, Storage storage) {
|
||||
this.engine = new WeakReference<>(Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine"));
|
||||
this.storage = Objects.requireNonNull(storage,
|
||||
"Native structure ownership storage must not be null");
|
||||
this.authorities = Caffeine.newBuilder().maximumSize(MAX_CACHED_AUTHORITIES).build();
|
||||
this.lifecycleLock = new ReentrantReadWriteLock(true);
|
||||
}
|
||||
|
||||
void record(NativeStructureOwnershipRecord record) {
|
||||
lifecycleLock.readLock().lock();
|
||||
try {
|
||||
requireOpen();
|
||||
NativeStructureOwnershipRecord resolved = Objects.requireNonNull(
|
||||
record, "Native structure ownership record must not be null");
|
||||
authorities.asMap().compute(resolved.ownershipKey(), (ignored, current) -> {
|
||||
storage.write(pack(resolved.originChunkX(), resolved.originChunkZ()), resolved);
|
||||
dirty = true;
|
||||
return Authority.present(resolved);
|
||||
});
|
||||
} finally {
|
||||
lifecycleLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
NativeStructureOwnershipRecord find(int targetChunkX, int targetChunkZ,
|
||||
String structureKey, int originChunkX, int originChunkZ) {
|
||||
lifecycleLock.readLock().lock();
|
||||
try {
|
||||
requireOpen();
|
||||
NativeStructureOwnershipRecord.OwnershipKey key =
|
||||
new NativeStructureOwnershipRecord.OwnershipKey(
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
Authority authority = authorities.asMap().computeIfAbsent(
|
||||
key, this::loadAuthority);
|
||||
NativeStructureOwnershipRecord record = authority.record();
|
||||
return record != null && record.covers(targetChunkX, targetChunkZ)
|
||||
? record : null;
|
||||
} finally {
|
||||
lifecycleLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
NativeStructureOwnershipRecord findPersisted(
|
||||
String structureKey, int originChunkX, int originChunkZ) {
|
||||
lifecycleLock.readLock().lock();
|
||||
try {
|
||||
requireOpen();
|
||||
NativeStructureOwnershipRecord.OwnershipKey key =
|
||||
new NativeStructureOwnershipRecord.OwnershipKey(
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
return authorities.asMap().computeIfAbsent(
|
||||
key, this::loadAuthority).record();
|
||||
} finally {
|
||||
lifecycleLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void discard(String structureKey, int originChunkX, int originChunkZ) {
|
||||
lifecycleLock.readLock().lock();
|
||||
try {
|
||||
requireOpen();
|
||||
NativeStructureOwnershipRecord.OwnershipKey key =
|
||||
new NativeStructureOwnershipRecord.OwnershipKey(
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
authorities.asMap().compute(key, (ignored, current) -> {
|
||||
storage.remove(pack(originChunkX, originChunkZ),
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
dirty = true;
|
||||
return Authority.absent();
|
||||
});
|
||||
} finally {
|
||||
lifecycleLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void flush() {
|
||||
lifecycleLock.writeLock().lock();
|
||||
try {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Native structure ownership store is closed");
|
||||
}
|
||||
flushDirty();
|
||||
} finally {
|
||||
lifecycleLock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void close() {
|
||||
lifecycleLock.writeLock().lock();
|
||||
try {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
flushDirty();
|
||||
closed = true;
|
||||
authorities.invalidateAll();
|
||||
} finally {
|
||||
lifecycleLock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void flushDirty() {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
storage.flush();
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
private Authority loadAuthority(
|
||||
NativeStructureOwnershipRecord.OwnershipKey key) {
|
||||
NativeStructureOwnershipBundle origin = storage.read(
|
||||
key.originChunkX(), key.originChunkZ());
|
||||
NativeStructureOwnershipRecord record = origin == null ? null
|
||||
: origin.find(key.structureKey(), key.originChunkX(), key.originChunkZ());
|
||||
return record == null ? Authority.absent() : Authority.present(record);
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
engine();
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Native structure ownership store is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private Engine engine() {
|
||||
Engine active = engine.get();
|
||||
if (active == null) {
|
||||
throw new IllegalStateException("Native structure ownership engine is unavailable");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
NativeStructureOwnershipBundle read(int chunkX, int chunkZ);
|
||||
|
||||
void write(long target, NativeStructureOwnershipRecord record);
|
||||
|
||||
void remove(long target, String structureKey, int originChunkX, int originChunkZ);
|
||||
|
||||
void flush();
|
||||
}
|
||||
|
||||
private static final class MantleStorage implements Storage {
|
||||
private final WeakReference<Engine> engine;
|
||||
private final Map<Long, NativeStructureOwnershipBundle> pendingBundles;
|
||||
|
||||
private MantleStorage(Engine engine) {
|
||||
this.engine = new WeakReference<>(Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine"));
|
||||
this.pendingBundles = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) {
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
return chunk.get(0, 0, 0, NativeStructureOwnershipBundle.class);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(long target, NativeStructureOwnershipRecord record) {
|
||||
int targetChunkX = unpackX(target);
|
||||
int targetChunkZ = unpackZ(target);
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
Matter section = chunk.getOrCreate(0);
|
||||
MatterSlice<NativeStructureOwnershipBundle> slice =
|
||||
section.slice(NativeStructureOwnershipBundle.class);
|
||||
NativeStructureOwnershipBundle bundle = slice.get(0, 0, 0);
|
||||
NativeStructureOwnershipBundle updated = Objects.requireNonNullElseGet(
|
||||
bundle, NativeStructureOwnershipBundle::empty).with(record);
|
||||
slice.set(0, 0, 0, updated);
|
||||
pendingBundles.put(target, updated);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(long target, String structureKey,
|
||||
int originChunkX, int originChunkZ) {
|
||||
int targetChunkX = unpackX(target);
|
||||
int targetChunkZ = unpackZ(target);
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
Matter section = chunk.getOrCreate(0);
|
||||
MatterSlice<NativeStructureOwnershipBundle> slice =
|
||||
section.getSlice(NativeStructureOwnershipBundle.class);
|
||||
if (slice == null) {
|
||||
return;
|
||||
}
|
||||
NativeStructureOwnershipBundle bundle = slice.get(0, 0, 0);
|
||||
if (bundle == null) {
|
||||
return;
|
||||
}
|
||||
NativeStructureOwnershipBundle updated = bundle.without(
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
if (updated.equals(bundle)) {
|
||||
return;
|
||||
}
|
||||
slice.set(0, 0, 0, updated.records().isEmpty() ? null : updated);
|
||||
section.trimSlices();
|
||||
pendingBundles.put(target, updated);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
Map<Long, NativeStructureOwnershipBundle> pending = new TreeMap<>(pendingBundles);
|
||||
if (pending.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mantle<Matter> mantle = mantle();
|
||||
Set<Long> regions = new TreeSet<>();
|
||||
for (Map.Entry<Long, NativeStructureOwnershipBundle> entry : pending.entrySet()) {
|
||||
long target = entry.getKey();
|
||||
restorePendingBundle(mantle, target, entry.getValue());
|
||||
regions.add(Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5));
|
||||
}
|
||||
mantle.saveTectonicPlates(regions);
|
||||
for (Map.Entry<Long, NativeStructureOwnershipBundle> entry : pending.entrySet()) {
|
||||
pendingBundles.remove(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void restorePendingBundle(Mantle<Matter> mantle, long target,
|
||||
NativeStructureOwnershipBundle bundle) {
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(unpackX(target), unpackZ(target)).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
Matter section = chunk.getOrCreate(0);
|
||||
MatterSlice<NativeStructureOwnershipBundle> slice =
|
||||
section.slice(NativeStructureOwnershipBundle.class);
|
||||
slice.set(0, 0, 0, bundle.records().isEmpty() ? null : bundle);
|
||||
section.trimSlices();
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
}
|
||||
|
||||
private Mantle<Matter> mantle() {
|
||||
Engine active = engine.get();
|
||||
if (active == null) {
|
||||
throw new IllegalStateException("Native structure ownership engine is unavailable");
|
||||
}
|
||||
return active.getMantle().getMantle();
|
||||
}
|
||||
}
|
||||
|
||||
private record Authority(NativeStructureOwnershipRecord record) {
|
||||
private static Authority present(NativeStructureOwnershipRecord record) {
|
||||
return new Authority(Objects.requireNonNull(record,
|
||||
"Native structure ownership authority must not be null"));
|
||||
}
|
||||
|
||||
private static Authority absent() {
|
||||
return new Authority(null);
|
||||
}
|
||||
}
|
||||
|
||||
static long pack(int chunkX, int chunkZ) {
|
||||
return (chunkX & 0xffffffffL) | ((chunkZ & 0xffffffffL) << 32);
|
||||
}
|
||||
|
||||
static int unpackX(long packed) {
|
||||
return (int) packed;
|
||||
}
|
||||
|
||||
static int unpackZ(long packed) {
|
||||
return (int) (packed >>> 32);
|
||||
}
|
||||
}
|
||||
+34
-7
@@ -7,6 +7,9 @@ import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class NativeStructurePlacementPlanner {
|
||||
@@ -16,14 +19,18 @@ public final class NativeStructurePlacementPlanner {
|
||||
}
|
||||
|
||||
public static KList<NativeStructureStartPlan> plansAt(Engine engine, int chunkX, int chunkZ) {
|
||||
KList<NativeStructureStartPlan> plans = new KList<>();
|
||||
Map<String, NativeStructureStartPlan> plansByStructure = new LinkedHashMap<>();
|
||||
for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(engine, chunkX, chunkZ)) {
|
||||
NativeStructureStartPlan plan = planAt(engine, placement, chunkX, chunkZ);
|
||||
if (plan != null) {
|
||||
plans.add(plan);
|
||||
String structureKey = normalize(plan.source().getStructure());
|
||||
NativeStructureStartPlan current = plansByStructure.get(structureKey);
|
||||
if (current == null || comparePlacementPriority(plan.placement(), current.placement()) < 0) {
|
||||
plansByStructure.put(structureKey, plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
return plans;
|
||||
return new KList<>(plansByStructure.values());
|
||||
}
|
||||
|
||||
public static NativeStructureStartPlan planAt(Engine engine, IrisStructurePlacement placement,
|
||||
@@ -55,8 +62,9 @@ public final class NativeStructurePlacementPlanner {
|
||||
if (structureKey == null || structureKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedKey = normalize(structureKey);
|
||||
for (NativeStructureStartPlan plan : plansAt(engine, chunkX, chunkZ)) {
|
||||
if (structureKey.equals(plan.source().getStructure())) {
|
||||
if (normalizedKey.equals(normalize(plan.source().getStructure()))) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -123,9 +131,13 @@ public final class NativeStructurePlacementPlanner {
|
||||
if (worldMin > worldMax) {
|
||||
throw new IllegalStateException("Native structure placement has invalid world height bounds");
|
||||
}
|
||||
int blockX = (chunkX << 4) + 8;
|
||||
int blockZ = (chunkZ << 4) + 8;
|
||||
if (!placement.isUnderwater()
|
||||
&& isSubmerged(engine, blockX, blockZ)) {
|
||||
return null;
|
||||
}
|
||||
if (!placement.isUnderground()) {
|
||||
int blockX = (chunkX << 4) + 8;
|
||||
int blockZ = (chunkZ << 4) + 8;
|
||||
int surfaceY = engine.getHeight(blockX, blockZ, true) + engine.getMinHeight();
|
||||
if (surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight()) {
|
||||
return null;
|
||||
@@ -135,8 +147,23 @@ public final class NativeStructurePlacementPlanner {
|
||||
int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight()));
|
||||
int bandMax = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight()));
|
||||
if (bandMin > bandMax) {
|
||||
throw new IllegalStateException("Native structure underground band does not intersect world bounds");
|
||||
return null;
|
||||
}
|
||||
return bandMin == bandMax ? bandMin : bandMin + rng.nextInt((bandMax - bandMin) + 1);
|
||||
}
|
||||
|
||||
static boolean isSubmerged(Engine engine, int blockX, int blockZ) {
|
||||
return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight();
|
||||
}
|
||||
|
||||
private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) {
|
||||
long leftIdentity = StructurePlacementGrid.placementIdentity(left);
|
||||
long rightIdentity = StructurePlacementGrid.placementIdentity(right);
|
||||
return Long.compareUnsigned(leftIdentity, rightIdentity);
|
||||
}
|
||||
|
||||
private static String normalize(String structureKey) {
|
||||
return structureKey == null ? "" : structureKey.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -134,7 +134,7 @@ public final class StructurePlacementGrid {
|
||||
return rng.chance(density);
|
||||
}
|
||||
|
||||
private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed) {
|
||||
public static long placementIdentity(IrisStructurePlacement placement) {
|
||||
long signature = 1469598103934665603L;
|
||||
signature = appendLong(signature, placement.getDistribution().ordinal());
|
||||
signature = appendLong(signature, placement.getSalt());
|
||||
@@ -156,6 +156,11 @@ public final class StructurePlacementGrid {
|
||||
signature = appendLong(signature, placement.isUnderwater() ? 1L : 0L);
|
||||
signature = appendSources(signature, placement);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed) {
|
||||
long signature = placementIdentity(placement);
|
||||
int identitySalt = (int) (signature ^ (signature >>> 32));
|
||||
return mix(seed ^ signature, cx, cz, placementSalt(placement) ^ identitySalt);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public final class StructurePlacementScope {
|
||||
private StructurePlacementScope() {
|
||||
@@ -18,19 +21,29 @@ public final class StructurePlacementScope {
|
||||
int blockX = (chunkX << 4) + 8;
|
||||
int blockZ = (chunkZ << 4) + 8;
|
||||
KList<IrisStructurePlacement> placements = new KList<>();
|
||||
Set<IrisStructurePlacement> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
if (complex != null) {
|
||||
IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ);
|
||||
IrisRegion region = complex.getRegionStream().get(blockX, blockZ);
|
||||
if (biome != null && biome.getStructures() != null) {
|
||||
placements.addAll(biome.getStructures());
|
||||
}
|
||||
if (region != null && region.getStructures() != null) {
|
||||
placements.addAll(region.getStructures());
|
||||
}
|
||||
addUnique(placements, seen, biome == null ? null : biome.getStructures());
|
||||
addUnique(placements, seen, region == null ? null : region.getStructures());
|
||||
}
|
||||
if (activeEngine.getDimension() != null && activeEngine.getDimension().getStructures() != null) {
|
||||
placements.addAll(activeEngine.getDimension().getStructures());
|
||||
if (activeEngine.getDimension() != null) {
|
||||
addUnique(placements, seen, activeEngine.getDimension().getStructures());
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
private static void addUnique(KList<IrisStructurePlacement> destination,
|
||||
Set<IrisStructurePlacement> seen,
|
||||
KList<IrisStructurePlacement> source) {
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
for (IrisStructurePlacement placement : source) {
|
||||
if (placement != null && seen.add(placement)) {
|
||||
destination.add(placement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
@Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.")
|
||||
private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl();
|
||||
@ArrayType(type = String.class, min = 1)
|
||||
@Desc("External datapack sources for this dimension. List Modrinth datapack page URLs or direct zip URLs. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
|
||||
@Desc("External datapack sources requested by this dimension. Bukkit installs them into Minecraft's server-wide datapack registry, so native registry contents and conflicts cannot be isolated per dimension. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
|
||||
private KList<String> datapackImports = new KList<>();
|
||||
@MinNumber(0)
|
||||
@MaxNumber(318)
|
||||
|
||||
@@ -22,8 +22,8 @@ public class IrisStructureTerrain {
|
||||
private static final double MAX_EROSION_FREQUENCY = 1D;
|
||||
private static final double MAX_LOBE_FREQUENCY = 1D;
|
||||
|
||||
@Desc("Terrain operation. FORCE_CARVE is authoritative: the requested envelope is cleared before any structure piece is placed. ENCASE is its inverse: the envelope is filled with solid blocks before placement so native shells are not lost to pre-carved air.")
|
||||
private IrisStructureTerrainMode mode = IrisStructureTerrainMode.PRESERVE;
|
||||
@Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FORCE_CARVE clears the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.")
|
||||
private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@@ -43,7 +43,7 @@ public class IrisStructureTerrain {
|
||||
@Desc("Shape used by FORCE_CARVE.")
|
||||
private IrisStructureCarveShape shape = IrisStructureCarveShape.BOX;
|
||||
|
||||
@Desc("Block palette used by ENCASE. When unset, stone is filled at Y 0 and above and deepslate below Y 0.")
|
||||
@Desc("Block palette used by ENCASE. When unset, the Overworld uses stone or deepslate, the Nether uses netherrack, and the End uses end stone.")
|
||||
private IrisMaterialPalette encasePalette = null;
|
||||
|
||||
@MinNumber(0)
|
||||
@@ -67,7 +67,7 @@ public class IrisStructureTerrain {
|
||||
private double lobeStrength = DEFAULT_LOBE_STRENGTH;
|
||||
|
||||
public IrisStructureTerrainMode resolvedMode() {
|
||||
return mode == null ? IrisStructureTerrainMode.PRESERVE : mode;
|
||||
return mode == null ? IrisStructureTerrainMode.SOURCE : mode;
|
||||
}
|
||||
|
||||
public IrisStructureCarveShape resolvedShape() {
|
||||
|
||||
@@ -8,8 +8,6 @@ public enum IrisStructureTerrainMode {
|
||||
PRESERVE,
|
||||
BORE,
|
||||
FORCE_CARVE,
|
||||
SURFACE_FIT,
|
||||
REQUIRE_SUPPORT,
|
||||
VACUUM,
|
||||
|
||||
@Desc("Fills the padded piece volume with solid blocks before any piece is placed so shells, walls, and floors land in solid ground instead of pre-carved air. Only air and liquid cells are filled; existing terrain and structures are never overwritten. Native pieces then carve their own interiors.")
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class IrisVanillaStructureAdjustment {
|
||||
@Desc("Optional foundation columns placed beneath the native structure piece bases after placement.")
|
||||
private IrisStructureStiltSettings stilt = null;
|
||||
|
||||
@Desc("Optional terrain integration override. FORCE_CARVE clears every intersecting chunk before native pieces are placed, ENCASE fills it with solid blocks instead. Left unset, structures whose vanilla terrainAdaptation is BURY or ENCAPSULATE default to ENCASE with 3-block paddings; setting this field disables that default.")
|
||||
@Desc("Optional terrain integration override. FORCE_CARVE clears every intersecting chunk before native pieces are placed, ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.")
|
||||
private IrisStructureTerrain terrain = null;
|
||||
|
||||
public boolean matches(String key) {
|
||||
|
||||
+2
-11
@@ -2,13 +2,8 @@ package art.arcane.iris.engine.object.annotations.functions;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.ListFunction;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.loot.LootTable;
|
||||
import org.bukkit.loot.LootTables;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class LootTableKeyFunction implements ListFunction<KList<String>> {
|
||||
@Override
|
||||
@@ -23,10 +18,6 @@ public class LootTableKeyFunction implements ListFunction<KList<String>> {
|
||||
|
||||
@Override
|
||||
public KList<String> apply(IrisData data) {
|
||||
return Arrays.stream(LootTables.values())
|
||||
.map(LootTables::getLootTable)
|
||||
.map(LootTable::getKey)
|
||||
.map(NamespacedKey::toString)
|
||||
.collect(Collectors.toCollection(KList::new));
|
||||
return new KList<>(IrisPlatforms.get().registries().lootTableKeys());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.MinecraftVersion;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
@@ -40,6 +41,7 @@ import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
@@ -263,7 +265,12 @@ public final class BukkitPlatform implements IrisPlatform {
|
||||
|
||||
@Override
|
||||
public String minecraftVersion() {
|
||||
return Bukkit.getBukkitVersion();
|
||||
return minecraftVersion(Bukkit.getServer());
|
||||
}
|
||||
|
||||
static String minecraftVersion(Server server) {
|
||||
MinecraftVersion detected = MinecraftVersion.detect(server);
|
||||
return detected == null ? null : detected.value();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.loot.LootTables;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -206,6 +207,15 @@ public final class BukkitRegistries implements PlatformRegistries {
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> lootTableKeys() {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (LootTables table : LootTables.values()) {
|
||||
keys.add(table.getKey().toString());
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||
Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>();
|
||||
|
||||
@@ -45,6 +45,21 @@ public final class BukkitStructureHooks implements PlatformStructureHooks {
|
||||
return new ArrayList<>(INMS.get().getTemplatePoolKeys());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JigsawSourceMetadata jigsawSourceMetadata(String structureKey) {
|
||||
return INMS.get().getJigsawSourceMetadata(structureKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int templatePoolHorizontalSpan(String templatePoolKey) {
|
||||
return INMS.get().getTemplatePoolHorizontalSpan(templatePoolKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
|
||||
return INMS.get().getJigsawStartPoolHorizontalSpan(structureKey, templatePoolKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> structureSetKeys() {
|
||||
return new ArrayList<>(INMS.get().getStructureSetKeys());
|
||||
|
||||
+5
-6
@@ -20,6 +20,7 @@ package art.arcane.iris.util.common.director.specialhandlers;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.common.director.DirectorParameterHandler;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
@@ -36,12 +37,10 @@ public class ObjectHandler implements DirectorParameterHandler<String> {
|
||||
return new KList<>(data.getObjectLoader().getPossibleKeys());
|
||||
}
|
||||
|
||||
//noinspection ConstantConditions
|
||||
for (File i : IrisPlatforms.get().dataFolder("packs").listFiles()) {
|
||||
if (i.isDirectory()) {
|
||||
data = IrisData.get(i);
|
||||
p.add(data.getObjectLoader().getPossibleKeys());
|
||||
}
|
||||
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs"))) {
|
||||
data = IrisData.get(i);
|
||||
p.add(data.getObjectLoader().getPossibleKeys());
|
||||
}
|
||||
|
||||
return p;
|
||||
|
||||
+6
-9
@@ -20,6 +20,7 @@ package art.arcane.iris.util.common.director.specialhandlers;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.common.director.DirectorParameterHandler;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
@@ -42,15 +43,11 @@ public class ObjectTargetHandler implements DirectorParameterHandler<String> {
|
||||
}
|
||||
} else {
|
||||
File packsFolder = IrisPlatforms.get().dataFolder("packs");
|
||||
File[] packs = packsFolder.listFiles();
|
||||
if (packs != null) {
|
||||
for (File pack : packs) {
|
||||
if (!pack.isDirectory()) continue;
|
||||
IrisData d = IrisData.get(pack);
|
||||
for (String k : d.getObjectLoader().getPossibleKeys()) {
|
||||
out.add(k);
|
||||
collectPrefixes(k, prefixes);
|
||||
}
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
|
||||
IrisData d = IrisData.get(pack);
|
||||
for (String k : d.getObjectLoader().getPossibleKeys()) {
|
||||
out.add(k);
|
||||
collectPrefixes(k, prefixes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -3,6 +3,7 @@ package art.arcane.iris.util.common.director.specialhandlers;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.common.director.DirectorParameterHandler;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
@@ -34,14 +35,12 @@ public abstract class RegistrantHandler<T extends IrisRegistrant> implements Dir
|
||||
}
|
||||
}
|
||||
|
||||
//noinspection ConstantConditions
|
||||
for (File i : IrisPlatforms.get().dataFolder("packs").listFiles()) {
|
||||
if (i.isDirectory()) {
|
||||
data = IrisData.get(i);
|
||||
for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) {
|
||||
if (known.add(j.getLoadKey()))
|
||||
p.add(j);
|
||||
}
|
||||
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs"))) {
|
||||
data = IrisData.get(i);
|
||||
for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) {
|
||||
if (known.add(j.getLoadKey()))
|
||||
p.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
|
||||
@@ -82,40 +85,71 @@ public final class WebCache {
|
||||
}
|
||||
|
||||
public static File getNonCachedFile(String name, String url) {
|
||||
return getNonCachedFile(name, url, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public static File getNonCachedFile(String name, String url, long maxBytes) {
|
||||
String h = IO.hash(name + "*" + url);
|
||||
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
|
||||
IrisLogging.debug("Download " + name + " -> " + url);
|
||||
download(name, url, f);
|
||||
return f;
|
||||
return download(name, url, f, maxBytes) ? f : null;
|
||||
}
|
||||
|
||||
private static boolean download(String name, String url, File target) {
|
||||
return download(name, url, target, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
private static boolean download(String name, String url, File target, long maxBytes) {
|
||||
if (maxBytes < 1L) {
|
||||
throw new IllegalArgumentException("Download size limit must be positive.");
|
||||
}
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.GET()
|
||||
.build();
|
||||
Path staged = null;
|
||||
try {
|
||||
HttpResponse<InputStream> response = client()
|
||||
.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
try (InputStream discard = response.body()) {
|
||||
discard.readAllBytes();
|
||||
}
|
||||
response.body().close();
|
||||
IrisLogging.reportError(new IOException("HTTP " + response.statusCode()
|
||||
+ " downloading " + name + " from " + url));
|
||||
return false;
|
||||
}
|
||||
long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
||||
if (declaredBytes > maxBytes) {
|
||||
response.body().close();
|
||||
throw new IOException("Download exceeds the size limit for " + name + ".");
|
||||
}
|
||||
Path destination = target.toPath().toAbsolutePath().normalize();
|
||||
Path parent = destination.getParent();
|
||||
if (parent == null) {
|
||||
response.body().close();
|
||||
throw new IOException("Download target has no parent: " + destination);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
staged = Files.createTempFile(parent, ".download-", ".tmp");
|
||||
try (InputStream in = response.body();
|
||||
OutputStream out = Files.newOutputStream(target.toPath(),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long downloadedBytes = 0L;
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
if (read > maxBytes - downloadedBytes) {
|
||||
throw new IOException("Download exceeds the size limit for " + name + ".");
|
||||
}
|
||||
out.write(buffer, 0, read);
|
||||
downloadedBytes += read;
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
try {
|
||||
Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(staged, destination, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
staged = null;
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
@@ -124,6 +158,14 @@ public final class WebCache {
|
||||
Thread.currentThread().interrupt();
|
||||
IrisLogging.reportError(e);
|
||||
return false;
|
||||
} finally {
|
||||
if (staged != null) {
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException cleanupFailure) {
|
||||
IrisLogging.reportError("Failed to clean incomplete download " + staged + ".", cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.project.matter.slices.EntityMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.IdentifierMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.NativeStructureOwnershipMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.SpawnerMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.TileMatter;
|
||||
@@ -61,6 +62,7 @@ public final class IrisMatterSupport {
|
||||
|
||||
IrisMatter.registerSliceType(new EntityMatter());
|
||||
IrisMatter.registerSliceType(new IdentifierMatter());
|
||||
IrisMatter.registerSliceType(new NativeStructureOwnershipMatter());
|
||||
IrisMatter.registerSliceType(new PlatformBlockMatter());
|
||||
IrisMatter.registerSliceType(new SpawnerMatter());
|
||||
IrisMatter.registerSliceType(new TileMatter());
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package art.arcane.iris.util.project.matter.slices;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipBundle;
|
||||
import art.arcane.volmlib.util.data.palette.Palette;
|
||||
import art.arcane.volmlib.util.matter.Sliced;
|
||||
import art.arcane.volmlib.util.matter.slices.RawMatter;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
@Sliced
|
||||
public final class NativeStructureOwnershipMatter extends RawMatter<NativeStructureOwnershipBundle> {
|
||||
public NativeStructureOwnershipMatter() {
|
||||
this(1, 1, 1);
|
||||
}
|
||||
|
||||
public NativeStructureOwnershipMatter(int width, int height, int depth) {
|
||||
super(width, height, depth, NativeStructureOwnershipBundle.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Palette<NativeStructureOwnershipBundle> getGlobalPalette() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNode(NativeStructureOwnershipBundle bundle, DataOutputStream output) throws IOException {
|
||||
bundle.write(output);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeStructureOwnershipBundle readNode(DataInputStream input) throws IOException {
|
||||
return NativeStructureOwnershipBundle.read(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DatapackInstallResultTest {
|
||||
@Test
|
||||
public void failedResultIsNotSuccessfulOrChanged() {
|
||||
DatapackInstallResult result = DatapackInstallResult.failedResult();
|
||||
|
||||
assertFalse(result.succeeded());
|
||||
assertFalse(result.changed());
|
||||
assertFalse(result.restartRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unchangedResultIsSuccessfulWithoutChange() {
|
||||
DatapackInstallResult result = DatapackInstallResult.unchangedResult();
|
||||
|
||||
assertTrue(result.succeeded());
|
||||
assertFalse(result.changed());
|
||||
assertFalse(result.restartRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readyResultIsSuccessfulAndChanged() {
|
||||
DatapackInstallResult result = DatapackInstallResult.readyResult();
|
||||
|
||||
assertTrue(result.succeeded());
|
||||
assertTrue(result.changed());
|
||||
assertFalse(result.restartRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restartResultIsSuccessfulChangedAndPendingRestart() {
|
||||
DatapackInstallResult result = DatapackInstallResult.restartRequiredResult();
|
||||
|
||||
assertTrue(result.succeeded());
|
||||
assertTrue(result.changed());
|
||||
assertTrue(result.restartRequired());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisDatapackCompilerTest {
|
||||
@@ -26,10 +27,15 @@ public class IrisDatapackCompilerTest {
|
||||
Path serverRoot = temporaryFolder.newFolder("server").toPath();
|
||||
createPack(dataDirectory.resolve("packs/alpha"), "alpha", "alpha_custom");
|
||||
createPack(dataDirectory.resolve("packs/beta"), "beta", "beta_custom");
|
||||
createPack(dataDirectory.resolve("packs/.iris-import-stale"), "hidden", "hidden_custom");
|
||||
createPack(serverRoot.resolve("dimensions/example/world/iris/pack"), "world_local", "world_custom");
|
||||
createPack(serverRoot.resolve("dimensions/example/.iris-delete-stale/iris/pack"), "deleted", "deleted_custom");
|
||||
|
||||
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot);
|
||||
Path datapackRoot = temporaryFolder.newFolder("datapack").toPath();
|
||||
Path stale = datapackRoot.resolve("data/iris/dimension_type/removed.json");
|
||||
Files.createDirectories(stale.getParent());
|
||||
Files.writeString(stale, "stale", StandardCharsets.UTF_8);
|
||||
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
|
||||
packRoots,
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
@@ -48,6 +54,9 @@ public class IrisDatapackCompilerTest {
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/hidden.json")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/deleted.json")));
|
||||
assertFalse(Files.exists(stale));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,6 +81,28 @@ public class IrisDatapackCompilerTest {
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compilingNoPacksPublishesCleanEmptyDatapack() throws Exception {
|
||||
Path datapackRoot = temporaryFolder.newFolder("empty-datapack").toPath();
|
||||
Path stale = datapackRoot.resolve("data/iris/dimension_type/removed.json");
|
||||
Files.createDirectories(stale.getParent());
|
||||
Files.writeString(stale, "stale", StandardCharsets.UTF_8);
|
||||
|
||||
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
|
||||
List.of(),
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
new DataFixerV1217(),
|
||||
107,
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals(0, result.packCount());
|
||||
assertEquals(0, result.dimensionCount());
|
||||
assertEquals(0, result.biomeCount());
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
|
||||
assertFalse(Files.exists(stale));
|
||||
}
|
||||
|
||||
private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception {
|
||||
Files.createDirectories(root.resolve("dimensions"));
|
||||
Files.createDirectories(root.resolve("biomes"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.junit.rules.TemporaryFolder;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -44,6 +45,32 @@ public class IrisWorldStorageTest {
|
||||
assertEquals(new NamespacedKey("iris", "iris_world"), IrisWorldStorage.keyFromName("Iris World", "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void managedKeyRejectsMainWorldsAndUnsafePaths() {
|
||||
assertEquals(new NamespacedKey("iris", "iris_world"),
|
||||
IrisWorldStorage.managedKeyFromName("Iris World", "world"));
|
||||
assertEquals(new NamespacedKey("iris", "iris_world"),
|
||||
IrisWorldStorage.managedKeyFromName("iris:iris_world", "world"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> IrisWorldStorage.managedKeyFromName("world", "world"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> IrisWorldStorage.managedKeyFromName("minecraft:overworld", "world"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> IrisWorldStorage.managedKeyFromName("../world", "world"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> IrisWorldStorage.managedKeyFromName("iris:nested/world", "world"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> IrisWorldStorage.managedKeyFromName("iris:unsafe.world", "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitManagedIdentityDoesNotRequireServerLevelLookup() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
|
||||
|
||||
assertEquals(worldKey, IrisWorldStorage.managedKeyFromName(worldKey.toString()));
|
||||
assertEquals("probe", IrisWorldStorage.logicalName(worldKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapsOwnedPaperKeysBackToLogicalWorldNames() {
|
||||
assertEquals("world", IrisWorldStorage.logicalName(NamespacedKey.minecraft("overworld"), "world"));
|
||||
@@ -90,4 +117,16 @@ public class IrisWorldStorageTest {
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> IrisWorldStorage.dimensionRoot(levelRoot, key));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void safeManagedDimensionRootRejectsSymlinkedStorage() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("managed-world");
|
||||
Path dimensions = Files.createDirectories(levelRoot.toPath().resolve("dimensions"));
|
||||
Path outside = temporaryFolder.newFolder("outside").toPath();
|
||||
Files.createSymbolicLink(dimensions.resolve("iris"), outside);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
levelRoot,
|
||||
new NamespacedKey("iris", "probe")));
|
||||
}
|
||||
}
|
||||
+97
-8
@@ -2,14 +2,22 @@ package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.Assume;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class ServerConfiguratorDatapackFingerprintTest {
|
||||
@@ -17,12 +25,7 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
public TemporaryFolder tmp = new TemporaryFolder();
|
||||
|
||||
private Method fingerprintMethod() throws Exception {
|
||||
try {
|
||||
return ServerConfigurator.class.getMethod("computePackFingerprint", File.class);
|
||||
} catch (NoSuchMethodException e) {
|
||||
fail("ServerConfigurator.computePackFingerprint(File) does not exist yet — implement it in Task 2");
|
||||
throw e;
|
||||
}
|
||||
return ServerConfigurator.class.getMethod("computePackFingerprint", File.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,7 +44,7 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintChangesWhenFileIsModified() throws Exception {
|
||||
public void computePackFingerprintIgnoresMetadataOnlyChanges() throws Exception {
|
||||
Method method = fingerprintMethod();
|
||||
File packsDir = tmp.newFolder("packs");
|
||||
File dimFile = new File(packsDir, "testpack/dimensions/overworld.json");
|
||||
@@ -52,7 +55,23 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
dimFile.setLastModified(dimFile.lastModified() + 2000L);
|
||||
String fp2 = (String) method.invoke(null, packsDir);
|
||||
|
||||
assertNotEquals("A modified file must produce a different fingerprint", fp1, fp2);
|
||||
assertEquals("Metadata-only changes must not alter a content fingerprint", fp1, fp2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintDetectsEqualSizeContentWithRestoredMtime() throws Exception {
|
||||
File packsDir = tmp.newFolder("content-packs");
|
||||
Path dimension = packsDir.toPath().resolve("testpack/dimensions/overworld.json");
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8);
|
||||
FileTime originalMtime = Files.getLastModifiedTime(dimension);
|
||||
String before = ServerConfigurator.computePackFingerprint(packsDir);
|
||||
|
||||
Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(dimension, originalMtime);
|
||||
String after = ServerConfigurator.computePackFingerprint(packsDir);
|
||||
|
||||
assertNotEquals("Equal-size content changes must alter the fingerprint", before, after);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,4 +90,74 @@ public class ServerConfiguratorDatapackFingerprintTest {
|
||||
|
||||
assertNotEquals("Adding a file must produce a different fingerprint", fp1, fp2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintExcludesHiddenTransactionStages() throws Exception {
|
||||
File packsDir = tmp.newFolder("hidden-packs");
|
||||
Path visible = packsDir.toPath().resolve("testpack/dimensions/overworld.json");
|
||||
Path hidden = packsDir.toPath().resolve(".iris-import-123/dimensions/overworld.json");
|
||||
Files.createDirectories(visible.getParent());
|
||||
Files.createDirectories(hidden.getParent());
|
||||
Files.writeString(visible, "visible", StandardCharsets.UTF_8);
|
||||
Files.writeString(hidden, "stage-one", StandardCharsets.UTF_8);
|
||||
String before = ServerConfigurator.computePackFingerprint(packsDir);
|
||||
|
||||
Files.writeString(hidden, "stage-two", StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintRejectsSymbolicLinks() throws Exception {
|
||||
File packsDir = tmp.newFolder("unsafe-packs");
|
||||
Path pack = packsDir.toPath().resolve("testpack");
|
||||
Path outside = tmp.newFile("outside.json").toPath();
|
||||
Files.createDirectories(pack);
|
||||
Path link = pack.resolve("linked.json");
|
||||
try {
|
||||
Files.createSymbolicLink(link, outside);
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
try {
|
||||
ServerConfigurator.computePackFingerprint(packsDir);
|
||||
fail("Symbolic links must be rejected");
|
||||
} catch (UncheckedIOException expected) {
|
||||
assertTrue(expected.getMessage().contains("fingerprint"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computePackFingerprintReadsSafeSymbolicPackRoots() throws Exception {
|
||||
File packsDir = tmp.newFolder("linked-root-packs");
|
||||
Path externalPack = tmp.newFolder("linked-pack").toPath();
|
||||
Path dimension = externalPack.resolve("dimensions/overworld.json");
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, "first", StandardCharsets.UTF_8);
|
||||
Path link = packsDir.toPath().resolve("overworld");
|
||||
try {
|
||||
Files.createSymbolicLink(link, externalPack);
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
String before = ServerConfigurator.computePackFingerprint(packsDir);
|
||||
|
||||
Files.writeString(dimension, "other", StandardCharsets.UTF_8);
|
||||
|
||||
assertNotEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void incompleteExternalDatapackRecoveryBlocksCompilation() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/ServerConfigurator.java"));
|
||||
int recovery = source.indexOf("if (!DatapackIngestService.reapplyFromStaging(datapacksFolders))");
|
||||
int blocked = source.indexOf("return DatapackInstallResult.failedResult();", recovery);
|
||||
int compile = source.indexOf("IrisDatapackCompiler.compile(", recovery);
|
||||
|
||||
assertTrue(recovery >= 0);
|
||||
assertTrue(blocked > recovery);
|
||||
assertTrue(compile > blocked);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class ModrinthResolverTest {
|
||||
@Test
|
||||
public void latestVersionMustMatchServerVersion() {
|
||||
JsonArray versions = JsonParser.parseString("""
|
||||
[
|
||||
{"id":"new","loaders":["datapack"],"game_versions":["26.3"]},
|
||||
{"id":"match","loaders":["datapack"],"game_versions":["26.2"]}
|
||||
]
|
||||
""").getAsJsonArray();
|
||||
|
||||
JsonObject selected = ModrinthResolver.selectLatestDatapackVersion(versions, "26.2");
|
||||
|
||||
assertEquals("match", selected.get("id").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void incompatibleLatestVersionDoesNotFallBack() {
|
||||
JsonArray versions = JsonParser.parseString("""
|
||||
[{"id":"wrong","loaders":["datapack"],"game_versions":["26.3"]}]
|
||||
""").getAsJsonArray();
|
||||
|
||||
assertNull(ModrinthResolver.selectLatestDatapackVersion(versions, "26.2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedVersionElementsAreIgnored() {
|
||||
JsonArray versions = JsonParser.parseString("""
|
||||
[
|
||||
null,
|
||||
12,
|
||||
{"id":"wrong-loaders","loaders":{},"game_versions":["26.2"]},
|
||||
{"id":"wrong-loader-element","loaders":[{}],"game_versions":["26.2"]},
|
||||
{"id":"wrong-versions","loaders":["datapack"],"game_versions":{}},
|
||||
{"id":"wrong-version-element","loaders":["datapack"],"game_versions":[{}]},
|
||||
{"id":"match","loaders":["datapack"],"game_versions":["26.2"]}
|
||||
]
|
||||
""").getAsJsonArray();
|
||||
|
||||
JsonObject selected = ModrinthResolver.selectLatestDatapackVersion(versions, "26.2");
|
||||
|
||||
assertEquals("match", selected.get("id").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedFileElementsAreIgnored() {
|
||||
JsonObject version = JsonParser.parseString("""
|
||||
{
|
||||
"files": [
|
||||
null,
|
||||
"not-an-object",
|
||||
{"primary": {}, "filename": {}, "url": {}},
|
||||
{"primary": true, "filename": "pack.zip", "url": "https://example.test/pack.zip"}
|
||||
]
|
||||
}
|
||||
""").getAsJsonObject();
|
||||
|
||||
JsonObject selected = ModrinthResolver.selectFile(version);
|
||||
|
||||
assertEquals("pack.zip", selected.get("filename").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedFilesCollectionReturnsNoFile() {
|
||||
JsonObject version = JsonParser.parseString("{\"files\": {}}").getAsJsonObject();
|
||||
|
||||
assertNull(ModrinthResolver.selectFile(version));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void directUrlsWithSameFilenameHaveDistinctIdentities() throws Exception {
|
||||
ModrinthResolver.ResolvedDatapack first = ModrinthResolver.resolve("https://one.example/files/pack.zip", "26.2");
|
||||
ModrinthResolver.ResolvedDatapack second = ModrinthResolver.resolve("https://two.example/files/pack.zip", "26.2");
|
||||
|
||||
assertTrue(first.isDirect());
|
||||
assertTrue(second.isDirect());
|
||||
assertNotEquals(first.getVersionId(), second.getVersionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void modrinthTextOutsideTheHostDoesNotTriggerApiResolution() throws Exception {
|
||||
ModrinthResolver.ResolvedDatapack resolved = ModrinthResolver.resolve(
|
||||
"https://example.test/modrinth.com/datapack/not-a-project", "26.2");
|
||||
|
||||
assertTrue(resolved.isDirect());
|
||||
assertFalse(resolved.getVersionId().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception {
|
||||
String response = "1234\n5678";
|
||||
|
||||
assertEquals(response, ModrinthResolver.readBoundedApiResponse(
|
||||
new StringReader(response), "test response", response.length()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedApiReaderRejectsAnUnbrokenResponseBeforeReadingPastItsFirstBoundedChunk() throws Exception {
|
||||
Reader response = new UnbrokenResponseReader();
|
||||
|
||||
try {
|
||||
ModrinthResolver.readBoundedApiResponse(response, "test response", 32);
|
||||
fail("Expected oversized response rejection");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("Oversized response"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnbrokenResponseReader extends Reader {
|
||||
private boolean read;
|
||||
|
||||
@Override
|
||||
public int read(char[] buffer, int offset, int length) throws IOException {
|
||||
if (read) {
|
||||
throw new IOException("Reader continued after the bounded chunk");
|
||||
}
|
||||
read = true;
|
||||
Arrays.fill(buffer, offset, offset + length, 'x');
|
||||
return length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BukkitWorldConfigurationTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void registersAndRemovesWorldAtomically() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.Registration.CREATED,
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L));
|
||||
assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED,
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L));
|
||||
|
||||
YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertEquals("Iris:overworld", loaded.getString("worlds.probe.generator"));
|
||||
assertEquals(1337L, loaded.getLong("worlds.probe.seed"));
|
||||
assertTrue(BukkitWorldConfiguration.remove(configuration, "probe"));
|
||||
assertFalse(BukkitWorldConfiguration.remove(configuration, "probe"));
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).getConfigurationSection("worlds"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conflictingRegistrationLeavesOriginalUntouched() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> BukkitWorldConfiguration.register(configuration, "probe", "theend", 42L));
|
||||
|
||||
assertTrue(failure.getMessage().contains("different definition"));
|
||||
YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertEquals("Iris:overworld", loaded.getString("worlds.probe.generator"));
|
||||
assertEquals(1337L, loaded.getLong("worlds.probe.seed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedConfigurationIsPreserved() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
String malformed = "worlds: [unterminated";
|
||||
Files.writeString(configuration.toPath(), malformed);
|
||||
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L));
|
||||
|
||||
assertTrue(failure.getMessage().contains("invalid"));
|
||||
assertEquals(malformed, Files.readString(configuration.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsafeWorldNameIsRejected() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> BukkitWorldConfiguration.register(configuration, "nested.world", "overworld", 1337L));
|
||||
assertEquals(0L, configuration.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingRemovalIsCommittedOnce() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "iris-one", "overworld", null);
|
||||
BukkitWorldConfiguration.register(configuration, "iris-two", "overworld", null);
|
||||
BukkitWorldConfiguration.register(configuration, "persistent", "overworld", null);
|
||||
|
||||
assertEquals(2, BukkitWorldConfiguration.removeMatching(
|
||||
configuration,
|
||||
worldName -> worldName.startsWith("iris-")));
|
||||
|
||||
YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertNull(loaded.get("worlds.iris-one"));
|
||||
assertNull(loaded.get("worlds.iris-two"));
|
||||
assertEquals("Iris:overworld", loaded.getString("worlds.persistent.generator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conditionalRemovalPreservesChangedRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
|
||||
assertFalse(BukkitWorldConfiguration.removeIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
"overworld",
|
||||
42L));
|
||||
assertFalse(BukkitWorldConfiguration.removeIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
"theend",
|
||||
1337L));
|
||||
|
||||
YamlConfiguration preserved = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertEquals("Iris:overworld", preserved.getString("worlds.probe.generator"));
|
||||
assertEquals(1337L, preserved.getLong("worlds.probe.seed"));
|
||||
assertTrue(BukkitWorldConfiguration.removeIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
"overworld",
|
||||
1337L));
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.WorldRemovalPathPolicy;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisWorldRemovalServiceTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void reportsBusyOperationWithoutStartingBackend() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("busy"));
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
try (LifecycleOperationCoordinator.Lease existing = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"other"
|
||||
)) {
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("busy", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, result.status());
|
||||
assertTrue(result.busy());
|
||||
assertEquals(existing.operation(), result.blockingOperation());
|
||||
assertFalse(backend.resolvedTarget);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void holdsLeaseUntilEveryAsyncPhaseCompletes() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("serialized"));
|
||||
CompletableFuture<Void> evacuation = new CompletableFuture<>();
|
||||
backend.evacuation = evacuation;
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
CompletableFuture<IrisWorldRemovalService.RemovalResult> pending = service.remove("serialized", false);
|
||||
IrisWorldRemovalService.RemovalResult busy = service.remove("second", false).join();
|
||||
|
||||
assertFalse(pending.isDone());
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, busy.status());
|
||||
evacuation.complete(null);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = pending.join();
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.UNREGISTERED, result.status());
|
||||
assertTrue(result.succeeded());
|
||||
assertTrue(result.registryChanged());
|
||||
assertEquals(
|
||||
List.of("resolve", "begin", "evacuate", "unload", "close", "unregister", "unregisterRegistry", "end"),
|
||||
backend.operations
|
||||
);
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationFailureStopsBeforeDeletionAndRetainsCause() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("configuration-failure"));
|
||||
IOException failure = new IOException("bukkit.yml is read-only");
|
||||
backend.unregister = CompletableFuture.completedFuture(
|
||||
new IrisWorldRemovalService.ConfigurationDisposition(false, failure)
|
||||
);
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("configuration-failure", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.CONFIGURATION_FAILED, result.status());
|
||||
assertSame(failure, result.failure());
|
||||
assertFalse(result.succeeded());
|
||||
assertFalse(backend.operations.contains("delete"));
|
||||
assertFalse(backend.operations.contains("unregisterRegistry"));
|
||||
assertFalse(result.registryChanged());
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedDeletionIsSuccessfulAfterRegistryRemoval() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
WorldRemovalPathPolicy.Target target = target("queued");
|
||||
FakeBackend backend = new FakeBackend(target);
|
||||
Path quarantine = target.worldDirectory().resolveSibling(".iris-delete-test");
|
||||
backend.deletion = CompletableFuture.completedFuture(
|
||||
new IrisWorldRemovalService.DeleteDisposition(true, quarantine)
|
||||
);
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("queued", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.DELETE_QUEUED, result.status());
|
||||
assertTrue(result.succeeded());
|
||||
assertTrue(result.deletionDeferred());
|
||||
assertEquals(quarantine, result.quarantineDirectory());
|
||||
assertTrue(result.registryChanged());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registryFailureStopsBeforeFilesystemDeletion() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("registry-failure"));
|
||||
IOException failure = new IOException("worlds.json is read-only");
|
||||
backend.unregisterRegistry = CompletableFuture.failedFuture(failure);
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("registry-failure", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.REGISTRY_FAILED, result.status());
|
||||
assertSame(failure, result.failure());
|
||||
assertTrue(result.configurationChanged());
|
||||
assertFalse(result.registryChanged());
|
||||
assertFalse(backend.operations.contains("delete"));
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deletionFailureRetainsCompletedUnregistrationState() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
WorldRemovalPathPolicy.Target target = target("delete-failure");
|
||||
FakeBackend backend = new FakeBackend(target);
|
||||
IOException failure = new IOException("cannot quarantine directory");
|
||||
backend.deletion = CompletableFuture.failedFuture(failure);
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("delete-failure", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.QUARANTINE_FAILED, result.status());
|
||||
assertSame(failure, result.failure());
|
||||
assertTrue(result.configurationChanged());
|
||||
assertTrue(result.registryChanged());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unloadRefusalLeavesGeneratorOpenAndEndsMaintenance() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("unload-refused"));
|
||||
backend.unload = CompletableFuture.completedFuture(false);
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service.remove("unload-refused", true).join();
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.UNLOAD_FAILED, result.status());
|
||||
assertFalse(backend.operations.contains("close"));
|
||||
assertFalse(backend.operations.contains("unregister"));
|
||||
assertEquals("end", backend.operations.get(backend.operations.size() - 1));
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timeoutInstallsRestartFenceBeforeResultAndRejectsLateContinuation() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("timeout-fence"));
|
||||
NonCancellableFuture<Boolean> unload = new NonCancellableFuture<>();
|
||||
backend.unload = unload;
|
||||
AtomicInteger restartDispatches = new AtomicInteger();
|
||||
AtomicBoolean fenceInstalledBeforeResult = new AtomicBoolean(false);
|
||||
AtomicReference<CompletableFuture<IrisWorldRemovalService.RemovalResult>> resultReference =
|
||||
new AtomicReference<>();
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(
|
||||
coordinator,
|
||||
new IrisWorldRemovalService.ServiceOptions(
|
||||
backend,
|
||||
100L,
|
||||
reason -> {
|
||||
assertTrue(coordinator.quiesceForRestart(restartDispatches::incrementAndGet));
|
||||
CompletableFuture<IrisWorldRemovalService.RemovalResult> result = resultReference.get();
|
||||
fenceInstalledBeforeResult.set(
|
||||
coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE).isPresent()
|
||||
&& (result == null || !result.isDone()));
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
CompletableFuture<IrisWorldRemovalService.RemovalResult> pending =
|
||||
service.remove("timeout-fence", false);
|
||||
resultReference.set(pending);
|
||||
IrisWorldRemovalService.RemovalResult result = pending.get(2L, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.UNLOAD_FAILED, result.status());
|
||||
assertTrue(fenceInstalledBeforeResult.get());
|
||||
assertEquals(1, restartDispatches.get());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
|
||||
coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE)
|
||||
.orElseThrow()
|
||||
.kind());
|
||||
|
||||
IrisWorldRemovalService.RemovalResult blocked = service.remove("second", false).join();
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, blocked.status());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
|
||||
blocked.blockingOperation().kind());
|
||||
|
||||
unload.complete(true);
|
||||
assertFalse(backend.operations.contains("close"));
|
||||
assertFalse(backend.operations.contains("unregister"));
|
||||
assertFalse(backend.operations.contains("delete"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalTimeoutStopsDeletionAfterIntentCompletion() throws Exception {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
FakeBackend backend = new FakeBackend(target("delete-terminal"));
|
||||
backend.stageDeletion = true;
|
||||
AtomicInteger restartDispatches = new AtomicInteger();
|
||||
IrisWorldRemovalService service = new IrisWorldRemovalService(
|
||||
coordinator,
|
||||
new IrisWorldRemovalService.ServiceOptions(
|
||||
backend,
|
||||
100L,
|
||||
reason -> coordinator.quiesceForRestart(restartDispatches::incrementAndGet)
|
||||
)
|
||||
);
|
||||
|
||||
IrisWorldRemovalService.RemovalResult result = service
|
||||
.remove("delete-terminal", true)
|
||||
.get(2L, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(IrisWorldRemovalService.RemovalStatus.QUARANTINE_FAILED, result.status());
|
||||
assertTrue(backend.operations.contains("deleteIntent"));
|
||||
assertEquals(1, restartDispatches.get());
|
||||
|
||||
backend.deletionIntent.complete(null);
|
||||
assertFalse(backend.operations.contains("quarantine"));
|
||||
assertFalse(backend.operations.contains("deleteQuarantine"));
|
||||
}
|
||||
|
||||
private WorldRemovalPathPolicy.Target target(String identifier) throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder(identifier + "-level").toPath();
|
||||
return WorldRemovalPathPolicy.resolve(identifier, "production", levelRoot);
|
||||
}
|
||||
|
||||
private static final class FakeBackend implements IrisWorldRemovalService.RemovalBackend {
|
||||
private final WorldRemovalPathPolicy.Target target;
|
||||
private final List<String> operations;
|
||||
private boolean resolvedTarget;
|
||||
private CompletableFuture<Void> evacuation;
|
||||
private CompletableFuture<Boolean> unload;
|
||||
private CompletableFuture<IrisWorldRemovalService.ConfigurationDisposition> unregister;
|
||||
private CompletableFuture<Boolean> unregisterRegistry;
|
||||
private CompletableFuture<IrisWorldRemovalService.DeleteDisposition> deletion;
|
||||
private final NonCancellableFuture<Void> deletionIntent;
|
||||
private boolean stageDeletion;
|
||||
|
||||
private FakeBackend(WorldRemovalPathPolicy.Target target) {
|
||||
this.target = target;
|
||||
operations = new ArrayList<>();
|
||||
evacuation = CompletableFuture.completedFuture(null);
|
||||
unload = CompletableFuture.completedFuture(true);
|
||||
unregister = CompletableFuture.completedFuture(
|
||||
new IrisWorldRemovalService.ConfigurationDisposition(true, null)
|
||||
);
|
||||
unregisterRegistry = CompletableFuture.completedFuture(true);
|
||||
deletion = CompletableFuture.completedFuture(
|
||||
new IrisWorldRemovalService.DeleteDisposition(false, null)
|
||||
);
|
||||
deletionIntent = new NonCancellableFuture<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldRemovalPathPolicy.Target resolveTarget(String identifier) {
|
||||
resolvedTarget = true;
|
||||
return target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<IrisWorldRemovalService.ResolvedWorld> resolve(
|
||||
WorldRemovalPathPolicy.Target ignored
|
||||
) {
|
||||
operations.add("resolve");
|
||||
return CompletableFuture.completedFuture(new IrisWorldRemovalService.ResolvedWorld(
|
||||
target,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> evacuatePlayers(IrisWorldRemovalService.ResolvedWorld resolvedWorld) {
|
||||
operations.add("evacuate");
|
||||
return evacuation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> beginMaintenance(IrisWorldRemovalService.ResolvedWorld resolvedWorld) {
|
||||
operations.add("begin");
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> endMaintenance(IrisWorldRemovalService.ResolvedWorld resolvedWorld) {
|
||||
operations.add("end");
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> closeGenerator(IrisWorldRemovalService.ResolvedWorld resolvedWorld) {
|
||||
operations.add("close");
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> unloadWorld(IrisWorldRemovalService.ResolvedWorld resolvedWorld) {
|
||||
operations.add("unload");
|
||||
return unload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<IrisWorldRemovalService.ConfigurationDisposition> unregister(
|
||||
WorldRemovalPathPolicy.Target ignored,
|
||||
BooleanSupplier terminal
|
||||
) {
|
||||
operations.add("unregister");
|
||||
return unregister;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> unregisterRegistry(
|
||||
WorldRemovalPathPolicy.Target ignored,
|
||||
BooleanSupplier terminal
|
||||
) {
|
||||
operations.add("unregisterRegistry");
|
||||
return unregisterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<IrisWorldRemovalService.DeleteDisposition> delete(
|
||||
WorldRemovalPathPolicy.Target ignored,
|
||||
BooleanSupplier terminal
|
||||
) {
|
||||
operations.add("delete");
|
||||
if (stageDeletion) {
|
||||
operations.add("deleteIntent");
|
||||
NonCancellableFuture<IrisWorldRemovalService.DeleteDisposition> result =
|
||||
new NonCancellableFuture<>();
|
||||
deletionIntent.whenComplete((ignoredValue, throwable) -> {
|
||||
if (throwable != null) {
|
||||
result.completeExceptionally(throwable);
|
||||
return;
|
||||
}
|
||||
if (terminal.getAsBoolean()) {
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"terminal after deletion intent"));
|
||||
return;
|
||||
}
|
||||
operations.add("quarantine");
|
||||
if (terminal.getAsBoolean()) {
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"terminal after quarantine"));
|
||||
return;
|
||||
}
|
||||
operations.add("deleteQuarantine");
|
||||
result.complete(new IrisWorldRemovalService.DeleteDisposition(false, null));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return deletion;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NonCancellableFuture<T> extends CompletableFuture<T> {
|
||||
@Override
|
||||
public boolean cancel(boolean mayInterruptIfRunning) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class LifecycleOperationCoordinatorTest {
|
||||
@Test
|
||||
public void getReturnsSingletonInstance() {
|
||||
assertSame(LifecycleOperationCoordinator.get(), LifecycleOperationCoordinator.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateDomainIsRejectedWithCurrentOperationMetadata() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease lease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
|
||||
LifecycleOperationCoordinator.BusyException exception = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"world-two"));
|
||||
|
||||
assertEquals(lease.operation(), exception.currentOperation());
|
||||
assertEquals(lease.operation().id(), exception.operationId());
|
||||
assertEquals(LifecycleOperationCoordinator.Domain.WORLD_MUTATION, exception.domain());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, exception.operationKind());
|
||||
assertEquals("world-one", exception.target());
|
||||
lease.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void differentDomainsConflictWithCurrentOperationMetadata() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease worldLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
|
||||
LifecycleOperationCoordinator.BusyException exception = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_CREATE,
|
||||
"pack-one"));
|
||||
|
||||
Map<LifecycleOperationCoordinator.Domain, LifecycleOperationCoordinator.ActiveOperation> snapshot = coordinator.snapshot();
|
||||
assertEquals(1, snapshot.size());
|
||||
assertEquals(worldLease.operation(), snapshot.get(LifecycleOperationCoordinator.Domain.WORLD_MUTATION));
|
||||
assertEquals(Optional.of(worldLease.operation()), coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION));
|
||||
assertEquals(Optional.empty(), coordinator.active(LifecycleOperationCoordinator.Domain.PACK_MUTATION));
|
||||
assertEquals(worldLease.operation(), exception.currentOperation());
|
||||
assertFalse(coordinator.isIdle());
|
||||
|
||||
worldLease.close();
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeReleasesDomainForNextOperation() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease firstLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_CREATE,
|
||||
"pack-one");
|
||||
firstLease.close();
|
||||
|
||||
LifecycleOperationCoordinator.Lease secondLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
|
||||
"pack-two");
|
||||
|
||||
assertTrue(firstLease.isClosed());
|
||||
assertFalse(secondLease.isClosed());
|
||||
assertTrue(secondLease.operation().id() > firstLease.operation().id());
|
||||
secondLease.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingLeaseTwiceIsHarmless() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease lease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"world-one");
|
||||
|
||||
lease.close();
|
||||
lease.close();
|
||||
|
||||
assertTrue(lease.isClosed());
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void idleCallbackRunsImmediatelyExactlyOnce() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
AtomicInteger callbackCount = new AtomicInteger();
|
||||
|
||||
coordinator.whenIdle(callbackCount::incrementAndGet);
|
||||
|
||||
assertEquals(1, callbackCount.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void idleCallbackWaitsForActiveMutation() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease worldLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
AtomicInteger callbackCount = new AtomicInteger();
|
||||
|
||||
coordinator.whenIdle(callbackCount::incrementAndGet);
|
||||
assertEquals(0, callbackCount.get());
|
||||
worldLease.close();
|
||||
assertEquals(1, callbackCount.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void idleCallbackAcquiresBeforeACompetingMutationCanEnter() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease initialLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
AtomicReference<LifecycleOperationCoordinator.Lease> callbackLease = new AtomicReference<>();
|
||||
coordinator.whenIdle(() -> callbackLease.set(coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_CREATE,
|
||||
"pack-one")));
|
||||
|
||||
initialLease.close();
|
||||
|
||||
LifecycleOperationCoordinator.Lease reservedLease = callbackLease.get();
|
||||
assertEquals(LifecycleOperationCoordinator.Domain.PACK_MUTATION, reservedLease.operation().domain());
|
||||
LifecycleOperationCoordinator.BusyException exception = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"world-two"));
|
||||
assertEquals(reservedLease.operation(), exception.currentOperation());
|
||||
reservedLease.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void quiesceBlocksNewMutationsBeforeDrainAndThroughRestartDispatch() throws InterruptedException {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease activeLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
CountDownLatch dispatchStarted = new CountDownLatch(1);
|
||||
CountDownLatch allowDispatchCompletion = new CountDownLatch(1);
|
||||
|
||||
assertTrue(coordinator.quiesceForRestart(() -> {
|
||||
dispatchStarted.countDown();
|
||||
await(allowDispatchCompletion);
|
||||
}));
|
||||
|
||||
LifecycleOperationCoordinator.BusyException drainingException = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
|
||||
"pack-one"));
|
||||
assertEquals(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE, drainingException.domain());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART, drainingException.operationKind());
|
||||
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
executor.execute(activeLease::close);
|
||||
assertTrue(dispatchStarted.await(5L, TimeUnit.SECONDS));
|
||||
|
||||
LifecycleOperationCoordinator.BusyException dispatchException = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"world-two"));
|
||||
assertEquals(drainingException.currentOperation(), dispatchException.currentOperation());
|
||||
|
||||
allowDispatchCompletion.countDown();
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertFalse(coordinator.isIdle());
|
||||
assertEquals(Optional.of(drainingException.currentOperation()),
|
||||
coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE));
|
||||
assertFalse(coordinator.quiesceForRestart(() -> {
|
||||
throw new AssertionError("duplicate restart callback must not run");
|
||||
}));
|
||||
LifecycleOperationCoordinator.BusyException terminalException = assertThrows(
|
||||
LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH,
|
||||
"pack-two"));
|
||||
assertEquals(drainingException.currentOperation(), terminalException.currentOperation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentIdleCallbacksEachRunExactlyOnce() throws InterruptedException {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease lease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
int callbackTotal = 32;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch registered = new CountDownLatch(callbackTotal);
|
||||
AtomicIntegerArray callbackCounts = new AtomicIntegerArray(callbackTotal);
|
||||
|
||||
for (int callbackIndex = 0; callbackIndex < callbackTotal; callbackIndex++) {
|
||||
int registeredIndex = callbackIndex;
|
||||
executor.execute(() -> {
|
||||
await(start);
|
||||
coordinator.whenIdle(() -> callbackCounts.incrementAndGet(registeredIndex));
|
||||
registered.countDown();
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
assertTrue(registered.await(5L, TimeUnit.SECONDS));
|
||||
lease.close();
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
|
||||
for (int callbackIndex = 0; callbackIndex < callbackTotal; callbackIndex++) {
|
||||
assertEquals(1, callbackCounts.get(callbackIndex));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentDoubleCloseReleasesAndSignalsIdleOnce() throws InterruptedException {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease lease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH,
|
||||
"pack-one");
|
||||
AtomicInteger callbackCount = new AtomicInteger();
|
||||
coordinator.whenIdle(callbackCount::incrementAndGet);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
|
||||
for (int threadIndex = 0; threadIndex < 16; threadIndex++) {
|
||||
executor.execute(() -> {
|
||||
await(start);
|
||||
lease.close();
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(coordinator.isIdle());
|
||||
assertEquals(1, callbackCount.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failingIdleCallbackDoesNotBlockLeaseReleaseOrLaterCallbacks() {
|
||||
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
|
||||
LifecycleOperationCoordinator.Lease lease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
"world-one");
|
||||
AtomicInteger callbackCount = new AtomicInteger();
|
||||
coordinator.whenIdle(() -> {
|
||||
throw new IllegalStateException("expected");
|
||||
});
|
||||
coordinator.whenIdle(callbackCount::incrementAndGet);
|
||||
|
||||
lease.close();
|
||||
|
||||
assertTrue(coordinator.isIdle());
|
||||
assertEquals(1, callbackCount.get());
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WorldLifecycleUnloadAsyncTest {
|
||||
@Test
|
||||
public void reflectedAsyncUnloadWaitsForTrueCallback() throws Exception {
|
||||
CallbackServer server = new CallbackServer();
|
||||
Method unloadMethod = CallbackServer.class.getMethod(
|
||||
"unloadWorldAsync",
|
||||
World.class,
|
||||
boolean.class,
|
||||
Consumer.class
|
||||
);
|
||||
|
||||
CompletableFuture<Boolean> result = WorldLifecycleSupport.invokeAsyncUnload(
|
||||
server,
|
||||
unloadMethod,
|
||||
mock(World.class),
|
||||
false
|
||||
);
|
||||
|
||||
assertFalse(result.isDone());
|
||||
server.complete(true);
|
||||
assertTrue(result.join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reflectedAsyncUnloadWaitsForFalseCallback() throws Exception {
|
||||
CallbackServer server = new CallbackServer();
|
||||
Method unloadMethod = CallbackServer.class.getMethod(
|
||||
"unloadWorldAsync",
|
||||
World.class,
|
||||
boolean.class,
|
||||
Consumer.class
|
||||
);
|
||||
|
||||
CompletableFuture<Boolean> result = WorldLifecycleSupport.invokeAsyncUnload(
|
||||
server,
|
||||
unloadMethod,
|
||||
mock(World.class),
|
||||
true
|
||||
);
|
||||
|
||||
assertFalse(result.isDone());
|
||||
server.complete(false);
|
||||
assertFalse(result.join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reflectedAsyncUnloadPropagatesInvocationFailure() throws Exception {
|
||||
FailingServer server = new FailingServer();
|
||||
Method unloadMethod = FailingServer.class.getMethod(
|
||||
"unloadWorldAsync",
|
||||
World.class,
|
||||
boolean.class,
|
||||
Consumer.class
|
||||
);
|
||||
|
||||
CompletableFuture<Boolean> result = WorldLifecycleSupport.invokeAsyncUnload(
|
||||
server,
|
||||
unloadMethod,
|
||||
mock(World.class),
|
||||
false
|
||||
);
|
||||
|
||||
CompletionException failure = assertThrows(CompletionException.class, result::join);
|
||||
assertTrue(failure.getCause() instanceof IllegalStateException);
|
||||
assertEquals("unload failed", failure.getCause().getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serviceRemovesRememberedBackendOnlyAfterTrueCompletion() {
|
||||
ControlledBackend remembered = new ControlledBackend("remembered");
|
||||
ControlledBackend fallback = new ControlledBackend("fallback");
|
||||
WorldLifecycleService service = service(remembered, fallback);
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "async_true");
|
||||
World world = world(worldKey);
|
||||
service.rememberBackend(worldKey, remembered.backendName());
|
||||
|
||||
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
|
||||
|
||||
assertFalse(result.isDone());
|
||||
assertEquals("remembered", service.backendNameForWorld(worldKey));
|
||||
remembered.complete(true);
|
||||
assertTrue(result.join());
|
||||
assertEquals("fallback", service.backendNameForWorld(worldKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serviceRetainsRememberedBackendAfterFalseCompletion() {
|
||||
ControlledBackend remembered = new ControlledBackend("remembered");
|
||||
ControlledBackend fallback = new ControlledBackend("fallback");
|
||||
WorldLifecycleService service = service(remembered, fallback);
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "async_false");
|
||||
World world = world(worldKey);
|
||||
service.rememberBackend(worldKey, remembered.backendName());
|
||||
|
||||
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
|
||||
remembered.complete(false);
|
||||
|
||||
assertFalse(result.join());
|
||||
assertEquals("remembered", service.backendNameForWorld(worldKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serviceRetainsRememberedBackendAfterExceptionalCompletion() {
|
||||
ControlledBackend remembered = new ControlledBackend("remembered");
|
||||
ControlledBackend fallback = new ControlledBackend("fallback");
|
||||
WorldLifecycleService service = service(remembered, fallback);
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "async_failure");
|
||||
World world = world(worldKey);
|
||||
service.rememberBackend(worldKey, remembered.backendName());
|
||||
|
||||
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
|
||||
remembered.fail(new IllegalStateException("delayed failure"));
|
||||
|
||||
CompletionException failure = assertThrows(CompletionException.class, result::join);
|
||||
assertEquals("delayed failure", failure.getCause().getMessage());
|
||||
assertEquals("remembered", service.backendNameForWorld(worldKey));
|
||||
}
|
||||
|
||||
private static WorldLifecycleService service(
|
||||
ControlledBackend remembered,
|
||||
ControlledBackend fallback
|
||||
) {
|
||||
ControlledBackend inactive = new ControlledBackend("inactive");
|
||||
return new WorldLifecycleService(
|
||||
CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, false),
|
||||
inactive,
|
||||
remembered,
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
private static World world(NamespacedKey key) {
|
||||
World world = mock(World.class);
|
||||
when(world.getKey()).thenReturn(key);
|
||||
when(world.getName()).thenReturn(key.getKey());
|
||||
return world;
|
||||
}
|
||||
|
||||
public static final class CallbackServer {
|
||||
private Consumer<Boolean> callback;
|
||||
|
||||
public void unloadWorldAsync(World world, boolean save, Consumer<Boolean> callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
private void complete(boolean unloaded) {
|
||||
callback.accept(unloaded);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class FailingServer {
|
||||
public void unloadWorldAsync(World world, boolean save, Consumer<Boolean> callback) {
|
||||
throw new IllegalStateException("unload failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ControlledBackend implements WorldLifecycleBackend {
|
||||
private final String name;
|
||||
private final CompletableFuture<Boolean> unloadResult = new CompletableFuture<>();
|
||||
|
||||
private ControlledBackend(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return unloadResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backendName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
private void complete(boolean unloaded) {
|
||||
unloadResult.complete(unloaded);
|
||||
}
|
||||
|
||||
private void fail(Throwable failure) {
|
||||
unloadResult.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.WorldRemovalPathPolicy;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class WorldRemovalPathPolicyTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void resolvesExactIrisDimensionDirectory() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("world").toPath();
|
||||
|
||||
WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve("Iris World", "world", levelRoot);
|
||||
|
||||
assertEquals("iris:iris_world", target.worldKey().toString());
|
||||
assertEquals("iris_world", target.logicalName());
|
||||
assertEquals(
|
||||
levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(),
|
||||
target.worldDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsConfiguredMainAndMinecraftNamespace() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("main-protection").toPath();
|
||||
|
||||
WorldRemovalPathPolicy.Rejection mainFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.resolve("production", "production", levelRoot)
|
||||
);
|
||||
WorldRemovalPathPolicy.Rejection namespaceFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.resolve("minecraft:the_nether", "production", levelRoot)
|
||||
);
|
||||
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.CONFIGURED_MAIN_WORLD, mainFailure.reason());
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.MINECRAFT_NAMESPACE, namespaceFailure.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTraversalAndOutsideCandidate() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("path-protection").toPath();
|
||||
NamespacedKey worldKey = NamespacedKey.fromString("iris:safe");
|
||||
|
||||
WorldRemovalPathPolicy.Rejection traversalFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.resolve("../world", "production", levelRoot)
|
||||
);
|
||||
WorldRemovalPathPolicy.Rejection outsideFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.validateStoragePath(
|
||||
levelRoot,
|
||||
worldKey,
|
||||
levelRoot.resolve("safe")
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.INVALID_IDENTIFIER, traversalFailure.reason());
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.OUTSIDE_STORAGE_ROOT, outsideFailure.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinkTargetAndParent() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("symlink-protection").toPath();
|
||||
Path dimensionsRoot = Files.createDirectories(levelRoot.resolve("dimensions"));
|
||||
Path realNamespace = temporaryFolder.newFolder("real-namespace").toPath();
|
||||
Path namespaceLink = dimensionsRoot.resolve("iris");
|
||||
Files.createSymbolicLink(namespaceLink, realNamespace);
|
||||
|
||||
WorldRemovalPathPolicy.Rejection parentFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.resolve("linked", "production", levelRoot)
|
||||
);
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, parentFailure.reason());
|
||||
|
||||
Files.delete(namespaceLink);
|
||||
Path namespace = Files.createDirectories(dimensionsRoot.resolve("iris"));
|
||||
Path realTarget = temporaryFolder.newFolder("real-target").toPath();
|
||||
Files.createSymbolicLink(namespace.resolve("linked"), realTarget);
|
||||
|
||||
WorldRemovalPathPolicy.Rejection targetFailure = assertThrows(
|
||||
WorldRemovalPathPolicy.Rejection.class,
|
||||
() -> WorldRemovalPathPolicy.resolve("linked", "production", levelRoot)
|
||||
);
|
||||
assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, targetFailure.reason());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class AtomicDirectoryPublisherTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void commitPublishesStagedDirectoryAndRemovesBackup() throws Exception {
|
||||
Path root = temporaryFolder.getRoot().toPath();
|
||||
Path target = Files.createDirectory(root.resolve("target"));
|
||||
Files.writeString(target.resolve("value.txt"), "old");
|
||||
Path staged = Files.createDirectory(root.resolve("stage"));
|
||||
Files.writeString(staged.resolve("value.txt"), "new");
|
||||
|
||||
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staged, target)) {
|
||||
assertEquals("new", Files.readString(target.resolve("value.txt")));
|
||||
publication.commit();
|
||||
publication.cleanupBackup();
|
||||
}
|
||||
|
||||
assertEquals("new", Files.readString(target.resolve("value.txt")));
|
||||
assertFalse(Files.exists(staged));
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("backup-")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeWithoutCommitRestoresOriginalDirectory() throws Exception {
|
||||
Path root = temporaryFolder.getRoot().toPath();
|
||||
Path target = Files.createDirectory(root.resolve("target"));
|
||||
Files.writeString(target.resolve("value.txt"), "old");
|
||||
Path staged = Files.createDirectory(root.resolve("stage"));
|
||||
Files.writeString(staged.resolve("value.txt"), "new");
|
||||
|
||||
try (AtomicDirectoryPublisher.Publication ignored = AtomicDirectoryPublisher.publish(staged, target)) {
|
||||
assertEquals("new", Files.readString(target.resolve("value.txt")));
|
||||
}
|
||||
|
||||
assertTrue(Files.isDirectory(target));
|
||||
assertEquals("old", Files.readString(target.resolve("value.txt")));
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,11 @@ public class ContentKeyValidatorTest {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> lootTableKeys() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||
return properties;
|
||||
|
||||
@@ -9,9 +9,12 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class PackDirectoryResolverTest {
|
||||
@Rule
|
||||
@@ -43,7 +46,7 @@ public class PackDirectoryResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinkChildren() throws Exception {
|
||||
public void acceptsSafeSymbolicLinkPackRoots() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("symlink-root");
|
||||
File outside = temporaryFolder.newFolder("symlink-target");
|
||||
Path link = new File(packs, "linked").toPath();
|
||||
@@ -53,6 +56,46 @@ public class PackDirectoryResolverTest {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "linked"));
|
||||
assertEquals(link.toFile().getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "linked"));
|
||||
assertTrue(PackDirectoryResolver.listVisiblePackDirectories(packs).contains(link.toFile()));
|
||||
PackDirectoryResolver.requireSafePackTree(link.toFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void excludesEveryHiddenTransactionDirectory() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("transaction-root");
|
||||
File visible = new File(packs, "overworld");
|
||||
Files.createDirectory(visible.toPath());
|
||||
Files.createDirectory(new File(packs, ".iris-import-123").toPath());
|
||||
Files.createDirectory(new File(packs, ".overworld.backup-123").toPath());
|
||||
Files.createDirectory(new File(packs, ".importing-123").toPath());
|
||||
Files.createDirectory(new File(packs, ".custom-stage").toPath());
|
||||
|
||||
List<File> listed = PackDirectoryResolver.listVisiblePackDirectories(packs);
|
||||
|
||||
assertEquals(List.of(visible), listed);
|
||||
assertTrue(PackDirectoryResolver.isVisiblePackDirectory(visible));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, ".custom-stage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksInsidePackTrees() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("nested-link-root");
|
||||
File pack = new File(packs, "overworld");
|
||||
Files.createDirectories(pack.toPath().resolve("dimensions"));
|
||||
Path outside = temporaryFolder.newFile("outside-dimension.json").toPath();
|
||||
Path link = pack.toPath().resolve("dimensions/overworld.json");
|
||||
try {
|
||||
Files.createSymbolicLink(link, outside);
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
try {
|
||||
PackDirectoryResolver.requireSafePackTree(pack);
|
||||
fail("Pack trees containing symbolic links must be rejected");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("symbolic link"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,24 +18,76 @@
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import org.junit.Assume;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.Answers;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
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.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class PackDownloaderTest {
|
||||
@Rule
|
||||
public TemporaryFolder temp = new TemporaryFolder();
|
||||
|
||||
private IrisPlatform previousPlatform;
|
||||
private IrisSettings previousSettings;
|
||||
|
||||
@Before
|
||||
public void bindPlatform() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
previousSettings = IrisSettings.settings;
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS);
|
||||
PlatformStructureHooks structureHooks = mock(PlatformStructureHooks.class, Answers.CALLS_REAL_METHODS);
|
||||
when(platform.dataFolder()).thenReturn(temp.getRoot());
|
||||
when(platform.structureHooks()).thenReturn(structureHooks);
|
||||
when(structureHooks.structureKeys()).thenReturn(List.of("minecraft:village"));
|
||||
when(structureHooks.jigsawStructureKeys()).thenReturn(List.of("minecraft:village"));
|
||||
when(structureHooks.templatePoolKeys()).thenReturn(List.of("minecraft:empty"));
|
||||
IrisPlatforms.bind(platform);
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
}
|
||||
|
||||
@After
|
||||
public void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
IrisSettings.settings = previousSettings;
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesDefaultOverworldBetaRelease() {
|
||||
assertEquals(
|
||||
@@ -60,6 +112,14 @@ public class PackDownloaderTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesRepositoryDefaultBranchReference() {
|
||||
assertEquals(
|
||||
"https://github.com/IrisDimensions/example/archive/HEAD.zip",
|
||||
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/example", "HEAD")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesQualifiedHeadReference() {
|
||||
assertEquals(
|
||||
@@ -109,6 +169,26 @@ public class PackDownloaderTest {
|
||||
assertTrue(PackDownloader.isPackPresent(packsFolder, "overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packPresenceAcceptsSafeSymbolicPackDirectories() throws IOException {
|
||||
File packsFolder = temp.newFolder("linked-packs");
|
||||
Path external = temp.newFolder("external-pack").toPath();
|
||||
Files.createDirectories(external.resolve("dimensions"));
|
||||
Files.writeString(external.resolve("dimensions/overworld.json"), "{}");
|
||||
Path linked = packsFolder.toPath().resolve("overworld");
|
||||
try {
|
||||
Files.createSymbolicLink(linked, external);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
assertTrue(PackDownloader.isPackPresent(packsFolder, "overworld"));
|
||||
|
||||
Path externalFile = temp.newFile("outside-pack.txt").toPath();
|
||||
Files.createSymbolicLink(external.resolve("linked-file"), externalFile);
|
||||
assertFalse(PackDownloader.isPackPresent(packsFolder, "overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void downloadSkipsWhenExpectedPackAlreadyPresent() throws IOException {
|
||||
File packsFolder = temp.newFolder("packs");
|
||||
@@ -119,7 +199,7 @@ public class PackDownloaderTest {
|
||||
List<String> feedback = new ArrayList<>();
|
||||
// The URL is unreachable on purpose: reaching the network would fail the download and
|
||||
// return null, so a non-null key proves the presence check ran before any fetch.
|
||||
String key = PackDownloader.download(
|
||||
PackDownloader.PackInstallResult result = PackDownloader.download(
|
||||
packsFolder,
|
||||
"IrisDimensions/overworld",
|
||||
"http://127.0.0.1:9/unreachable.zip",
|
||||
@@ -129,7 +209,8 @@ public class PackDownloaderTest {
|
||||
feedback::add
|
||||
);
|
||||
|
||||
assertEquals("overworld", key);
|
||||
assertEquals("overworld", result.key());
|
||||
assertFalse(result.changed());
|
||||
assertFalse(feedback.isEmpty());
|
||||
}
|
||||
|
||||
@@ -141,4 +222,282 @@ public class PackDownloaderTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/pull/123/head"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forceOverwritePublishesValidatedPackAndCleansTransactionState() throws Exception {
|
||||
File packsFolder = temp.newFolder("force-packs");
|
||||
File target = writePack(packsFolder.toPath().resolve("replaceable"), "replaceable", "old");
|
||||
Files.writeString(target.toPath().resolve("old-only.txt"), "old", StandardCharsets.UTF_8);
|
||||
File extracted = writePack(temp.newFolder("force-source").toPath(), "replaceable", "new");
|
||||
List<String> feedback = new ArrayList<>();
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
true,
|
||||
"replaceable",
|
||||
feedback::add
|
||||
);
|
||||
|
||||
assertEquals(feedback.toString(), "replaceable", result.key());
|
||||
assertTrue(result.changed());
|
||||
assertFalse(result.restartRequired());
|
||||
assertEquals("new", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8));
|
||||
assertFalse(Files.exists(target.toPath().resolve("old-only.txt")));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forceOverwritePreservesSymbolicPackTargets() throws Exception {
|
||||
File packsFolder = temp.newFolder("linked-target-packs");
|
||||
File external = writePack(temp.newFolder("linked-target-source").toPath(), "replaceable", "old");
|
||||
Path target = packsFolder.toPath().resolve("replaceable");
|
||||
try {
|
||||
Files.createSymbolicLink(target, external.toPath());
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
File extracted = writePack(temp.newFolder("linked-target-update").toPath(), "replaceable", "new");
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
true,
|
||||
"replaceable",
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
|
||||
assertNull(result);
|
||||
assertTrue(Files.isSymbolicLink(target));
|
||||
assertEquals("old", Files.readString(external.toPath().resolve("state.txt"), StandardCharsets.UTF_8));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidStagedPackPreservesExistingTarget() throws Exception {
|
||||
File packsFolder = temp.newFolder("invalid-packs");
|
||||
File target = writePack(packsFolder.toPath().resolve("protected_pack"), "protected_pack", "old");
|
||||
File extracted = writePack(temp.newFolder("invalid-source").toPath(), "protected_pack", "new");
|
||||
Files.delete(extracted.toPath().resolve("regions/local.json"));
|
||||
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
true,
|
||||
"protected_pack",
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
|
||||
assertNull(result);
|
||||
assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8));
|
||||
assertTrue(Files.isRegularFile(target.toPath().resolve("regions/local.json")));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forceOverwriteRefusesLoadedPackData() throws Exception {
|
||||
File packsFolder = temp.newFolder("loaded-packs");
|
||||
File target = writePack(packsFolder.toPath().resolve("active_pack"), "active_pack", "old");
|
||||
File extracted = writePack(temp.newFolder("loaded-update").toPath(), "active_pack", "new");
|
||||
IrisData loaded = IrisData.get(target);
|
||||
try {
|
||||
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
true,
|
||||
"active_pack",
|
||||
ignored -> {
|
||||
}
|
||||
);
|
||||
|
||||
assertNull(result);
|
||||
assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
} finally {
|
||||
loaded.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unexpectedDownloadedKeyPreservesExistingTarget() throws Exception {
|
||||
File packsFolder = temp.newFolder("mismatch-packs");
|
||||
File target = writePack(packsFolder.toPath().resolve("requested"), "requested", "old");
|
||||
File extracted = writePack(temp.newFolder("mismatch-source").toPath(), "different", "new");
|
||||
|
||||
IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack(
|
||||
packsFolder,
|
||||
extracted,
|
||||
true,
|
||||
"requested",
|
||||
ignored -> {
|
||||
}
|
||||
));
|
||||
|
||||
assertTrue(failure.getMessage().contains("different"));
|
||||
assertTrue(failure.getMessage().contains("requested"));
|
||||
assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8));
|
||||
assertFalse(new File(packsFolder, "different").exists());
|
||||
assertTransactionStateClean(packsFolder);
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception {
|
||||
File packsFolder = temp.newFolder("concurrent-packs");
|
||||
File firstSource = writePack(temp.newFolder("concurrent-first").toPath(), "shared_pack", "first");
|
||||
File secondSource = writePack(temp.newFolder("concurrent-second").toPath(), "shared_pack", "second");
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<PackDownloader.PackInstallResult> first = executor.submit(() -> {
|
||||
start.await();
|
||||
return PackDownloader.installExtractedPack(
|
||||
packsFolder, firstSource, false, "shared_pack", ignored -> {
|
||||
});
|
||||
});
|
||||
Future<PackDownloader.PackInstallResult> second = executor.submit(() -> {
|
||||
start.await();
|
||||
return PackDownloader.installExtractedPack(
|
||||
packsFolder, secondSource, false, "shared_pack", ignored -> {
|
||||
});
|
||||
});
|
||||
start.countDown();
|
||||
|
||||
int successes = 0;
|
||||
PackDownloader.PackInstallResult firstResult = first.get();
|
||||
PackDownloader.PackInstallResult secondResult = second.get();
|
||||
if (firstResult != null && "shared_pack".equals(firstResult.key())) {
|
||||
successes++;
|
||||
}
|
||||
if (secondResult != null && "shared_pack".equals(secondResult.key())) {
|
||||
successes++;
|
||||
}
|
||||
|
||||
assertEquals(1, successes);
|
||||
String installedState = Files.readString(
|
||||
packsFolder.toPath().resolve("shared_pack/state.txt"),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
assertTrue(installedState.equals("first") || installedState.equals("second"));
|
||||
assertTransactionStateClean(packsFolder);
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnsafeExpectedKeyBeforeDownload() throws IOException {
|
||||
File packsFolder = temp.newFolder("unsafe-key-packs");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.download(
|
||||
packsFolder,
|
||||
"IrisDimensions/overworld",
|
||||
"http://127.0.0.1:9/unreachable.zip",
|
||||
true,
|
||||
true,
|
||||
"../outside",
|
||||
ignored -> {
|
||||
}
|
||||
));
|
||||
assertEquals(0, PackDownloader.downloadLockCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedExtractionRejectsEscapedArchivePaths() throws Exception {
|
||||
Path archive = temp.newFile("escaped.zip").toPath();
|
||||
writeArchive(archive, Map.of("../escaped.txt", "outside"));
|
||||
Path destination = temp.newFolder("escaped-output").toPath();
|
||||
|
||||
assertThrows(IOException.class, () -> PackDownloader.unpackArchive(
|
||||
archive,
|
||||
destination,
|
||||
new PackDownloader.ArchiveLimits(1024L, 10, 1024L, 1024L)
|
||||
));
|
||||
assertFalse(Files.exists(destination.getParent().resolve("escaped.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedExtractionEnforcesEntryAndExpandedLimits() throws Exception {
|
||||
Path archive = temp.newFile("bounded.zip").toPath();
|
||||
LinkedHashMap<String, String> entries = new LinkedHashMap<>();
|
||||
entries.put("first.txt", "1234");
|
||||
entries.put("second.txt", "5678");
|
||||
writeArchive(archive, entries);
|
||||
|
||||
assertThrows(IOException.class, () -> PackDownloader.unpackArchive(
|
||||
archive,
|
||||
temp.newFolder("entry-count-output").toPath(),
|
||||
new PackDownloader.ArchiveLimits(4096L, 1, 4096L, 4096L)
|
||||
));
|
||||
assertThrows(IOException.class, () -> PackDownloader.unpackArchive(
|
||||
archive,
|
||||
temp.newFolder("entry-size-output").toPath(),
|
||||
new PackDownloader.ArchiveLimits(4096L, 10, 4096L, 3L)
|
||||
));
|
||||
assertThrows(IOException.class, () -> PackDownloader.unpackArchive(
|
||||
archive,
|
||||
temp.newFolder("expanded-output").toPath(),
|
||||
new PackDownloader.ArchiveLimits(4096L, 10, 7L, 4096L)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedExtractionPublishesOnlyInsideItsDestination() throws Exception {
|
||||
Path archive = temp.newFile("valid.zip").toPath();
|
||||
writeArchive(archive, Map.of("pack/dimensions/overworld.json", "{}"));
|
||||
Path destination = temp.newFolder("valid-output").toPath();
|
||||
|
||||
PackDownloader.unpackArchive(
|
||||
archive,
|
||||
destination,
|
||||
new PackDownloader.ArchiveLimits(4096L, 10, 4096L, 4096L)
|
||||
);
|
||||
|
||||
assertEquals("{}", Files.readString(destination.resolve("pack/dimensions/overworld.json")));
|
||||
}
|
||||
|
||||
private static File writePack(Path root, String key, String state) throws IOException {
|
||||
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
|
||||
);
|
||||
Files.writeString(
|
||||
root.resolve("regions/local.json"),
|
||||
"{\"name\":\"Local\",\"landBiomes\":[\"local\"]}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
Files.writeString(
|
||||
root.resolve("biomes/local.json"),
|
||||
"{\"name\":\"Local\",\"derivative\":\"minecraft:plains\"}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
Files.writeString(root.resolve("state.txt"), state, StandardCharsets.UTF_8);
|
||||
return root.toFile();
|
||||
}
|
||||
|
||||
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()) {
|
||||
zip.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertTransactionStateClean(File packsFolder) {
|
||||
File[] transactionEntries = packsFolder.listFiles((File parent, String name) ->
|
||||
name.startsWith(".iris-import-") || name.contains(".backup-"));
|
||||
assertTrue(transactionEntries == null || transactionEntries.length == 0);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
@@ -16,6 +20,11 @@ import java.util.Set;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class PackValidatorStructureGraphTest {
|
||||
@Rule
|
||||
@@ -64,6 +73,52 @@ public class PackValidatorStructureGraphTest {
|
||||
pack, Set.of(), Map.of()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void datapackBootstrapDefersOnlyLiveRegistryValidation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("bootstrap-native-structure");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"test:structure\"}]}],"
|
||||
+ "\"regions\":[\"main\"]}");
|
||||
write(pack, "regions/main.json", "{\"landBiomes\":[\"main\"]}");
|
||||
write(pack, "biomes/main.json", "{\"name\":\"Main\"}");
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenThrow(
|
||||
new IllegalStateException("server unavailable"));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
PackValidationResult bootstrap = PackValidator.validateForDatapackBootstrap(pack);
|
||||
PackValidationResult live = PackValidator.validate(pack);
|
||||
|
||||
assertTrue(bootstrap.getBlockingErrors().toString(), bootstrap.isLoadable());
|
||||
assertFalse(live.isLoadable());
|
||||
assertTrue(live.getBlockingErrors().toString(), live.getBlockingErrors().stream().anyMatch(
|
||||
error -> error.contains("server unavailable")));
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void datapackBootstrapStillRejectsMalformedPlacementConfiguration() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("bootstrap-invalid-placement");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"test:structure\"}],"
|
||||
+ "\"spacing\":0}]}");
|
||||
|
||||
PackValidationResult result = PackValidator.validateForDatapackBootstrap(pack);
|
||||
|
||||
assertFalse(result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().toString(), result.getBlockingErrors().stream().anyMatch(
|
||||
error -> error.contains("spacing must be at least 1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNativeTerrainLobeSettingsOutsideTheirRange() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("invalid-lobe");
|
||||
@@ -141,6 +196,316 @@ public class PackValidatorStructureGraphTest {
|
||||
message -> message.contains(".maxDepth must be at most 20")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedPlacementGridAndPolicyFields() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("invalid-placement-grid");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}],"
|
||||
+ "\"placementId\":42,\"distribution\":\"UNKNOWN\",\"spacing\":0,"
|
||||
+ "\"separation\":32,\"density\":\"often\",\"ringCount\":0,"
|
||||
+ "\"minHeight\":90,\"maxHeight\":20,\"underwater\":\"yes\","
|
||||
+ "\"nativeSuppression\":\"SOMETIMES\","
|
||||
+ "\"terrain\":{\"mode\":\"SURFACE_FIT\",\"shape\":\"CUBE\"}}]}");
|
||||
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("placementId must be a string")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("distribution must be one of")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("spacing must be at least 1")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("density must be a number")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("inverted height band")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("underwater must be a boolean")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("terrain.mode must be one of")));
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("terrain.shape must be one of")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDuplicatePlacementIdsAcrossResources() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("duplicate-placement-id");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{\"placementId\":\"shared\","
|
||||
+ "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}]}]}");
|
||||
write(pack, "regions/forest.json", "{\"structures\":[{\"placementId\":\"shared\","
|
||||
+ "\"nativeStructures\":[{\"structure\":\"minecraft:village_plains\"}]}]}");
|
||||
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("placementId duplicates 'shared'")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCanonicalAnonymousGridDuplicatesAcrossResources() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("duplicate-anonymous-grid");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}]}]}");
|
||||
write(pack, "regions/forest.json", "{\"structures\":[{"
|
||||
+ "\"spacing\":32,\"separation\":8,\"density\":0.02,"
|
||||
+ "\"nativeStructures\":[{\"weight\":1,\"structure\":\"minecraft:ancient_city\"}]}]}");
|
||||
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("duplicates an anonymous placement grid")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDuplicateEditableStructureKeysIgnoringCase() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("duplicate-editable-key");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":["
|
||||
+ "\"castle\",\"CASTLE\"]}]}");
|
||||
write(pack, "structures/castle.json", "{}");
|
||||
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("duplicates Iris structure")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalJigsawTerrainEnvelopeOverflowDoesNotBlockThePack() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("oversized-envelope");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\","
|
||||
+ "\"jigsaw\":{\"maxDistanceHorizontal\":96}}],"
|
||||
+ "\"terrain\":{\"mode\":\"FORCE_CARVE\",\"horizontalPadding\":40}}]}");
|
||||
|
||||
assertTrue(PackObjectSurfaceValidator.validateStructureGraph(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedOverworldAncientCityTerrainEnvelopeDoesNotBlockThePack() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack(
|
||||
"shared-overworld-ancient-city", "minecraft:ancient_city", "FORCE_CARVE", 24);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(
|
||||
pack, "minecraft:ancient_city", 116, 12, 0, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceAndPreserveIgnoreConfiguredEnvelopePadding() throws Exception {
|
||||
File source = nativeEnvelopePack("source-padding", "SOURCE", 128, 128);
|
||||
File preserve = nativeEnvelopePack("preserve-padding", "PRESERVE", 128, 128);
|
||||
|
||||
assertTrue(validateWithReferenceExpansion(source, 0).isEmpty());
|
||||
assertTrue(validateWithReferenceExpansion(preserve, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceTerrainAdjustmentOverflowDoesNotBlockThePack() throws Exception {
|
||||
File boundary = nativeEnvelopePack("source-adjustment-boundary", "SOURCE", 116, 64);
|
||||
File oversized = nativeEnvelopePack("source-adjustment-oversized", "SOURCE", 117, 0);
|
||||
|
||||
assertTrue(validateWithReferenceExpansion(boundary, 12).isEmpty());
|
||||
assertTrue(validateWithReferenceExpansion(oversized, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customTerrainEnvelopeOverflowDoesNotBlockThePack() throws Exception {
|
||||
File boundary = nativeEnvelopePack("custom-envelope-boundary", "ENCASE", 96, 32);
|
||||
File oversized = nativeEnvelopePack("custom-envelope-oversized", "ENCASE", 96, 33);
|
||||
|
||||
assertTrue(validateWithReferenceExpansion(boundary, 12).isEmpty());
|
||||
assertTrue(validateWithReferenceExpansion(oversized, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registeredJigsawOptionalPaddingDoesNotExpandItsBlockingSpan() throws Exception {
|
||||
File boundary = nativeSourceEnvelopePack("dnt-source-boundary", "ENCASE", 48);
|
||||
File oversized = nativeSourceEnvelopePack("dnt-source-oversized", "ENCASE", 49);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(boundary, 80, 12).isEmpty());
|
||||
assertTrue(validateWithJigsawMetadata(oversized, 80, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void jigsawOverrideReplacesLiveSourceDistance() throws Exception {
|
||||
File pack = nativeEnvelopePack("source-distance-override", "ENCASE", 80, 36);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(pack, 120, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oversizedLiveStartElementCannotBypassAssemblyDistance() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("oversized-live-start", "SOURCE", 0);
|
||||
|
||||
List<String> errors = validateWithJigsawMetadata(pack, 80, 0, 129, 0);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains("129-block maximum start element")
|
||||
&& message.contains("128-block (8-chunk)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void liveStartElementSpanUsesInclusiveReferenceBoundary() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("live-start-boundary", "SOURCE", 0);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(pack, 80, 12, 116, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overriddenStartPoolUsesItsLiveSpan() throws Exception {
|
||||
File pack = nativePoolOverrideEnvelopePack("oversized-pool-override");
|
||||
|
||||
List<String> errors = validateWithJigsawMetadata(pack, 80, 0, 20, 129);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains("129-block maximum start element")
|
||||
&& message.contains("128-block (8-chunk)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void smallerOverriddenStartPoolReplacesTheSourcePoolSpan() throws Exception {
|
||||
File pack = nativePoolOverrideEnvelopePack("smaller-pool-override");
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(pack, 80, 0, 129, 20).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedOverriddenStartPoolSpanFailsClosed() throws Exception {
|
||||
File pack = nativePoolOverrideEnvelopePack("unresolved-pool-override");
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start", "test:override"));
|
||||
when(hooks.jigsawSourceMetadata("test:structure"))
|
||||
.thenReturn(new JigsawSourceMetadata(80, 0, 20));
|
||||
when(hooks.jigsawStartPoolHorizontalSpan("test:structure", "test:override"))
|
||||
.thenThrow(new IllegalStateException("missing template"));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains("could not resolve a bounded live horizontal span")
|
||||
&& message.contains("missing template")));
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registeredJigsawSourceAdjustmentOverflowDoesNotBlockThePack() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("source-adjustment-no-override", "SOURCE", 0);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(pack, 117, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registeredJigsawAssemblyBeyondReferenceRangeStillBlocksThePack() throws Exception {
|
||||
File pack = nativeEnvelopePack("oversized-live-assembly", "ENCASE", 129, 0);
|
||||
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains(".jigsaw.maxDistanceHorizontal")
|
||||
&& message.contains("129-block maximum assembly distance")
|
||||
&& message.contains("128-block (8-chunk)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void liveJigsawMetadataFailureBlocksValidation() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("missing-source-metadata", "SOURCE", 0);
|
||||
|
||||
List<String> errors = validateWithUnavailableJigsawMetadata(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains("returned null jigsaw metadata for 'test:structure'")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validationResolvesOnlyReferencedJigsawMetadataAndCachesDuplicates() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("lazy-jigsaw-metadata");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":["
|
||||
+ "{\"placementId\":\"first\",\"nativeStructures\":["
|
||||
+ "{\"structure\":\"test:used\"}]},"
|
||||
+ "{\"placementId\":\"second\",\"nativeStructures\":["
|
||||
+ "{\"structure\":\"test:used\"}]}]}");
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of("test:used", "test:unused"));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:used", "test:unused"));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start"));
|
||||
when(hooks.jigsawSourceMetadata("test:used"))
|
||||
.thenReturn(new JigsawSourceMetadata(80, 0, 20));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
List<String> errors = PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
|
||||
assertTrue(errors.toString(), errors.isEmpty());
|
||||
verify(hooks, times(1)).jigsawSourceMetadata("test:used");
|
||||
verify(hooks, never()).jigsawSourceMetadata("test:unused");
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalTerrainPaddingForNonJigsawSourceDoesNotBlockThePack() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("non-jigsaw-envelope", "ENCASE", 1);
|
||||
|
||||
assertTrue(validateWithNonJigsawSource(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsZeroReferencePaddingForRegisteredNonJigsawSource() throws Exception {
|
||||
File pack = nativeSourceEnvelopePack("non-jigsaw-zero-envelope", "ENCASE", 0);
|
||||
|
||||
assertTrue(validateWithNonJigsawSource(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalTerrainPaddingForNaturalNonJigsawAdjustmentDoesNotBlockThePack() throws Exception {
|
||||
File pack = nativeAdjustmentEnvelopePack(
|
||||
"natural-non-jigsaw-envelope", "test:structure", "ENCASE", 1);
|
||||
|
||||
assertTrue(validateWithNonJigsawSource(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalTerrainPaddingForNaturalJigsawAdjustmentDoesNotBlockThePack() throws Exception {
|
||||
File boundary = nativeAdjustmentEnvelopePack(
|
||||
"natural-jigsaw-envelope-boundary", "test:structure", "ENCASE", 48);
|
||||
File oversized = nativeAdjustmentEnvelopePack(
|
||||
"natural-jigsaw-envelope-oversized", "test:structure", "ENCASE", 49);
|
||||
|
||||
assertTrue(validateWithJigsawMetadata(boundary, 80, 12).isEmpty());
|
||||
assertTrue(validateWithJigsawMetadata(oversized, 80, 12).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void actualNaturalJigsawContentBeyondReferenceRangeStillBlocksThePack() throws Exception {
|
||||
File pack = nativeAdjustmentEnvelopePack(
|
||||
"natural-jigsaw-oversized-content", "test:structure", "ENCASE", 1);
|
||||
|
||||
List<String> errors = validateWithJigsawMetadata(pack, 80, 0, 129, 0);
|
||||
|
||||
assertTrue(errors.toString(), errors.stream().anyMatch(message ->
|
||||
message.contains("importedStructures.adjustments[0]")
|
||||
&& message.contains("129-block maximum start element")
|
||||
&& message.contains("128-block (8-chunk)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lastMatchingNaturalTerrainAdjustmentControlsEnvelopeValidation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("natural-adjustment-precedence");
|
||||
write(pack, "dimensions/main.json", "{\"importedStructures\":{\"adjustments\":["
|
||||
+ "{\"match\":[\"test:structure\"],\"terrain\":{\"mode\":\"ENCASE\","
|
||||
+ "\"horizontalPadding\":128}},"
|
||||
+ "{\"match\":[\"test:structure\"],\"terrain\":{\"mode\":\"PRESERVE\"}}]}}}");
|
||||
|
||||
assertTrue(validateWithNonJigsawSource(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reportsMissingReferencesInDeterministicGraphOrder() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
@@ -415,6 +780,156 @@ public class PackValidatorStructureGraphTest {
|
||||
return pack;
|
||||
}
|
||||
|
||||
private File nativeEnvelopePack(String name, String mode, int maximumDistance,
|
||||
int horizontalPadding) throws Exception {
|
||||
File pack = temporaryFolder.newFolder(name);
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"test:structure\","
|
||||
+ "\"jigsaw\":{\"maxDistanceHorizontal\":" + maximumDistance + "}}],"
|
||||
+ "\"terrain\":{\"mode\":\"" + mode + "\",\"horizontalPadding\":"
|
||||
+ horizontalPadding + "}}]}");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private File nativeSourceEnvelopePack(String name, String mode,
|
||||
int horizontalPadding) throws Exception {
|
||||
return nativeSourceEnvelopePack(name, "test:structure", mode, horizontalPadding);
|
||||
}
|
||||
|
||||
private File nativeSourceEnvelopePack(String name, String structureKey, String mode,
|
||||
int horizontalPadding) throws Exception {
|
||||
File pack = temporaryFolder.newFolder(name);
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"" + structureKey + "\"}],"
|
||||
+ "\"terrain\":{\"mode\":\"" + mode + "\",\"horizontalPadding\":"
|
||||
+ horizontalPadding + "}}]}");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private File nativePoolOverrideEnvelopePack(String name) throws Exception {
|
||||
File pack = temporaryFolder.newFolder(name);
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{"
|
||||
+ "\"nativeStructures\":[{\"structure\":\"test:structure\","
|
||||
+ "\"jigsaw\":{\"startPool\":\"test:override\","
|
||||
+ "\"maxDistanceHorizontal\":80}}],"
|
||||
+ "\"terrain\":{\"mode\":\"SOURCE\"}}]}");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private File nativeAdjustmentEnvelopePack(String name, String match, String mode,
|
||||
int horizontalPadding) throws Exception {
|
||||
File pack = temporaryFolder.newFolder(name);
|
||||
write(pack, "dimensions/main.json", "{\"importedStructures\":{\"adjustments\":[{"
|
||||
+ "\"match\":[\"" + match + "\"],\"terrain\":{\"mode\":\"" + mode
|
||||
+ "\",\"horizontalPadding\":" + horizontalPadding + "}}]}}}");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private List<String> validateWithReferenceExpansion(File pack, int expansion) {
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start"));
|
||||
when(hooks.jigsawSourceMetadata("test:structure"))
|
||||
.thenReturn(new JigsawSourceMetadata(80, expansion));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
return PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> validateWithJigsawMetadata(File pack, int maximumDistance, int expansion) {
|
||||
return validateWithJigsawMetadata(pack, maximumDistance, expansion, 0, 0);
|
||||
}
|
||||
|
||||
private List<String> validateWithJigsawMetadata(File pack, int maximumDistance, int expansion,
|
||||
int sourceSpan, int overrideSpan) {
|
||||
return validateWithJigsawMetadata(
|
||||
pack, "test:structure", maximumDistance, expansion, sourceSpan, overrideSpan);
|
||||
}
|
||||
|
||||
private List<String> validateWithJigsawMetadata(
|
||||
File pack,
|
||||
String structureKey,
|
||||
int maximumDistance,
|
||||
int expansion,
|
||||
int sourceSpan,
|
||||
int overrideSpan
|
||||
) {
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of(structureKey));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of(structureKey));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start", "test:override"));
|
||||
when(hooks.jigsawSourceMetadata(structureKey))
|
||||
.thenReturn(new JigsawSourceMetadata(maximumDistance, expansion, sourceSpan));
|
||||
when(hooks.jigsawStartPoolHorizontalSpan(
|
||||
structureKey, "test:override")).thenReturn(overrideSpan);
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
return PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> validateWithUnavailableJigsawMetadata(File pack) {
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure"));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start"));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
return PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> validateWithNonJigsawSource(File pack) {
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformStructureHooks hooks = mock(PlatformStructureHooks.class);
|
||||
when(platform.structureHooks()).thenReturn(hooks);
|
||||
when(hooks.structureKeys()).thenReturn(List.of("test:structure", "test:jigsaw"));
|
||||
when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:jigsaw"));
|
||||
when(hooks.templatePoolKeys()).thenReturn(List.of("test:start"));
|
||||
when(hooks.jigsawSourceMetadata("test:jigsaw"))
|
||||
.thenReturn(new JigsawSourceMetadata(80, 0));
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatforms.bind(platform);
|
||||
try {
|
||||
return PackObjectSurfaceValidator.validateStructureGraph(pack);
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<StructureGraphPackValidator.SampledVerticalEnvelope>> sampledEnvelope(
|
||||
String structureKey,
|
||||
int pieceCount,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisProjectCopierTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void copiesTransformsAndAtomicallyPublishesProject() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Files.createDirectories(source.resolve("biomes"));
|
||||
Files.writeString(source.resolve("biomes/plains.json"), "{}");
|
||||
Files.createDirectories(source.resolve(".git"));
|
||||
Files.writeString(source.resolve(".git/config"), "private");
|
||||
Files.writeString(source.resolve("source.code-workspace"), "private");
|
||||
Path target = workspace.resolve("target-pack");
|
||||
|
||||
IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
"target-pack"
|
||||
);
|
||||
|
||||
assertTrue(Files.isDirectory(target));
|
||||
assertTrue(Files.isRegularFile(target.resolve("biomes/plains.json")));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/target-pack.json")));
|
||||
assertFalse(Files.exists(target.resolve("dimensions/source.json")));
|
||||
assertFalse(Files.exists(target.resolve(".git")));
|
||||
assertFalse(Files.exists(target.resolve("source.code-workspace")));
|
||||
JSONObject dimension = new JSONObject(Files.readString(target.resolve("dimensions/target-pack.json")));
|
||||
assertEquals("Target Pack", dimension.getString("name"));
|
||||
assertNoStages(workspace, "target-pack");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceFolderAndSelectedDimensionMayHaveDifferentKeys() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("folder-key-mismatch").toPath();
|
||||
Path source = workspace.resolve("template-pack");
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/overworld.json"), "{\"name\":\"Source\"}");
|
||||
Path target = workspace.resolve("new-project");
|
||||
|
||||
IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"overworld",
|
||||
"new-project"
|
||||
);
|
||||
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/new-project.json")));
|
||||
assertFalse(Files.exists(target.resolve("dimensions/overworld.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsSupportingDimensionsWhileRenamingTheSelectedDimension() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("multiple-dimensions").toPath();
|
||||
Path source = workspace.resolve("template-pack");
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/overworld.json"), "{\"name\":\"Source\"}");
|
||||
Files.writeString(source.resolve("dimensions/the_nether.json"), "{\"name\":\"Nether\"}");
|
||||
Path target = workspace.resolve("new-project");
|
||||
|
||||
IrisProjectCopier.copyProject(source.toFile(), workspace.toFile(), "overworld", "new-project");
|
||||
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/new-project.json")));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/the_nether.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingTargetRemainsUnchanged() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Path target = workspace.resolve("target");
|
||||
Files.createDirectories(target);
|
||||
Path sentinel = target.resolve("sentinel.txt");
|
||||
Files.writeString(sentinel, "keep-me");
|
||||
|
||||
assertThrows(FileAlreadyExistsException.class, () -> IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
"target"
|
||||
));
|
||||
|
||||
assertEquals("keep-me", Files.readString(sentinel));
|
||||
assertEquals(1L, countEntries(target));
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidProjectKeysAndEscapedTargets() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Path target = workspace.resolve("target");
|
||||
List<String> invalidKeys = List.of(
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"nested/project",
|
||||
"nested\\project",
|
||||
workspace.resolve("absolute").toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
for (String invalidKey : invalidKeys) {
|
||||
assertThrows(IOException.class, () -> IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
invalidKey
|
||||
));
|
||||
}
|
||||
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingSelectedDimension() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Path target = workspace.resolve("target");
|
||||
|
||||
assertThrows(IOException.class, () -> IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"different-source",
|
||||
"target"
|
||||
));
|
||||
|
||||
assertFalse(Files.exists(target));
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsSafeSymbolicLinkSource() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path realSource = workspace.resolve("real-source");
|
||||
Files.createDirectories(realSource.resolve("dimensions"));
|
||||
Files.writeString(realSource.resolve("dimensions/source.json"), "{\"name\":\"Source\"}");
|
||||
Path linkedSource = workspace.resolve("source");
|
||||
try {
|
||||
Files.createSymbolicLink(linkedSource, realSource.getFileName());
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
Path target = workspace.resolve("target");
|
||||
|
||||
IrisProjectCopier.copyProject(
|
||||
linkedSource.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
"target"
|
||||
);
|
||||
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/target.json")));
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void injectedMidCopyFailureLeavesNoTargetOrStage() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Files.createDirectories(source.resolve("objects"));
|
||||
Files.writeString(source.resolve("objects/fail.iob"), "failure-point");
|
||||
Path target = workspace.resolve("target");
|
||||
|
||||
assertThrows(IOException.class, () -> IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
"target",
|
||||
(Path input, Path output) -> {
|
||||
if ("fail.iob".equals(input.getFileName().toString())) {
|
||||
throw new IOException("injected copy failure");
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
assertFalse(Files.exists(target));
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksInsideSourceTreeAndCleansStage() throws Exception {
|
||||
Path workspace = temporaryFolder.newFolder("packs").toPath();
|
||||
Path source = createSource(workspace, "source");
|
||||
Path external = temporaryFolder.newFile("outside.txt").toPath();
|
||||
Path link = source.resolve("objects-link");
|
||||
try {
|
||||
Files.createSymbolicLink(link, external);
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
Path target = workspace.resolve("target");
|
||||
|
||||
assertThrows(IOException.class, () -> IrisProjectCopier.copyProject(
|
||||
source.toFile(),
|
||||
workspace.toFile(),
|
||||
"source",
|
||||
"target"
|
||||
));
|
||||
|
||||
assertFalse(Files.exists(target));
|
||||
assertNoStages(workspace, "target");
|
||||
}
|
||||
|
||||
private static Path createSource(Path workspace, String key) throws IOException {
|
||||
Path source = workspace.resolve(key);
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions").resolve(key + ".json"), "{\"name\":\"Source\"}");
|
||||
return source;
|
||||
}
|
||||
|
||||
private static long countEntries(Path directory) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.count();
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoStages(Path workspace, String targetKey) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(workspace)) {
|
||||
assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith(
|
||||
"." + targetKey + ".importing-"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,12 @@ import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListBiome;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListFunction;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
|
||||
import art.arcane.iris.engine.object.annotations.functions.LootTableKeyFunction;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
@@ -81,6 +83,7 @@ public class SchemaBuilderParityTest {
|
||||
private static final List<String> STRUCTURE_KEYS = List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple");
|
||||
private static final List<String> BIOME_KEYS = List.of("minecraft:plains", "cool_mod:sky_meadow");
|
||||
private static final List<String> SPECIAL_ENTITY_KEYS = List.of("mythicmobs:skeleton_king");
|
||||
private static final List<String> LOOT_TABLE_KEYS = List.of("minecraft:chests/simple_dungeon", "cool_mod:chests/sky_temple");
|
||||
|
||||
// Namespaced key first, then the legacy short form for the vanilla namespace only. A mod key is addressable
|
||||
// by its full key instead of a namespace-stripped path that could collide with vanilla content.
|
||||
@@ -252,6 +255,16 @@ public class SchemaBuilderParityTest {
|
||||
enumValues(schema.getJSONObject("definitions"), "enum-vanilla-structure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lootTableFunctionUsesPlatformRegistryKeys() {
|
||||
JSONObject schema = new SchemaBuilder(LootTableModel.class, (IrisData) null).construct();
|
||||
JSONObject lootTable = schema.getJSONObject("properties").getJSONObject("lootTable");
|
||||
|
||||
assertEquals("#/definitions/loot-table-key", lootTable.getString("$ref"));
|
||||
assertEquals(LOOT_TABLE_KEYS,
|
||||
schema.getJSONObject("definitions").getJSONObject("loot-table-key").get("enum"));
|
||||
}
|
||||
|
||||
private static IrisData structureSchemaData() {
|
||||
IrisData data = mock(IrisData.class);
|
||||
ResourceLoader<IrisStructure> structureLoader = mock(ResourceLoader.class);
|
||||
@@ -368,6 +381,13 @@ public class SchemaBuilderParityTest {
|
||||
private KList<String> disabled = new KList<>();
|
||||
}
|
||||
|
||||
@Desc("Loot table model.")
|
||||
public static class LootTableModel {
|
||||
@Desc("Loot table.")
|
||||
@RegistryListFunction(LootTableKeyFunction.class)
|
||||
private String lootTable = "";
|
||||
}
|
||||
|
||||
public enum Flavor {
|
||||
ALPHA,
|
||||
BETA
|
||||
@@ -459,6 +479,11 @@ public class SchemaBuilderParityTest {
|
||||
return POTION_KEYS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> lootTableKeys() {
|
||||
return LOOT_TABLE_KEYS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||
return Map.of();
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class StudioOpenCoordinatorCloseSequenceTest {
|
||||
@Test
|
||||
public void unloadCompletesBeforeGeneratorCloseAndFolderDeletion() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
|
||||
StudioOpenCoordinator.sequenceStudioClose(
|
||||
() -> phase(phases, "evacuate"),
|
||||
() -> phase(phases, "unload"),
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folders")
|
||||
).join();
|
||||
|
||||
assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unloadFailurePreventsGeneratorCloseAndFolderDeletion() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
IllegalStateException failure = new IllegalStateException("unload rejected");
|
||||
|
||||
try {
|
||||
StudioOpenCoordinator.sequenceStudioClose(
|
||||
() -> phase(phases, "evacuate"),
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
},
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folders")
|
||||
).join();
|
||||
fail("Expected unload failure");
|
||||
} catch (CompletionException exception) {
|
||||
assertSame(failure, exception.getCause());
|
||||
}
|
||||
|
||||
assertEquals(List.of("evacuate", "unload"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatorCloseFailurePreventsFolderDeletion() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
IllegalStateException failure = new IllegalStateException("close rejected");
|
||||
|
||||
try {
|
||||
StudioOpenCoordinator.sequenceStudioClose(
|
||||
() -> phase(phases, "evacuate"),
|
||||
() -> phase(phases, "unload"),
|
||||
() -> {
|
||||
phases.add("close-generator");
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
},
|
||||
() -> phase(phases, "delete-folders")
|
||||
).join();
|
||||
fail("Expected generator close failure");
|
||||
} catch (CompletionException exception) {
|
||||
assertSame(failure, exception.getCause());
|
||||
}
|
||||
|
||||
assertEquals(List.of("evacuate", "unload", "close-generator"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
CompletableFuture<Void> unload = new CompletableFuture<>();
|
||||
|
||||
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
|
||||
() -> phase(phases, "evacuate"),
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return unload;
|
||||
},
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folders"),
|
||||
terminalTimeout::get);
|
||||
|
||||
terminalTimeout.set(true);
|
||||
unload.complete(null);
|
||||
try {
|
||||
close.join();
|
||||
fail("Expected terminal timeout");
|
||||
} catch (CompletionException exception) {
|
||||
assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage());
|
||||
}
|
||||
assertEquals(List.of("evacuate", "unload"), phases);
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> phase(List<String> phases, String phase) {
|
||||
phases.add(phase);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class StudioSVCProjectSafetyTest {
|
||||
@Test
|
||||
public void listingWithoutBranchUsesRepositoryDefault() {
|
||||
StudioSVC.PackListingReference reference = StudioSVC.resolvePackListingReference(
|
||||
"example",
|
||||
"IrisDimensions/example"
|
||||
);
|
||||
|
||||
assertEquals("IrisDimensions/example", reference.repository());
|
||||
assertEquals("HEAD", reference.ref());
|
||||
assertEquals("example", reference.expectedKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsLocalStarterWithoutARepositoryDownload() throws IOException {
|
||||
File workspace = temporaryFolder.newFolder("starter-workspace");
|
||||
|
||||
StudioSVC.createStarterProject(workspace, "new_pack");
|
||||
|
||||
Path project = workspace.toPath().resolve("new_pack");
|
||||
assertTrue(Files.isRegularFile(project.resolve("dimensions/new_pack.json")));
|
||||
assertTrue(Files.isRegularFile(project.resolve("regions/starter.json")));
|
||||
assertTrue(Files.isRegularFile(project.resolve("biomes/starter.json")));
|
||||
assertTrue(Files.isRegularFile(project.resolve("generators/flat.json")));
|
||||
assertTrue(Files.isRegularFile(project.resolve("new_pack.code-workspace")));
|
||||
PackValidationResult validation = PackValidator.validate(project.toFile());
|
||||
assertTrue(validation.getBlockingErrors().toString(), validation.isLoadable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void starterCreationNeverDeletesAnExistingTarget() throws IOException {
|
||||
File workspace = temporaryFolder.newFolder("occupied-starter-workspace");
|
||||
Path occupied = workspace.toPath().resolve("occupied");
|
||||
Files.createDirectories(occupied);
|
||||
Path marker = occupied.resolve("marker.txt");
|
||||
Files.writeString(marker, "owned");
|
||||
|
||||
assertThrows(IOException.class, () -> StudioSVC.createStarterProject(workspace, "occupied"));
|
||||
|
||||
assertTrue(Files.isRegularFile(marker));
|
||||
}
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void normalizesSafeProjectNames() throws IOException {
|
||||
assertEquals("my_pack-2", StudioSVC.normalizeProjectName(" MY_PACK-2 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnsafeProjectNames() {
|
||||
List<String> invalidNames = List.of(
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"nested/project",
|
||||
"nested\\project",
|
||||
"pack.name",
|
||||
"pack name"
|
||||
);
|
||||
|
||||
for (String invalidName : invalidNames) {
|
||||
assertThrows(IOException.class, () -> StudioSVC.normalizeProjectName(invalidName));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void choosesAnUnusedDefaultProjectWithoutReplacingExistingProjects() throws IOException {
|
||||
File workspace = temporaryFolder.newFolder("packs");
|
||||
Files.createDirectory(workspace.toPath().resolve("studio"));
|
||||
Files.createDirectory(workspace.toPath().resolve("studio2"));
|
||||
|
||||
assertEquals("studio3", StudioSVC.nextAvailableProjectName(workspace, "studio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinkWorkspace() throws IOException {
|
||||
Path realWorkspace = temporaryFolder.newFolder("real-packs").toPath();
|
||||
Path linkedWorkspace = realWorkspace.getParent().resolve("linked-packs");
|
||||
try {
|
||||
Files.createSymbolicLink(linkedWorkspace, realWorkspace.getFileName());
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
assertThrows(IOException.class, () -> StudioSVC.requireSafeWorkspace(linkedWorkspace.toFile()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class StudioSVCWorldPackPublishTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void copiesToStageAndPublishesTheCompletePack() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("world").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path stage = root.resolve("iris/.pack.installing-test");
|
||||
Path target = root.resolve("iris/pack");
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/example.json"), "{}");
|
||||
Files.createDirectories(stage);
|
||||
|
||||
StudioSVC.copyPackTree(source, stage);
|
||||
assertFalse(Files.exists(target));
|
||||
StudioSVC.publishNewDirectory(stage, target);
|
||||
|
||||
assertFalse(Files.exists(stage));
|
||||
assertTrue(Files.isRegularFile(target.resolve("dimensions/example.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingPartialTargetIsNeverReplacedOrMerged() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("existing-world").toPath();
|
||||
Path stage = root.resolve("iris/.pack.installing-test");
|
||||
Path target = root.resolve("iris/pack");
|
||||
Files.createDirectories(stage);
|
||||
Files.writeString(stage.resolve("new.txt"), "new");
|
||||
Files.createDirectories(target);
|
||||
Files.writeString(target.resolve("sentinel.txt"), "keep");
|
||||
|
||||
assertThrows(FileAlreadyExistsException.class, () -> StudioSVC.publishNewDirectory(stage, target));
|
||||
|
||||
assertEquals("keep", Files.readString(target.resolve("sentinel.txt")));
|
||||
assertFalse(Files.exists(target.resolve("new.txt")));
|
||||
assertTrue(Files.exists(stage.resolve("new.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinksInSourceAreRejectedBeforePublish() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("linked-source").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path stage = root.resolve("stage");
|
||||
Path outside = root.resolve("outside.txt");
|
||||
Files.createDirectories(source);
|
||||
Files.createDirectories(stage);
|
||||
Files.writeString(outside, "outside");
|
||||
try {
|
||||
Files.createSymbolicLink(source.resolve("link.txt"), outside);
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
assertThrows(IOException.class, () -> StudioSVC.copyPackTree(source, stage));
|
||||
assertFalse(Files.exists(stage.resolve("link.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootPackSymlinkResolvesWhileNestedLinksRemainRejected() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("linked-pack-root").toPath();
|
||||
Path source = root.resolve("source");
|
||||
Path linkedSource = root.resolve("linked_source");
|
||||
Path stage = root.resolve("stage");
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/example.json"), "{}");
|
||||
Files.createDirectories(stage);
|
||||
try {
|
||||
Files.createSymbolicLink(linkedSource, source);
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
Path resolved = StudioSVC.resolveSafePackSource(linkedSource.toFile());
|
||||
StudioSVC.copyPackTree(resolved, stage);
|
||||
|
||||
assertEquals(source.toRealPath(), resolved);
|
||||
assertTrue(Files.isRegularFile(stage.resolve("dimensions/example.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectedPublicationEvictsCreatedLoaderBeforeDiskRollback() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("cache-rollback").toPath();
|
||||
Path target = root.resolve("pack");
|
||||
Path stage = root.resolve("stage");
|
||||
Files.createDirectories(target);
|
||||
Files.writeString(target.resolve("sentinel.txt"), "previous");
|
||||
Files.createDirectories(stage);
|
||||
Files.writeString(stage.resolve("rejected.txt"), "rejected");
|
||||
AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(stage, target);
|
||||
IrisData createdData = IrisData.get(target.toFile());
|
||||
|
||||
assertSame(createdData, IrisData.getLoaded(target.toFile()).orElse(null));
|
||||
StudioSVC.rollbackFailedPublication(createdData, publication, new IOException("validation failed"));
|
||||
|
||||
assertTrue(IrisData.getLoaded(target.toFile()).isEmpty());
|
||||
assertEquals("previous", Files.readString(target.resolve("sentinel.txt")));
|
||||
assertFalse(Files.exists(target.resolve("rejected.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("project-cache-rollback").toPath();
|
||||
Path project = root.resolve("created_project");
|
||||
Path sibling = root.resolve("existing_project");
|
||||
Files.createDirectories(project.resolve("dimensions"));
|
||||
Files.writeString(project.resolve("dimensions/created_project.json"), "{}");
|
||||
Files.createDirectories(sibling.resolve("dimensions"));
|
||||
Files.writeString(sibling.resolve("dimensions/existing_project.json"), "{}");
|
||||
IrisData createdData = IrisData.get(project.toFile());
|
||||
IrisData siblingData = IrisData.get(sibling.toFile());
|
||||
|
||||
try {
|
||||
assertSame(createdData, IrisData.getLoaded(project.toFile()).orElse(null));
|
||||
assertNull(StudioSVC.rollbackCreatedProjectFiles(project.toFile()));
|
||||
|
||||
assertTrue(IrisData.getLoaded(project.toFile()).isEmpty());
|
||||
assertFalse(Files.exists(project));
|
||||
assertSame(siblingData, IrisData.getLoaded(sibling.toFile()).orElse(null));
|
||||
assertTrue(Files.isDirectory(sibling));
|
||||
} finally {
|
||||
IrisData.getLoaded(project.toFile()).ifPresent(IrisData::close);
|
||||
IrisData.getLoaded(sibling.toFile()).ifPresent(IrisData::close);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioTransitionsWaitForTheInFlightOpenBeforeReplacement() {
|
||||
StudioSVC.StudioTransitionQueue transitions = new StudioSVC.StudioTransitionQueue();
|
||||
CompletableFuture<String> firstGate = new CompletableFuture<>();
|
||||
CompletableFuture<String> secondGate = new CompletableFuture<>();
|
||||
List<String> events = new ArrayList<>();
|
||||
|
||||
CompletableFuture<String> first = transitions.submit(() -> {
|
||||
events.add("first-start");
|
||||
return firstGate;
|
||||
});
|
||||
CompletableFuture<String> second = transitions.submit(() -> {
|
||||
events.add("second-start");
|
||||
return secondGate;
|
||||
});
|
||||
|
||||
assertEquals(List.of("first-start"), events);
|
||||
assertFalse(first.isDone());
|
||||
assertFalse(second.isDone());
|
||||
|
||||
firstGate.complete("first");
|
||||
assertEquals(List.of("first-start", "second-start"), events);
|
||||
assertEquals("first", first.join());
|
||||
assertFalse(second.isDone());
|
||||
|
||||
secondGate.complete("second");
|
||||
assertEquals("second", second.join());
|
||||
}
|
||||
}
|
||||
+81
@@ -20,7 +20,15 @@ package art.arcane.iris.core.structure;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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 BulkStructureImporterTemplateNameTest {
|
||||
@Test
|
||||
@@ -51,4 +59,77 @@ public class BulkStructureImporterTemplateNameTest {
|
||||
assertEquals("nova/a/b", BulkStructureImporter.templateNameFor("nova:a//b"));
|
||||
assertEquals("nova/leading", BulkStructureImporter.templateNameFor("nova:/leading"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitSourceScopeIncludesOnlyOwnedRegistryKeys() {
|
||||
Set<String> allowed = Set.of("nova:tavern", "minecraft:village_plains");
|
||||
|
||||
assertTrue(BulkStructureImporter.isAllowedDatapackKey("nova:tavern", allowed));
|
||||
assertTrue(BulkStructureImporter.isAllowedDatapackKey("minecraft:village_plains", allowed));
|
||||
assertFalse(BulkStructureImporter.isAllowedDatapackKey("other:castle", allowed));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitSourceSelectionReportsEveryMissingAllowedKey() {
|
||||
BulkStructureImporter.KeySelection selection = BulkStructureImporter.selectDatapackKeys(
|
||||
List.of("NOVA:TAVERN", "other:castle", "nova:tavern"),
|
||||
Set.of("nova:tavern", "nova:missing", "minecraft:village_plains")
|
||||
);
|
||||
|
||||
assertEquals(List.of("nova:tavern"), selection.present());
|
||||
assertEquals(List.of("minecraft:village_plains", "nova:missing"), selection.missing());
|
||||
assertEquals(3, selection.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultSourceSelectionExcludesMinecraftAndDeduplicatesKeys() {
|
||||
BulkStructureImporter.KeySelection selection = BulkStructureImporter.selectDatapackKeys(
|
||||
List.of("minecraft:village", "Nova:Tavern", "nova:tavern", "other:castle"),
|
||||
null
|
||||
);
|
||||
|
||||
assertEquals(List.of("nova:tavern", "other:castle"), selection.present());
|
||||
assertTrue(selection.missing().isEmpty());
|
||||
assertEquals(2, selection.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void datapackReportIncludesStructureAndTemplateAttempts() {
|
||||
BulkStructureImporter.Report report = BulkStructureImporter.datapackReport(
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
Map.of("iris:nova_tavern", "nova:tavern")
|
||||
);
|
||||
|
||||
assertEquals(5, report.total());
|
||||
assertEquals(3, report.imported());
|
||||
assertEquals(1, report.skipped());
|
||||
assertEquals(1, report.failed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enumerationFailureIsOneFailedAttempt() {
|
||||
BulkStructureImporter.Report report = BulkStructureImporter.enumerationFailureReport();
|
||||
|
||||
assertEquals(1, report.total());
|
||||
assertEquals(0, report.imported());
|
||||
assertEquals(0, report.skipped());
|
||||
assertEquals(1, report.failed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulBundleEvidenceIsDefensiveAndImmutable() {
|
||||
Map<String, String> successfulBundles = new HashMap<>();
|
||||
successfulBundles.put("iris:nova_tavern", "nova:tavern");
|
||||
BulkStructureImporter.Report report = new BulkStructureImporter.Report(1, 1, 0, 0, successfulBundles);
|
||||
|
||||
successfulBundles.put("iris:other_castle", "other:castle");
|
||||
|
||||
assertEquals(Map.of("iris:nova_tavern", "nova:tavern"), report.successfulBundles());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> report.successfulBundles().put("iris:third", "third:structure"));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package art.arcane.iris.core.structure;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class FeatureImporterScratchLifecycleTest {
|
||||
@Test
|
||||
public void scratchWorldNamesRequireReservedUuidIdentity() {
|
||||
assertTrue(FeatureImporter.isReservedScratchWorldName(
|
||||
"iris-feature-import-bac1678e-9bca-4d70-9510-a146566e478c"));
|
||||
assertFalse(FeatureImporter.isReservedScratchWorldName("iris_vanilla_import"));
|
||||
assertFalse(FeatureImporter.isReservedScratchWorldName("iris-feature-import-not-a-uuid"));
|
||||
assertFalse(FeatureImporter.isReservedScratchWorldName(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmedUnloadClosesGeneratorBeforeDeletingFolder() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
|
||||
FeatureImporter.sequenceScratchTeardown(
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return CompletableFuture.completedFuture(true);
|
||||
},
|
||||
() -> false,
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folder"),
|
||||
"scratch"
|
||||
).join();
|
||||
|
||||
assertEquals(List.of("unload", "close-generator", "delete-folder"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedUnloadNeverClosesGeneratorOrDeletesFolder() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
|
||||
try {
|
||||
FeatureImporter.sequenceScratchTeardown(
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return CompletableFuture.completedFuture(false);
|
||||
},
|
||||
() -> true,
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folder"),
|
||||
"scratch"
|
||||
).join();
|
||||
fail("Expected unload refusal");
|
||||
} catch (CompletionException exception) {
|
||||
assertTrue(exception.getCause().getMessage().contains("not confirmed"));
|
||||
}
|
||||
|
||||
assertEquals(List.of("unload"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void identityStillLoadedPreventsGeneratorCloseAndFolderDeletion() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
|
||||
try {
|
||||
FeatureImporter.sequenceScratchTeardown(
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return CompletableFuture.completedFuture(true);
|
||||
},
|
||||
() -> true,
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folder"),
|
||||
"scratch"
|
||||
).join();
|
||||
fail("Expected loaded-identity refusal");
|
||||
} catch (CompletionException exception) {
|
||||
assertTrue(exception.getCause().getMessage().contains("not confirmed"));
|
||||
}
|
||||
|
||||
assertEquals(List.of("unload"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatorCloseFailureNeverDeletesFolder() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
IllegalStateException failure = new IllegalStateException("close rejected");
|
||||
|
||||
try {
|
||||
FeatureImporter.sequenceScratchTeardown(
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return CompletableFuture.completedFuture(true);
|
||||
},
|
||||
() -> false,
|
||||
() -> {
|
||||
phases.add("close-generator");
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
},
|
||||
() -> phase(phases, "delete-folder"),
|
||||
"scratch"
|
||||
).join();
|
||||
fail("Expected generator close failure");
|
||||
} catch (CompletionException exception) {
|
||||
assertSame(failure, exception.getCause());
|
||||
}
|
||||
|
||||
assertEquals(List.of("unload", "close-generator"), phases);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() {
|
||||
ArrayList<String> phases = new ArrayList<>();
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
CompletableFuture<Boolean> unload = new CompletableFuture<>();
|
||||
|
||||
CompletableFuture<Void> cleanup = FeatureImporter.sequenceScratchTeardown(
|
||||
() -> {
|
||||
phases.add("unload");
|
||||
return unload;
|
||||
},
|
||||
() -> false,
|
||||
() -> phase(phases, "close-generator"),
|
||||
() -> phase(phases, "delete-folder"),
|
||||
"scratch",
|
||||
terminalTimeout::get);
|
||||
|
||||
terminalTimeout.set(true);
|
||||
unload.complete(true);
|
||||
try {
|
||||
cleanup.join();
|
||||
fail("Expected terminal timeout");
|
||||
} catch (CompletionException exception) {
|
||||
assertEquals("Scratch world cleanup stopped after its terminal timeout.",
|
||||
exception.getCause().getMessage());
|
||||
}
|
||||
assertEquals(List.of("unload"), phases);
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> phase(List<String> phases, String phase) {
|
||||
phases.add(phase);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
+191
-4
@@ -313,6 +313,25 @@ public class StructureTransactionWriterTest {
|
||||
assertFalse(Files.exists(root.resolve("objects/temple.iob")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveryBoundsTransactionDirectoryCount() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("bounded-transaction-count").toPath();
|
||||
Path staging = root.resolve(".iris/structure-staging");
|
||||
Files.createDirectories(staging);
|
||||
for (int i = 0; i < 1_025; i++) {
|
||||
Files.createDirectory(staging.resolve(UUID.randomUUID().toString()));
|
||||
}
|
||||
|
||||
StructureRecoveryResult recovery = new StructureTransactionWriter(root)
|
||||
.recoverIncompleteTransactions();
|
||||
|
||||
assertFalse(recovery.successful());
|
||||
assertTrue(recovery.failures().getFirst().cause().getMessage().contains("transaction count"));
|
||||
try (Stream<Path> transactions = Files.list(staging)) {
|
||||
assertEquals(1_025L, transactions.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writeRecoversPreparedTransactionBeforePreflight() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("write-recovery").toPath();
|
||||
@@ -412,6 +431,161 @@ public class StructureTransactionWriterTest {
|
||||
assertFalse(Files.exists(transactionRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedRemovalDeletesOnlyVerifiedBundleResources() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal").toPath();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY);
|
||||
assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status());
|
||||
|
||||
boolean removed = writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY);
|
||||
|
||||
assertTrue(removed);
|
||||
assertFalse(Files.exists(root.resolve("objects/temple.iob")));
|
||||
assertFalse(Files.exists(root.resolve("structures/temple.json")));
|
||||
assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY)));
|
||||
assertFalse(writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedRemovalPreservesModifiedResourcesAndManifest() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal-modified").toPath();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY);
|
||||
assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status());
|
||||
Path object = root.resolve("objects/temple.iob");
|
||||
Files.writeString(object, "user-edit", StandardCharsets.UTF_8);
|
||||
|
||||
try {
|
||||
writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY);
|
||||
throw new AssertionError("Expected modified owned resource to block removal");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("modified"));
|
||||
}
|
||||
|
||||
assertEquals("user-edit", Files.readString(object, StandardCharsets.UTF_8));
|
||||
assertTrue(Files.exists(root.resolve("structures/temple.json")));
|
||||
assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedMultiBundleRemovalRestoresEarlierMovesWhenALaterMoveFails() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal-batch-rollback").toPath();
|
||||
StructureKey alphaKey = StructureKey.parse("iris_test:alpha");
|
||||
StructureKey zetaKey = StructureKey.parse("iris_test:zeta");
|
||||
StructureKey alphaSource = StructureKey.parse("example:alpha");
|
||||
StructureKey zetaSource = StructureKey.parse("example:zeta");
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult alphaWrite = writer.write(
|
||||
bundle(alphaKey, alphaSource, "alpha-object", "alpha-structure"),
|
||||
StructureWriteMode.ADD_ONLY
|
||||
);
|
||||
StructureWriteResult zetaWrite = writer.write(
|
||||
bundle(zetaKey, zetaSource, "zeta-object", "zeta-structure"),
|
||||
StructureWriteMode.ADD_ONLY
|
||||
);
|
||||
assertEquals(failureMessage(alphaWrite), StructureWriteResult.Status.ADDED, alphaWrite.status());
|
||||
assertEquals(failureMessage(zetaWrite), StructureWriteResult.Status.ADDED, zetaWrite.status());
|
||||
StructureTransactionWriter failingWriter = new StructureTransactionWriter(
|
||||
root,
|
||||
new FailOnceMoveOperations("objects/zeta.iob")
|
||||
);
|
||||
|
||||
try {
|
||||
failingWriter.prepareOwnedRemovals(List.of(
|
||||
new StructureTransactionWriter.OwnedRemoval(
|
||||
alphaKey,
|
||||
StructureSource.Kind.DATAPACK,
|
||||
alphaSource
|
||||
),
|
||||
new StructureTransactionWriter.OwnedRemoval(
|
||||
zetaKey,
|
||||
StructureSource.Kind.DATAPACK,
|
||||
zetaSource
|
||||
)
|
||||
));
|
||||
throw new AssertionError("Expected the later removal move to fail");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("Injected install failure"));
|
||||
}
|
||||
|
||||
assertEquals("alpha-object", Files.readString(root.resolve("objects/alpha.iob"), StandardCharsets.UTF_8));
|
||||
assertEquals("zeta-object", Files.readString(root.resolve("objects/zeta.iob"), StandardCharsets.UTF_8));
|
||||
assertTrue(Files.exists(writer.ownershipManifestPath(alphaKey)));
|
||||
assertTrue(Files.exists(writer.ownershipManifestPath(zetaKey)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedRemovalCanRollbackAfterCommitMarkerBeforeCoordinatorPublish() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal-coordinator-rollback").toPath();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY);
|
||||
assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status());
|
||||
StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of(
|
||||
new StructureTransactionWriter.OwnedRemoval(
|
||||
TARGET_KEY,
|
||||
StructureSource.Kind.VANILLA,
|
||||
SOURCE_KEY
|
||||
)
|
||||
));
|
||||
assertFalse(Files.exists(root.resolve("objects/temple.iob")));
|
||||
|
||||
removal.markCommitted();
|
||||
removal.rollback();
|
||||
|
||||
assertEquals("object-v1", Files.readString(root.resolve("objects/temple.iob"), StandardCharsets.UTF_8));
|
||||
assertEquals("structure-v1", Files.readString(root.resolve("structures/temple.json"), StandardCharsets.UTF_8));
|
||||
assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedRemovalTokenCanRestoreAfterTheCoordinatorProcessDies() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal-token-rollback").toPath();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY);
|
||||
assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status());
|
||||
StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of(
|
||||
new StructureTransactionWriter.OwnedRemoval(
|
||||
TARGET_KEY,
|
||||
StructureSource.Kind.VANILLA,
|
||||
SOURCE_KEY
|
||||
)
|
||||
));
|
||||
StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow();
|
||||
removal.leaveForRecovery();
|
||||
|
||||
new StructureTransactionWriter(root).resolvePreparedRemoval(token, false);
|
||||
|
||||
assertEquals("object-v1", Files.readString(root.resolve("objects/temple.iob"), StandardCharsets.UTF_8));
|
||||
assertEquals("structure-v1", Files.readString(root.resolve("structures/temple.json"), StandardCharsets.UTF_8));
|
||||
assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY)));
|
||||
assertFalse(Files.exists(transactionRoot(root, token.transactionId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedRemovalTokenCanCommitAfterTheCoordinatorProcessDies() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("owned-removal-token-commit").toPath();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(root);
|
||||
StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY);
|
||||
assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status());
|
||||
StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of(
|
||||
new StructureTransactionWriter.OwnedRemoval(
|
||||
TARGET_KEY,
|
||||
StructureSource.Kind.VANILLA,
|
||||
SOURCE_KEY
|
||||
)
|
||||
));
|
||||
StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow();
|
||||
removal.leaveForRecovery();
|
||||
|
||||
new StructureTransactionWriter(root).resolvePreparedRemoval(token, true);
|
||||
|
||||
assertFalse(Files.exists(root.resolve("objects/temple.iob")));
|
||||
assertFalse(Files.exists(root.resolve("structures/temple.json")));
|
||||
assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY)));
|
||||
assertFalse(Files.exists(transactionRoot(root, token.transactionId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinkAncestorsCannotEscapeThePackRoot() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("symlink-pack").toPath();
|
||||
@@ -431,8 +605,21 @@ public class StructureTransactionWriterTest {
|
||||
}
|
||||
|
||||
private StructureResourceBundle bundle(String objectContent, String structureContent) {
|
||||
return StructureResourceBundle.builder(TARGET_KEY)
|
||||
.source(StructureSource.of(StructureSource.Kind.VANILLA, SOURCE_KEY))
|
||||
return bundle(TARGET_KEY, SOURCE_KEY, objectContent, structureContent);
|
||||
}
|
||||
|
||||
private StructureResourceBundle bundle(
|
||||
StructureKey targetKey,
|
||||
StructureKey sourceKey,
|
||||
String objectContent,
|
||||
String structureContent
|
||||
) {
|
||||
return StructureResourceBundle.builder(targetKey)
|
||||
.source(StructureSource.of(
|
||||
sourceKey.namespace().equals("minecraft")
|
||||
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK,
|
||||
sourceKey
|
||||
))
|
||||
.backend(StructureBackend.IRIS_ASSEMBLY)
|
||||
.capability(StructureCapability.BLOCKS)
|
||||
.capability(StructureCapability.CONNECTORS)
|
||||
@@ -441,8 +628,8 @@ public class StructureTransactionWriterTest {
|
||||
"processors_omitted",
|
||||
"The source processor list was not represented"
|
||||
))
|
||||
.resource("objects/temple.iob", objectContent.getBytes(StandardCharsets.UTF_8))
|
||||
.textResource("structures/temple.json", structureContent)
|
||||
.resource("objects/" + targetKey.path() + ".iob", objectContent.getBytes(StandardCharsets.UTF_8))
|
||||
.textResource("structures/" + targetKey.path() + ".json", structureContent)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package art.arcane.iris.core.tools;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisToolbeltEvacuationTest {
|
||||
@Test
|
||||
public void evacuationCompletionWaitsForEveryPlayerTeleport() {
|
||||
CompletableFuture<Boolean> first = new CompletableFuture<>();
|
||||
CompletableFuture<Boolean> second = new CompletableFuture<>();
|
||||
|
||||
CompletableFuture<Boolean> evacuation = IrisToolbelt.settleEvacuations(List.of(first, second));
|
||||
|
||||
first.complete(true);
|
||||
assertFalse(evacuation.isDone());
|
||||
second.complete(true);
|
||||
assertTrue(evacuation.join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPlayerTeleportFailsEvacuation() {
|
||||
CompletableFuture<Boolean> evacuation = IrisToolbelt.settleEvacuations(List.of(
|
||||
CompletableFuture.completedFuture(true),
|
||||
CompletableFuture.completedFuture(false)));
|
||||
|
||||
assertFalse(evacuation.join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exceptionalPlayerTeleportFailsEvacuation() {
|
||||
CompletableFuture<Boolean> evacuation = IrisToolbelt.settleEvacuations(List.of(
|
||||
CompletableFuture.completedFuture(true),
|
||||
CompletableFuture.failedFuture(new IllegalStateException("teleport failed"))));
|
||||
|
||||
assertFalse(evacuation.join());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ public class IrisToolbeltPackReferenceTest {
|
||||
assertTrue(reference.explicitDimension());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repositoryShorthandUsesRepositoryAsDefaultDimension() {
|
||||
IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference("IrisDimensions/overworld/stable");
|
||||
|
||||
assertEquals("IrisDimensions/overworld/stable", reference.pack());
|
||||
assertEquals("overworld", reference.dimension());
|
||||
assertFalse(reference.explicitDimension());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedReferencesAreRejected() {
|
||||
assertNull(IrisToolbelt.parsePackReference(null));
|
||||
@@ -44,6 +53,14 @@ public class IrisToolbeltPackReferenceTest {
|
||||
assertNull(IrisToolbelt.parsePackReference(":"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:"));
|
||||
assertNull(IrisToolbelt.parsePackReference(":dimension"));
|
||||
assertNull(IrisToolbelt.parsePackReference("../outside:dimension"));
|
||||
assertNull(IrisToolbelt.parsePackReference("owner/../outside:dimension"));
|
||||
assertNull(IrisToolbelt.parsePackReference("/absolute:dimension"));
|
||||
assertNull(IrisToolbelt.parsePackReference(".iris-import-stage:dimension"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:../../outside"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:/absolute"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:nested\\outside"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:.hidden"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class EngineShutdownOwnershipContractTest {
|
||||
@Test
|
||||
public void ownershipCloseMustSucceedBeforeMantleRelease() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java"));
|
||||
int ownershipClose = source.indexOf("NativeStructureOwnershipStore.close(engine)");
|
||||
int ownershipGate = source.indexOf("if (ownershipFailure == null)", ownershipClose);
|
||||
int mantleRelease = source.indexOf("releaseMantle(failure)", ownershipGate);
|
||||
|
||||
assertTrue(ownershipClose >= 0);
|
||||
assertTrue(ownershipGate > ownershipClose);
|
||||
assertTrue(mantleRelease > ownershipGate);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedConstructionKeepsMantleOpenWhileOwnershipWritesRemain() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java"));
|
||||
int cleanupStart = source.indexOf("void cleanupFailedConstruction");
|
||||
int cleanupEnd = source.indexOf("Throwable closeAssembly", cleanupStart);
|
||||
String cleanup = source.substring(cleanupStart, cleanupEnd);
|
||||
int ownershipClose = cleanup.indexOf("NativeStructureOwnershipStore.close(engine)");
|
||||
int ownershipGate = cleanup.indexOf("if (ownershipFailure == null)", ownershipClose);
|
||||
int mantleRelease = cleanup.indexOf("engine.getMantle()::close", ownershipGate);
|
||||
int closedPublication = cleanup.indexOf("engine.closed = true", ownershipGate);
|
||||
|
||||
assertTrue(ownershipClose >= 0);
|
||||
assertTrue(ownershipGate > ownershipClose);
|
||||
assertTrue(mantleRelease > ownershipGate);
|
||||
assertTrue(closedPublication > ownershipGate);
|
||||
}
|
||||
}
|
||||
+117
@@ -46,6 +46,7 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -354,6 +355,57 @@ public class IrisStructureLocatorContractTest {
|
||||
verify(engine, times(4_096)).getComplex();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void densityLocateDoesNotReturnPartialRingWinnerAtSafetyLimit() {
|
||||
Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032);
|
||||
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
engine, "test:density", 0, 0, 2048,
|
||||
(chunkX, chunkZ) -> chunkX == 31 && chunkZ == 31
|
||||
|| chunkX == 0 && chunkZ == 32);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status());
|
||||
assertFalse(result.found());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroRadiusDoesNotSearchAdjacentDensityChunks() {
|
||||
Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032);
|
||||
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
engine, "test:density", 0, 0, 0,
|
||||
(chunkX, chunkZ) -> chunkX == 1 && chunkZ == 0);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, result.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void locateRanksEditableStartsByResolvedBlockOrigin() {
|
||||
Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032);
|
||||
when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(128);
|
||||
when(engine.getDimension().getFluidHeight()).thenReturn(63);
|
||||
IrisStructurePlacement placement = engine.getDimension().getStructures().get(0);
|
||||
NearestScenario scenario = nearestEqualChunkDistanceScenario(engine, placement);
|
||||
assertNotNull(scenario);
|
||||
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
engine, "test:density", scenario.fromBlockX(), scenario.fromBlockZ(), 1,
|
||||
(chunkX, chunkZ) -> chunkX == scenario.firstChunkX() && chunkZ == scenario.firstChunkZ()
|
||||
|| chunkX == scenario.nearestChunkX() && chunkZ == scenario.nearestChunkZ());
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status());
|
||||
assertEquals(scenario.nearestOriginX(), result.originX());
|
||||
assertEquals(scenario.nearestOriginZ(), result.originZ());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void diagonalRadiusBoundaryUsesVanillaChebyshevSemantics() {
|
||||
assertTrue(IrisStructureLocator.withinRadius(1, 1, 0, 0, 1));
|
||||
assertTrue(IrisStructureLocator.withinRadius(-1, -1, 0, 0, 1));
|
||||
assertFalse(IrisStructureLocator.withinRadius(2, 1, 0, 0, 1));
|
||||
assertFalse(IrisStructureLocator.withinRadius(1, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchableDensityRequiresPositiveProbabilityAndWorldHeightOverlap() {
|
||||
Engine engine = mock(Engine.class);
|
||||
@@ -566,6 +618,60 @@ public class IrisStructureLocatorContractTest {
|
||||
assertEquals(1L, IrisStructureLocator.cellRingDistanceLowerBound(1, 32, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkBlockLowerBoundSaturatesAtExtremeCoordinates() {
|
||||
assertEquals(Long.MAX_VALUE, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound(
|
||||
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE));
|
||||
assertEquals(1L, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound(1, 0, 15, 8));
|
||||
assertEquals(0L, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound(0, 0, 15, 8));
|
||||
}
|
||||
|
||||
private NearestScenario nearestEqualChunkDistanceScenario(
|
||||
Engine engine, IrisStructurePlacement placement) {
|
||||
int[][] chunks = {
|
||||
{-1, -1}, {-1, 0}, {-1, 1}, {0, -1},
|
||||
{0, 1}, {1, -1}, {1, 0}, {1, 1}
|
||||
};
|
||||
IrisStructureLocator.ResolvedPlacement[] resolved =
|
||||
new IrisStructureLocator.ResolvedPlacement[chunks.length];
|
||||
for (int i = 0; i < chunks.length; i++) {
|
||||
resolved[i] = IrisStructureLocator.resolvePlacement(
|
||||
engine, placement, chunks[i][0], chunks[i][1]);
|
||||
assertNotNull(resolved[i]);
|
||||
}
|
||||
for (int fromBlockX = 0; fromBlockX < 16; fromBlockX++) {
|
||||
for (int fromBlockZ = 0; fromBlockZ < 16; fromBlockZ++) {
|
||||
for (int first = 0; first < chunks.length; first++) {
|
||||
int firstChunkDistance = chunks[first][0] * chunks[first][0]
|
||||
+ chunks[first][1] * chunks[first][1];
|
||||
long firstDistance = resolvedDistanceSquared(
|
||||
resolved[first], fromBlockX, fromBlockZ);
|
||||
for (int later = first + 1; later < chunks.length; later++) {
|
||||
int laterChunkDistance = chunks[later][0] * chunks[later][0]
|
||||
+ chunks[later][1] * chunks[later][1];
|
||||
long laterDistance = resolvedDistanceSquared(
|
||||
resolved[later], fromBlockX, fromBlockZ);
|
||||
if (firstChunkDistance == laterChunkDistance && laterDistance < firstDistance) {
|
||||
return new NearestScenario(
|
||||
fromBlockX, fromBlockZ,
|
||||
chunks[first][0], chunks[first][1],
|
||||
chunks[later][0], chunks[later][1],
|
||||
resolved[later].originX(), resolved[later].originZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private long resolvedDistanceSquared(
|
||||
IrisStructureLocator.ResolvedPlacement resolved, int fromBlockX, int fromBlockZ) {
|
||||
long dx = (long) resolved.originX() - fromBlockX;
|
||||
long dz = (long) resolved.originZ() - fromBlockZ;
|
||||
return dx * dx + dz * dz;
|
||||
}
|
||||
|
||||
private PlacedStructurePiece piece(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
return new PlacedStructurePiece(null, null, 0, 0, 0, null, minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
@@ -639,4 +745,15 @@ public class IrisStructureLocatorContractTest {
|
||||
}
|
||||
throw new AssertionError("Expected strict native replacement failure containing '" + expectedMessage + "'");
|
||||
}
|
||||
|
||||
private record NearestScenario(
|
||||
int fromBlockX,
|
||||
int fromBlockZ,
|
||||
int firstChunkX,
|
||||
int firstChunkZ,
|
||||
int nearestChunkX,
|
||||
int nearestChunkZ,
|
||||
int nearestOriginX,
|
||||
int nearestOriginZ) {
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.StructureDistribution;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisStructurePlacementRingLocatorTest {
|
||||
private static final String STRUCTURE_KEY = "test:manual_native";
|
||||
|
||||
@Test
|
||||
public void zeroVanillaRingSearchesTheOriginPlacementCell() {
|
||||
IrisStructurePlacement placement = randomPlacement("origin-cell", 24, 6);
|
||||
Engine engine = engine(placement);
|
||||
long seed = engine.getSeedManager().getMantle();
|
||||
int[] expected = StructurePlacementGrid.randomSpreadCellChunk(
|
||||
0, 0, placement.getSpacing(), placement.getSeparation(),
|
||||
StructurePlacementGrid.placementSalt(placement), seed);
|
||||
assertTrue(expected[0] != 0 || expected[1] != 0);
|
||||
|
||||
IrisStructureLocator.LocateResult chunkRadius = IrisStructureLocator.locate(
|
||||
engine, STRUCTURE_KEY, 0, 0, 0);
|
||||
IrisStructureLocator.LocateResult placementRadius =
|
||||
IrisStructureLocator.locateInPlacementRings(
|
||||
engine, STRUCTURE_KEY, 0, 0, 0, (chunkX, chunkZ) -> true);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, chunkRadius.status());
|
||||
assertEquals(IrisStructureLocator.LocateStatus.FOUND, placementRadius.status());
|
||||
assertEquals(expected[0] << 4, placementRadius.originX());
|
||||
assertEquals(expected[1] << 4, placementRadius.originZ());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mixedSpacingPlacementsShareTheSameVanillaRingIndex() {
|
||||
IrisStructurePlacement small = randomPlacement("small-grid", 8, 2);
|
||||
IrisStructurePlacement large = randomPlacement("large-grid", 40, 10);
|
||||
Engine engine = engine(small, large);
|
||||
long seed = engine.getSeedManager().getMantle();
|
||||
int[] smallRingZero = candidate(small, seed, 0, 0);
|
||||
int[] largeRingZero = candidate(large, seed, 0, 0);
|
||||
int[] smallRingOne = candidate(small, seed, -1, -1);
|
||||
AtomicBoolean visitedLaterSmallRing = new AtomicBoolean();
|
||||
|
||||
IrisStructureLocator.LocateResult result =
|
||||
IrisStructureLocator.locateInPlacementRings(
|
||||
engine, STRUCTURE_KEY, 0, 0, 1,
|
||||
(chunkX, chunkZ) -> {
|
||||
if (chunkX == smallRingOne[0] && chunkZ == smallRingOne[1]) {
|
||||
visitedLaterSmallRing.set(true);
|
||||
}
|
||||
return chunkX == largeRingZero[0] && chunkZ == largeRingZero[1];
|
||||
});
|
||||
|
||||
assertTrue(smallRingZero[0] != largeRingZero[0]
|
||||
|| smallRingZero[1] != largeRingZero[1]);
|
||||
assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status());
|
||||
assertEquals(largeRingZero[0] << 4, result.originX());
|
||||
assertEquals(largeRingZero[1] << 4, result.originZ());
|
||||
assertFalse(visitedLaterSmallRing.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concentricPlacementsIgnoreTheRandomSpreadRingRadius() {
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId("concentric")
|
||||
.setDistribution(StructureDistribution.CONCENTRIC_RINGS)
|
||||
.setRingCount(1)
|
||||
.setRingDistance(32)
|
||||
.setRingSpread(1);
|
||||
placement.getNativeStructures().add(
|
||||
new IrisNativeStructure().setStructure(STRUCTURE_KEY));
|
||||
Engine engine = engine(placement);
|
||||
|
||||
IrisStructureLocator.LocateResult result =
|
||||
IrisStructureLocator.locateInPlacementRings(
|
||||
engine, STRUCTURE_KEY, 0, 0, 0, (chunkX, chunkZ) -> true);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status());
|
||||
long chunkX = result.originX() >> 4;
|
||||
long chunkZ = result.originZ() >> 4;
|
||||
assertTrue(chunkX * chunkX + chunkZ * chunkZ >= 31L * 31L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sparseDensityPlacementRingSearchStopsAtTheCandidateBudget() {
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId("density")
|
||||
.setDistribution(StructureDistribution.DENSITY)
|
||||
.setDensity(1.0);
|
||||
placement.getNativeStructures().add(
|
||||
new IrisNativeStructure().setStructure(STRUCTURE_KEY));
|
||||
Engine engine = engine(placement);
|
||||
|
||||
IrisStructureLocator.LocateResult result =
|
||||
IrisStructureLocator.locateInPlacementRings(
|
||||
engine, STRUCTURE_KEY, 0, 0, 2048,
|
||||
(chunkX, chunkZ) -> false);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concentricPlacementRingSearchStopsAtTheSharedCandidateBudget() {
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId("unbounded-concentric-rings")
|
||||
.setDistribution(StructureDistribution.CONCENTRIC_RINGS)
|
||||
.setRingCount(Integer.MAX_VALUE)
|
||||
.setRingDistance(32)
|
||||
.setRingSpread(1);
|
||||
placement.getNativeStructures().add(
|
||||
new IrisNativeStructure().setStructure(STRUCTURE_KEY));
|
||||
Engine engine = engine(placement);
|
||||
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locateInPlacementRings(
|
||||
engine, STRUCTURE_KEY, 0, 0, 2048, (chunkX, chunkZ) -> false);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concentricChunkRadiusSearchStopsAtTheSharedCandidateBudget() {
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId("unbounded-concentric-chunks")
|
||||
.setDistribution(StructureDistribution.CONCENTRIC_RINGS)
|
||||
.setRingCount(Integer.MAX_VALUE)
|
||||
.setRingDistance(32)
|
||||
.setRingSpread(1);
|
||||
placement.getNativeStructures().add(
|
||||
new IrisNativeStructure().setStructure(STRUCTURE_KEY));
|
||||
Engine engine = engine(placement);
|
||||
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
engine, STRUCTURE_KEY, 0, 0, 2048, (chunkX, chunkZ) -> false);
|
||||
|
||||
assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status());
|
||||
}
|
||||
|
||||
private static int[] candidate(
|
||||
IrisStructurePlacement placement, long seed, int cellX, int cellZ) {
|
||||
return StructurePlacementGrid.randomSpreadCellChunk(
|
||||
cellX, cellZ, placement.getSpacing(), placement.getSeparation(),
|
||||
StructurePlacementGrid.placementSalt(placement), seed);
|
||||
}
|
||||
|
||||
private static IrisStructurePlacement randomPlacement(
|
||||
String placementId, int spacing, int separation) {
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId(placementId)
|
||||
.setDistribution(StructureDistribution.RANDOM_SPREAD)
|
||||
.setSpacing(spacing)
|
||||
.setSeparation(separation);
|
||||
placement.getNativeStructures().add(
|
||||
new IrisNativeStructure().setStructure(STRUCTURE_KEY));
|
||||
return placement;
|
||||
}
|
||||
|
||||
private static Engine engine(IrisStructurePlacement... placements) {
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
Engine engine = mock(Engine.class);
|
||||
KList<IrisStructurePlacement> configured = new KList<>();
|
||||
for (IrisStructurePlacement placement : placements) {
|
||||
configured.add(placement);
|
||||
}
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getSeedManager()).thenReturn(new SeedManager(1337L));
|
||||
when(engine.getComplex()).thenReturn(null);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight()).thenReturn(384);
|
||||
when(engine.getHeight(anyInt(), anyInt(), anyBoolean())).thenReturn(128);
|
||||
when(dimension.getFluidHeight()).thenReturn(63);
|
||||
when(dimension.getStructures()).thenReturn(configured);
|
||||
when(dimension.getAllRegions(engine)).thenReturn(new KList<>());
|
||||
when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>());
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructureCarveShape;
|
||||
import art.arcane.iris.engine.object.IrisStructureStiltSettings;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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;
|
||||
|
||||
public class NativeStructureOwnershipRecordTest {
|
||||
private static final String FINGERPRINT = "12".repeat(32);
|
||||
|
||||
@Test
|
||||
public void binaryRoundTripPreservesMultipleVersionedOwnershipRecords() throws Exception {
|
||||
NativeStructureOwnershipRecord tavern = record("nova_structures:tavern_oak", 4, -7, 71L);
|
||||
NativeStructureOwnershipRecord crypt = record("nova_structures:undead_crypt", 4, -7, 72L);
|
||||
NativeStructureOwnershipBundle bundle = NativeStructureOwnershipBundle.empty()
|
||||
.with(tavern)
|
||||
.with(crypt);
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream();
|
||||
bundle.write(new DataOutputStream(encoded));
|
||||
NativeStructureOwnershipBundle restored = NativeStructureOwnershipBundle.read(
|
||||
new DataInputStream(new ByteArrayInputStream(encoded.toByteArray())));
|
||||
|
||||
assertNotNull(restored);
|
||||
assertEquals(tavern, restored.find("NOVA_STRUCTURES:TAVERN_OAK", 4, -7));
|
||||
assertEquals(crypt, restored.find("nova_structures:undead_crypt", 4, -7));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decisionSnapshotFreezesTerrainVegetationAndStiltSettings() {
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(9)
|
||||
.setCeilingPadding(3)
|
||||
.setFloorPadding(2)
|
||||
.setShape(IrisStructureCarveShape.ERODED)
|
||||
.setErosionStrength(0.42D)
|
||||
.setErosionFrequency(0.17D)
|
||||
.setLobeFrequency(0.03D)
|
||||
.setLobeStrength(0.61D);
|
||||
IrisStructureStiltSettings stilt = new IrisStructureStiltSettings()
|
||||
.setMaxDepth(91)
|
||||
.setSpacing(4)
|
||||
.setSupportNonOccluding(true);
|
||||
IrisNativeStructureDecision source = new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
0,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
stilt,
|
||||
terrain
|
||||
);
|
||||
|
||||
NativeStructureOwnershipRecord.DecisionSnapshot snapshot =
|
||||
NativeStructureOwnershipRecord.DecisionSnapshot.capture(source);
|
||||
terrain.setHorizontalPadding(1);
|
||||
stilt.setMaxDepth(2);
|
||||
IrisNativeStructureDecision restored = snapshot.restore();
|
||||
|
||||
assertTrue(restored.generate());
|
||||
assertTrue(restored.clearVegetation());
|
||||
assertEquals(IrisStructureTerrainMode.FORCE_CARVE, restored.terrain().resolvedMode());
|
||||
assertEquals(IrisStructureCarveShape.ERODED, restored.terrain().resolvedShape());
|
||||
assertEquals(9, restored.terrain().getHorizontalPadding());
|
||||
assertEquals(0.42D, restored.terrain().getErosionStrength(), 0D);
|
||||
assertNotNull(restored.stilt());
|
||||
assertEquals(91, restored.stilt().getMaxDepth());
|
||||
assertEquals(4, restored.stilt().getSpacing());
|
||||
assertTrue(restored.stilt().isSupportNonOccluding());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordCarriesLocateAndReferenceEnvelopeState() {
|
||||
NativeStructureOwnershipRecord record = record("nova_structures:tavern_oak", -3, 9, 83L);
|
||||
|
||||
assertEquals(47, record.baseY());
|
||||
assertEquals(43, record.contentMinY());
|
||||
assertEquals(78, record.contentMaxY());
|
||||
assertEquals(58, record.locatorY());
|
||||
assertTrue(record.covers(-5, 11));
|
||||
assertFalse(record.covers(-6, 11));
|
||||
assertEquals(83L, record.placementIdentity());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownershipRoundTripPreservesExactBoundsAndPriority() throws Exception {
|
||||
NativeStructureOwnershipRecord ownership = record(
|
||||
"nova_structures:tavern_oak", -3, 9, 83L);
|
||||
NativeStructureOwnershipBundle bundle = NativeStructureOwnershipBundle.empty().with(ownership);
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream();
|
||||
bundle.write(new DataOutputStream(encoded));
|
||||
|
||||
NativeStructureOwnershipRecord restored = NativeStructureOwnershipBundle.read(
|
||||
new DataInputStream(new ByteArrayInputStream(encoded.toByteArray())))
|
||||
.find(ownership.structureKey(), ownership.originChunkX(), ownership.originChunkZ());
|
||||
|
||||
assertNotNull(restored);
|
||||
assertEquals(ownership.placementIdentity(), restored.placementIdentity());
|
||||
assertEquals(ownership.contentMinX(), restored.contentMinX());
|
||||
assertEquals(ownership.contentMinY(), restored.contentMinY());
|
||||
assertEquals(ownership.contentMinZ(), restored.contentMinZ());
|
||||
assertEquals(ownership.contentMaxX(), restored.contentMaxX());
|
||||
assertEquals(ownership.contentMaxY(), restored.contentMaxY());
|
||||
assertEquals(ownership.contentMaxZ(), restored.contentMaxZ());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidOrUnboundedPayloadsFailClosed() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
"nova_structures:tavern_oak",
|
||||
0,
|
||||
0,
|
||||
1L,
|
||||
47,
|
||||
0,
|
||||
43,
|
||||
0,
|
||||
15,
|
||||
78,
|
||||
15,
|
||||
58,
|
||||
-9,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
FINGERPRINT,
|
||||
decision()
|
||||
));
|
||||
|
||||
Map<NativeStructureOwnershipRecord.OwnershipKey, NativeStructureOwnershipRecord> records =
|
||||
new LinkedHashMap<>();
|
||||
for (int i = 0; i <= NativeStructureOwnershipBundle.MAX_RECORDS; i++) {
|
||||
NativeStructureOwnershipRecord record = record("test:structure_" + i, 0, 0, i);
|
||||
records.put(record.ownershipKey(), record);
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new NativeStructureOwnershipBundle(records));
|
||||
|
||||
Map<NativeStructureOwnershipRecord.OwnershipKey, NativeStructureOwnershipRecord> oversized =
|
||||
new LinkedHashMap<>();
|
||||
NativeStructureOwnershipRecord.DecisionSnapshot largeDecision =
|
||||
new NativeStructureOwnershipRecord.DecisionSnapshot(
|
||||
false,
|
||||
"null",
|
||||
"{\"unused\":\"" + "x".repeat(65_000) + "\"}"
|
||||
);
|
||||
for (int i = 0; i < 33; i++) {
|
||||
NativeStructureOwnershipRecord record = new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
"test:large_" + i,
|
||||
0,
|
||||
0,
|
||||
i,
|
||||
47,
|
||||
0,
|
||||
43,
|
||||
0,
|
||||
15,
|
||||
78,
|
||||
15,
|
||||
58,
|
||||
-1,
|
||||
1,
|
||||
-1,
|
||||
1,
|
||||
FINGERPRINT,
|
||||
largeDecision
|
||||
);
|
||||
oversized.put(record.ownershipKey(), record);
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new NativeStructureOwnershipBundle(oversized));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referenceDistanceChecksCannotBeBypassedByIntegerOverflow() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
"test:overflow",
|
||||
Integer.MAX_VALUE,
|
||||
0,
|
||||
1L,
|
||||
47,
|
||||
0,
|
||||
43,
|
||||
0,
|
||||
15,
|
||||
78,
|
||||
15,
|
||||
58,
|
||||
Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE,
|
||||
0,
|
||||
0,
|
||||
FINGERPRINT,
|
||||
decision()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentBoundsMustRemainInsideThePersistedReferenceEnvelope() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
"test:outside_envelope",
|
||||
0,
|
||||
0,
|
||||
1L,
|
||||
47,
|
||||
-16,
|
||||
43,
|
||||
-16,
|
||||
32,
|
||||
78,
|
||||
31,
|
||||
58,
|
||||
-1,
|
||||
1,
|
||||
-1,
|
||||
1,
|
||||
FINGERPRINT,
|
||||
decision()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void displacedReferenceEnvelopeMayExcludeTheStartChunk() {
|
||||
NativeStructureOwnershipRecord record = new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
"test:displaced",
|
||||
0,
|
||||
0,
|
||||
1L,
|
||||
47,
|
||||
80,
|
||||
43,
|
||||
0,
|
||||
95,
|
||||
78,
|
||||
15,
|
||||
58,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
FINGERPRINT,
|
||||
decision()
|
||||
);
|
||||
|
||||
assertFalse(record.covers(0, 0));
|
||||
assertTrue(record.covers(5, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nullableStiltRoundTripsAsNull() {
|
||||
NativeStructureOwnershipRecord record = record("test:no_stilt", 0, 0, 1L);
|
||||
assertNull(record.restoredDecision().stilt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedButSizedBinaryRecordsFailAsIoErrors() throws Exception {
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream();
|
||||
DataOutputStream output = new DataOutputStream(encoded);
|
||||
output.writeInt(NativeStructureOwnershipRecord.CURRENT_SCHEMA);
|
||||
NativeStructureOwnershipRecord.writeString(output, " ", 512, "structure key");
|
||||
output.writeInt(0);
|
||||
output.writeInt(0);
|
||||
output.writeLong(1L);
|
||||
output.writeInt(47);
|
||||
output.writeInt(0);
|
||||
output.writeInt(43);
|
||||
output.writeInt(0);
|
||||
output.writeInt(15);
|
||||
output.writeInt(78);
|
||||
output.writeInt(15);
|
||||
output.writeInt(58);
|
||||
output.writeInt(-1);
|
||||
output.writeInt(1);
|
||||
output.writeInt(-1);
|
||||
output.writeInt(1);
|
||||
NativeStructureOwnershipRecord.writeString(output, FINGERPRINT, 128, "content fingerprint");
|
||||
decision().write(output);
|
||||
|
||||
assertThrows(IOException.class, () -> NativeStructureOwnershipRecord.read(
|
||||
new DataInputStream(new ByteArrayInputStream(encoded.toByteArray()))));
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord record(String key, int originX, int originZ,
|
||||
long placementIdentity) {
|
||||
return new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
key,
|
||||
originX,
|
||||
originZ,
|
||||
placementIdentity,
|
||||
47,
|
||||
originX << 4,
|
||||
43,
|
||||
originZ << 4,
|
||||
(originX << 4) + 31,
|
||||
78,
|
||||
(originZ << 4) + 31,
|
||||
58,
|
||||
originX - 2,
|
||||
originX + 2,
|
||||
originZ - 2,
|
||||
originZ + 2,
|
||||
FINGERPRINT,
|
||||
decision()
|
||||
);
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord.DecisionSnapshot decision() {
|
||||
return NativeStructureOwnershipRecord.DecisionSnapshot.capture(
|
||||
new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
0,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
new IrisStructureTerrain()
|
||||
));
|
||||
}
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeStructureOwnershipStoreTest {
|
||||
private static final String FINGERPRINT = "34".repeat(32);
|
||||
|
||||
@Test
|
||||
public void fullReferenceEnvelopeWritesOnlyItsOriginAuthority() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record("test:origin_only", 4, -7, 91L, false);
|
||||
|
||||
state.record(record);
|
||||
|
||||
assertEquals(1, storage.chunks.size());
|
||||
assertEquals(record, storage.find(
|
||||
NativeStructureOwnershipStore.pack(4, -7), record));
|
||||
assertNull(storage.find(
|
||||
NativeStructureOwnershipStore.pack(4, -6), record));
|
||||
assertEquals(record, state.find(
|
||||
4, -6, record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void originAuthorityIgnoresAStaleTargetReplicaWhenOnlyPolicyChanged() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
NativeStructureOwnershipRecord stale = record("test:replacement", 2, 3, 11L, false);
|
||||
NativeStructureOwnershipRecord current = record("test:replacement", 2, 3, 11L, true);
|
||||
long origin = NativeStructureOwnershipStore.pack(2, 3);
|
||||
long target = NativeStructureOwnershipStore.pack(3, 4);
|
||||
storage.write(origin, current);
|
||||
storage.write(target, stale);
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
|
||||
NativeStructureOwnershipRecord resolved = state.find(
|
||||
3, 4, current.structureKey(), 2, 3);
|
||||
|
||||
assertEquals(current, resolved);
|
||||
assertTrue(resolved.restoredDecision().clearVegetation());
|
||||
assertEquals(stale.contentFingerprint(), resolved.contentFingerprint());
|
||||
assertEquals(stale, storage.find(target, stale));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedOriginAuthorityRemainsVisibleOutsideItsReferenceEnvelope() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:narrow_authority", 8, -3, 19L, false, 1);
|
||||
state.record(record);
|
||||
|
||||
assertNull(state.find(
|
||||
10, -3, record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(record, state.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleTargetReplicaCannotReplaceAMissingOriginAuthority() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
NativeStructureOwnershipRecord stale = record("test:deleted", -2, 5, 17L, false);
|
||||
storage.write(NativeStructureOwnershipStore.pack(-1, 5), stale);
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
|
||||
assertNull(state.find(-1, 5, stale.structureKey(), -2, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void denseOverlappingEnvelopesDoNotAmplifyIntoTheTargetChunk() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
int records = 0;
|
||||
for (int chunkX = -8; chunkX <= 8; chunkX++) {
|
||||
for (int chunkZ = -8; chunkZ <= 8; chunkZ++) {
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:dense_" + chunkX + "_" + chunkZ,
|
||||
chunkX, chunkZ, records, false);
|
||||
state.record(record);
|
||||
assertEquals(record, state.find(
|
||||
0, 0, record.structureKey(), chunkX, chunkZ));
|
||||
records++;
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(289, records);
|
||||
assertEquals(289, storage.chunks.size());
|
||||
NativeStructureOwnershipBundle target = storage.chunks.get(
|
||||
NativeStructureOwnershipStore.pack(0, 0));
|
||||
assertEquals(1, target.records().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flushWaitsForAnOriginWriteAlreadyInFlight() throws Exception {
|
||||
Engine engine = engine();
|
||||
BlockingStorage storage = new BlockingStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record("test:flush_race", -4, 8, 42L, false);
|
||||
storage.blockedTarget = NativeStructureOwnershipStore.pack(-4, 8);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> recording = callers.submit(() -> state.record(record));
|
||||
assertTrue(storage.writeEntered.await(5, TimeUnit.SECONDS));
|
||||
Future<?> flushing = callers.submit(state::flush);
|
||||
|
||||
assertThrows(TimeoutException.class,
|
||||
() -> flushing.get(100, TimeUnit.MILLISECONDS));
|
||||
assertEquals(0, storage.flushes);
|
||||
storage.allowWrite.countDown();
|
||||
recording.get(5, TimeUnit.SECONDS);
|
||||
flushing.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, storage.flushes);
|
||||
} finally {
|
||||
callers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeWaitsForAnOriginWriteAlreadyInFlight() throws Exception {
|
||||
Engine engine = engine();
|
||||
BlockingStorage storage = new BlockingStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record("test:close_race", 6, -9, 73L, false);
|
||||
storage.blockedTarget = NativeStructureOwnershipStore.pack(6, -9);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> recording = callers.submit(() -> state.record(record));
|
||||
assertTrue(storage.writeEntered.await(5, TimeUnit.SECONDS));
|
||||
Future<?> closing = callers.submit(state::close);
|
||||
|
||||
assertThrows(TimeoutException.class,
|
||||
() -> closing.get(100, TimeUnit.MILLISECONDS));
|
||||
storage.allowWrite.countDown();
|
||||
recording.get(5, TimeUnit.SECONDS);
|
||||
closing.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
callers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingEngineAllowsFirstOwnershipWriteUntilExplicitClose() {
|
||||
Engine engine = engine();
|
||||
TestStorage storage = new TestStorage();
|
||||
when(engine.isClosing()).thenReturn(true);
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:closing_session", -7, 12, 74L, false);
|
||||
|
||||
state.record(record);
|
||||
|
||||
assertEquals(record, state.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
state.close();
|
||||
assertThrows(IllegalStateException.class, () -> state.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitCloseCreatesAndRetainsAClosedOwnershipState() {
|
||||
Engine engine = engine();
|
||||
when(engine.isClosing()).thenReturn(true);
|
||||
|
||||
NativeStructureOwnershipStore.close(engine);
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
NativeStructureOwnershipStore.findPersisted(
|
||||
engine, "test:first_closing_use", 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameOriginWritesCannotPublishCacheAuthorityOutOfPersistenceOrder() throws Exception {
|
||||
Engine engine = engine();
|
||||
PostWriteBlockingStorage storage = new PostWriteBlockingStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord first = record("test:same_origin", 3, -6, 1L, false);
|
||||
NativeStructureOwnershipRecord second = record("test:same_origin", 3, -6, 2L, true);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> firstWrite = callers.submit(() -> state.record(first));
|
||||
assertTrue(storage.firstPersisted.await(5, TimeUnit.SECONDS));
|
||||
Future<?> secondWrite = callers.submit(() -> state.record(second));
|
||||
|
||||
assertThrows(TimeoutException.class,
|
||||
() -> secondWrite.get(100, TimeUnit.MILLISECONDS));
|
||||
storage.allowFirstReturn.countDown();
|
||||
firstWrite.get(5, TimeUnit.SECONDS);
|
||||
secondWrite.get(5, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(second, state.find(
|
||||
3, -6, second.structureKey(),
|
||||
second.originChunkX(), second.originChunkZ()));
|
||||
assertEquals(second, storage.find(
|
||||
NativeStructureOwnershipStore.pack(3, -6), second));
|
||||
} finally {
|
||||
callers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulAutosaveFlushSurvivesAbruptStateLoss() {
|
||||
Engine engine = engine();
|
||||
CrashableStorage storage = new CrashableStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:autosave_durable", -11, 14, 101L, true);
|
||||
|
||||
state.record(record);
|
||||
state.flush();
|
||||
storage.crash();
|
||||
|
||||
NativeStructureOwnershipStore.State recovered =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
assertEquals(record, recovered.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(1, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedFlushRemainsDirtyForSuccessfulRetry() {
|
||||
Engine engine = engine();
|
||||
FailingFlushStorage storage = new FailingFlushStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:flush_retry", 12, -15, 102L, false);
|
||||
|
||||
state.record(record);
|
||||
assertThrows(IllegalStateException.class, state::flush);
|
||||
state.flush();
|
||||
storage.crash();
|
||||
|
||||
NativeStructureOwnershipStore.State recovered =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
assertEquals(record, recovered.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(2, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanFlushPerformsNoStorageIo() {
|
||||
Engine engine = engine();
|
||||
CrashableStorage storage = new CrashableStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:clean_flush", 16, 17, 103L, false);
|
||||
|
||||
state.flush();
|
||||
state.record(record);
|
||||
state.flush();
|
||||
state.flush();
|
||||
|
||||
assertEquals(1, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void discardedAuthorityRemainsAbsentAfterAutosaveAndAbruptStateLoss() {
|
||||
Engine engine = engine();
|
||||
CrashableStorage storage = new CrashableStorage();
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:discard_durable", -18, 19, 104L, false);
|
||||
storage.write(NativeStructureOwnershipStore.pack(-18, 19), record);
|
||||
storage.flush();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
|
||||
state.discard(record.structureKey(), record.originChunkX(), record.originChunkZ());
|
||||
state.flush();
|
||||
storage.crash();
|
||||
|
||||
NativeStructureOwnershipStore.State recovered =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
assertNull(recovered.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(2, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulCloseFlushSurvivesAbruptStateLoss() {
|
||||
Engine engine = engine();
|
||||
CrashableStorage storage = new CrashableStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:close_durable", 20, -21, 105L, true);
|
||||
|
||||
state.record(record);
|
||||
state.close();
|
||||
storage.crash();
|
||||
|
||||
NativeStructureOwnershipStore.State recovered =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
assertEquals(record, recovered.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(1, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCloseLeavesTheStoreOpenAndDirtyForRetry() {
|
||||
Engine engine = engine();
|
||||
FailingFlushStorage storage = new FailingFlushStorage();
|
||||
NativeStructureOwnershipStore.State state =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
NativeStructureOwnershipRecord record = record(
|
||||
"test:close_retry", -22, 23, 106L, false);
|
||||
|
||||
state.record(record);
|
||||
assertThrows(IllegalStateException.class, state::close);
|
||||
assertEquals(record, state.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
|
||||
state.close();
|
||||
storage.crash();
|
||||
NativeStructureOwnershipStore.State recovered =
|
||||
new NativeStructureOwnershipStore.State(engine, storage);
|
||||
assertEquals(record, recovered.findPersisted(
|
||||
record.structureKey(), record.originChunkX(), record.originChunkZ()));
|
||||
assertEquals(2, storage.flushes);
|
||||
}
|
||||
|
||||
private static Engine engine() {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.isClosing()).thenReturn(false);
|
||||
when(engine.isClosed()).thenReturn(false);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord record(String key, int originX, int originZ,
|
||||
long placementIdentity,
|
||||
boolean clearVegetation) {
|
||||
return record(key, originX, originZ, placementIdentity, clearVegetation, 8);
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord record(String key, int originX, int originZ,
|
||||
long placementIdentity,
|
||||
boolean clearVegetation,
|
||||
int referenceRadius) {
|
||||
return new NativeStructureOwnershipRecord(
|
||||
NativeStructureOwnershipRecord.CURRENT_SCHEMA,
|
||||
key,
|
||||
originX,
|
||||
originZ,
|
||||
placementIdentity,
|
||||
47,
|
||||
originX << 4,
|
||||
43,
|
||||
originZ << 4,
|
||||
(originX << 4) + 31,
|
||||
78,
|
||||
(originZ << 4) + 31,
|
||||
58,
|
||||
originX - referenceRadius,
|
||||
originX + referenceRadius,
|
||||
originZ - referenceRadius,
|
||||
originZ + referenceRadius,
|
||||
FINGERPRINT,
|
||||
NativeStructureOwnershipRecord.DecisionSnapshot.capture(
|
||||
new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
0,
|
||||
null,
|
||||
false,
|
||||
clearVegetation,
|
||||
null,
|
||||
new IrisStructureTerrain()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private static class TestStorage implements NativeStructureOwnershipStore.Storage {
|
||||
protected final Map<Long, NativeStructureOwnershipBundle> chunks = new ConcurrentHashMap<>();
|
||||
protected volatile int flushes;
|
||||
|
||||
@Override
|
||||
public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) {
|
||||
return chunks.get(NativeStructureOwnershipStore.pack(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(long target, NativeStructureOwnershipRecord record) {
|
||||
chunks.compute(target, (ignored, bundle) ->
|
||||
(bundle == null ? NativeStructureOwnershipBundle.empty() : bundle).with(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(long target, String structureKey, int originChunkX, int originChunkZ) {
|
||||
chunks.computeIfPresent(target, (ignored, bundle) -> {
|
||||
NativeStructureOwnershipBundle updated = bundle.without(
|
||||
structureKey, originChunkX, originChunkZ);
|
||||
return updated.records().isEmpty() ? null : updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
flushes++;
|
||||
}
|
||||
|
||||
NativeStructureOwnershipRecord find(long target,
|
||||
NativeStructureOwnershipRecord record) {
|
||||
NativeStructureOwnershipBundle bundle = chunks.get(target);
|
||||
return bundle == null ? null : bundle.find(record.structureKey(),
|
||||
record.originChunkX(), record.originChunkZ());
|
||||
}
|
||||
}
|
||||
|
||||
private static class CrashableStorage extends TestStorage {
|
||||
protected final Map<Long, NativeStructureOwnershipBundle> durable = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
super.flush();
|
||||
durable.clear();
|
||||
durable.putAll(chunks);
|
||||
}
|
||||
|
||||
void crash() {
|
||||
chunks.clear();
|
||||
chunks.putAll(durable);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingFlushStorage extends CrashableStorage {
|
||||
private final AtomicBoolean fail = new AtomicBoolean(true);
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
flushes++;
|
||||
if (fail.compareAndSet(true, false)) {
|
||||
throw new IllegalStateException("Simulated ownership flush failure");
|
||||
}
|
||||
durable.clear();
|
||||
durable.putAll(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BlockingStorage extends TestStorage {
|
||||
private final CountDownLatch writeEntered = new CountDownLatch(1);
|
||||
private final CountDownLatch allowWrite = new CountDownLatch(1);
|
||||
private final AtomicBoolean blocked = new AtomicBoolean();
|
||||
private volatile long blockedTarget;
|
||||
|
||||
@Override
|
||||
public void write(long target, NativeStructureOwnershipRecord record) {
|
||||
if (target == blockedTarget && blocked.compareAndSet(false, true)) {
|
||||
writeEntered.countDown();
|
||||
try {
|
||||
if (!allowWrite.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Timed out waiting to release origin write");
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted waiting to release origin write", error);
|
||||
}
|
||||
}
|
||||
super.write(target, record);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PostWriteBlockingStorage extends TestStorage {
|
||||
private final CountDownLatch firstPersisted = new CountDownLatch(1);
|
||||
private final CountDownLatch allowFirstReturn = new CountDownLatch(1);
|
||||
private final AtomicBoolean blocked = new AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void write(long target, NativeStructureOwnershipRecord record) {
|
||||
super.write(target, record);
|
||||
if (!blocked.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
firstPersisted.countDown();
|
||||
try {
|
||||
if (!allowFirstReturn.await(5, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Timed out waiting to release first ownership write");
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Ownership write was interrupted", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -2,11 +2,14 @@ package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.iris.engine.object.StructureDistribution;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -38,6 +41,17 @@ public class NativeStructurePlacementPlannerTest {
|
||||
assertTrue(first.baseY() >= -45 && first.baseY() <= -20);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void undergroundBandOutsideWorldBoundsSkipsTheCandidate() {
|
||||
Engine engine = engine(982374L, -64, 384, 90);
|
||||
IrisStructurePlacement placement = nativePlacement()
|
||||
.setUnderground(true)
|
||||
.setMinHeight(500)
|
||||
.setMaxHeight(600);
|
||||
|
||||
assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 12, -7));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfacePlanUsesIrisTerrainAndHonorsHeightGate() {
|
||||
Engine engine = engine(77L, -64, 384, 150);
|
||||
@@ -72,6 +86,69 @@ public class NativeStructurePlacementPlannerTest {
|
||||
assertSame(terrain, decision.terrain());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void underwaterFlagRejectsSubmergedColumnsForSurfaceAndUndergroundPlans() {
|
||||
Engine engine = engine(77L, -64, 384, 63);
|
||||
IrisStructurePlacement placement = nativePlacement();
|
||||
|
||||
assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
|
||||
placement.setUnderground(true).setMinHeight(-40).setMaxHeight(-20);
|
||||
assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
|
||||
placement.setUnderwater(true);
|
||||
assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fluidHeightBoundaryIsShoreForPlannerAndLocator() {
|
||||
Engine engine = engine(77L, -64, 384, 64);
|
||||
IrisStructurePlacement placement = nativePlacement();
|
||||
|
||||
assertTrue(!NativeStructurePlacementPlanner.isSubmerged(engine, 8, 8));
|
||||
assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
|
||||
placement.setUnderground(true).setMinHeight(-40).setMaxHeight(-20);
|
||||
assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateSelectedStructureUsesOneDeterministicPlan() {
|
||||
Engine engine = engine(77L, -64, 384, 150);
|
||||
IrisStructurePlacement first = nativePlacement().setPlacementId("first");
|
||||
IrisStructurePlacement second = nativePlacement().setPlacementId("second");
|
||||
bindPlacements(engine, first, second);
|
||||
|
||||
KList<NativeStructureStartPlan> plans = NativeStructurePlacementPlanner.plansAt(engine, 0, 0);
|
||||
|
||||
IrisStructurePlacement expected = Long.compareUnsigned(
|
||||
StructurePlacementGrid.placementIdentity(first),
|
||||
StructurePlacementGrid.placementIdentity(second)) <= 0 ? first : second;
|
||||
assertEquals(1, plans.size());
|
||||
assertSame(expected, plans.getFirst().placement());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightedNativeSelectionPreservesAuthoredSourceOrder() {
|
||||
KList<IrisNativeStructure> first = new KList<>();
|
||||
first.add(new IrisNativeStructure().setStructure("test:a").setWeight(1));
|
||||
first.add(new IrisNativeStructure().setStructure("test:b").setWeight(3));
|
||||
KList<IrisNativeStructure> reordered = new KList<>();
|
||||
reordered.add(first.get(1));
|
||||
reordered.add(first.get(0));
|
||||
|
||||
RNG selectsFirstWeightSlot = new RNG(0L) {
|
||||
@Override
|
||||
public int nextInt(int bound) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
assertEquals("test:a", NativeStructurePlacementPlanner
|
||||
.selectSource(first, selectsFirstWeightSlot).getStructure());
|
||||
assertEquals("test:b", NativeStructurePlacementPlanner
|
||||
.selectSource(reordered, selectsFirstWeightSlot).getStructure());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void placementMustChooseExactlyOneBackend() {
|
||||
assertInvalid(new IrisStructurePlacement());
|
||||
@@ -98,9 +175,23 @@ public class NativeStructurePlacementPlannerTest {
|
||||
when(engine.getHeight(org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.eq(true)))
|
||||
.thenReturn(terrainHeight);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.getFluidHeight()).thenReturn(64);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private void bindPlacements(Engine engine, IrisStructurePlacement... placements) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
KList<IrisStructurePlacement> configured = new KList<>();
|
||||
for (IrisStructurePlacement placement : placements) {
|
||||
configured.add(placement);
|
||||
}
|
||||
when(dimension.getStructures()).thenReturn(configured);
|
||||
when(dimension.getAllRegions(engine)).thenReturn(new KList<>());
|
||||
when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>());
|
||||
}
|
||||
|
||||
private void assertInvalid(IrisStructurePlacement placement) {
|
||||
try {
|
||||
NativeStructurePlacementPlanner.validateBackend(placement);
|
||||
|
||||
@@ -205,6 +205,23 @@ public class StructurePlacementGridTest {
|
||||
assertTrue(differs);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anonymousIdentityPreservesAuthoredSourceOrder() {
|
||||
IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD);
|
||||
first.getStructures().add("test:second");
|
||||
IrisStructurePlacement reordered = placement(StructureDistribution.RANDOM_SPREAD);
|
||||
reordered.getStructures().clear();
|
||||
reordered.getStructures().add("test:second");
|
||||
reordered.getStructures().add("test:structure");
|
||||
|
||||
assertNotEquals(StructurePlacementGrid.placementIdentity(first),
|
||||
StructurePlacementGrid.placementIdentity(reordered));
|
||||
assertNotEquals(StructurePlacementGrid.placementSalt(first),
|
||||
StructurePlacementGrid.placementSalt(reordered));
|
||||
assertNotEquals(StructurePlacementGrid.placementRng(first, 7, -11, 123L).getSeed(),
|
||||
StructurePlacementGrid.placementRng(reordered, 7, -11, 123L).getSeed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blankIdsDeriveDistinctStableConcentricRingsFromContent() {
|
||||
IrisStructurePlacement first = placement(StructureDistribution.CONCENTRIC_RINGS);
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ import static org.junit.Assert.assertSame;
|
||||
|
||||
public class IrisStructurePlacementCarveSettingsTest {
|
||||
@Test
|
||||
public void defaultsPreserveTerrainWithBoxCarving() {
|
||||
public void defaultsToSourceTerrainWithBoxCarving() {
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain();
|
||||
|
||||
assertEquals(IrisStructureTerrainMode.PRESERVE, terrain.resolvedMode());
|
||||
assertEquals(IrisStructureTerrainMode.SOURCE, terrain.resolvedMode());
|
||||
assertEquals(IrisStructureCarveShape.BOX, terrain.getShape());
|
||||
assertEquals(IrisStructureCarveShape.BOX, terrain.resolvedShape());
|
||||
assertEquals(0.8D, terrain.getErosionStrength(), 0D);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class BukkitPlatformVersionTest {
|
||||
private interface PaperLikeServer extends Server {
|
||||
String getMinecraftVersion();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reportsCanonicalMinecraftVersionInsteadOfBukkitBuildVersion() {
|
||||
PaperLikeServer server = mock(PaperLikeServer.class);
|
||||
doReturn("26.2").when(server).getMinecraftVersion();
|
||||
doReturn("26.2.build.33-alpha").when(server).getBukkitVersion();
|
||||
|
||||
assertEquals("26.2", BukkitPlatform.minecraftVersion(server));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import org.bukkit.loot.LootTables;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class BukkitRegistriesLootTableTest {
|
||||
@Test
|
||||
public void lootTableKeysDoNotRequireLiveServerRegistryAccess() {
|
||||
List<String> expected = new ArrayList<>();
|
||||
for (LootTables table : LootTables.values()) {
|
||||
expected.add(table.getKey().toString());
|
||||
}
|
||||
|
||||
assertEquals(expected, new BukkitRegistries().lootTableKeys());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user