mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
d
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@ plugins {
|
||||
|
||||
def lib = 'art.arcane.iris.util'
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
||||
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||
.get()
|
||||
String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN')
|
||||
boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank()
|
||||
|
||||
@@ -289,6 +289,19 @@ public final class IrisWorldStorage {
|
||||
return requireSafeManagedDimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
public static File requireSafePersistentDimensionRoot(NamespacedKey key) {
|
||||
return requireSafePersistentDimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
static File requireSafePersistentDimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
WorldSlotKey slotKey = new WorldSlotKey(worldKey.getNamespace(), worldKey.getKey());
|
||||
return ExactWorldSlotPathPolicy.resolve(
|
||||
Objects.requireNonNull(levelRoot, "levelRoot").toPath(),
|
||||
slotKey
|
||||
).worldDirectory().toFile();
|
||||
}
|
||||
|
||||
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_-]+")) {
|
||||
@@ -368,4 +381,131 @@ public final class IrisWorldStorage {
|
||||
public static File packRoot(NamespacedKey key) {
|
||||
return new File(dimensionRoot(key), "iris/pack");
|
||||
}
|
||||
|
||||
public static File requireFrozenDimensionRoot(
|
||||
File worldContainer,
|
||||
File levelRoot,
|
||||
String bukkitWorldName,
|
||||
NamespacedKey key
|
||||
) {
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
return frozenDimensionRoot(worldContainer, levelRoot, bukkitWorldName, worldKey)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Frozen Iris world storage is missing for " + worldKey + "."));
|
||||
}
|
||||
|
||||
public static Optional<File> frozenDimensionRoot(
|
||||
File worldContainer,
|
||||
File levelRoot,
|
||||
String bukkitWorldName,
|
||||
NamespacedKey key
|
||||
) {
|
||||
File requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").getAbsoluteFile();
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
String requiredWorldName = Objects.requireNonNull(bukkitWorldName, "bukkitWorldName").trim();
|
||||
if (requiredWorldName.isEmpty()) {
|
||||
throw new IllegalArgumentException("Bukkit world name cannot be empty.");
|
||||
}
|
||||
|
||||
File directRoot = dimensionRoot(requiredLevelRoot, worldKey);
|
||||
boolean directExists = isExistingSafeDimensionRoot(requiredLevelRoot.toPath(), directRoot.toPath());
|
||||
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
|
||||
return directExists ? Optional.of(directRoot) : Optional.empty();
|
||||
}
|
||||
|
||||
String configuredName = configuredWorldName(worldKey, requiredLevelRoot.getName());
|
||||
if (!configuredName.equals(requiredWorldName)) {
|
||||
return directExists ? Optional.of(directRoot) : Optional.empty();
|
||||
}
|
||||
|
||||
File configuredRoot = configuredDimensionRoot(worldContainer, requiredLevelRoot, worldKey);
|
||||
boolean configuredExists = isExistingSafeDimensionRoot(
|
||||
Objects.requireNonNull(worldContainer, "worldContainer").toPath(),
|
||||
configuredRoot.toPath()
|
||||
);
|
||||
if (directExists == configuredExists) {
|
||||
if (directExists) {
|
||||
throw new IllegalStateException("Frozen Iris world storage is ambiguous for " + worldKey + ".");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(directExists ? directRoot : configuredRoot);
|
||||
}
|
||||
|
||||
public static File configuredLevelRoot(File worldContainer, File levelRoot, NamespacedKey key) {
|
||||
Path container = Objects.requireNonNull(worldContainer, "worldContainer")
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
String configuredName = configuredWorldName(
|
||||
Objects.requireNonNull(key, "key"),
|
||||
Objects.requireNonNull(levelRoot, "levelRoot").getName()
|
||||
);
|
||||
Path configuredLevelRoot = container.resolve(configuredName).normalize();
|
||||
if (!Objects.equals(configuredLevelRoot.getParent(), container)) {
|
||||
throw new IllegalStateException("Configured Bukkit world storage escapes the world container.");
|
||||
}
|
||||
isExistingSafeDimensionRoot(container, configuredLevelRoot.resolve("dimensions"));
|
||||
return configuredLevelRoot.toFile();
|
||||
}
|
||||
|
||||
public static File configuredDimensionRoot(File worldContainer, File levelRoot, NamespacedKey key) {
|
||||
File configuredLevelRoot = configuredLevelRoot(worldContainer, levelRoot, key);
|
||||
File configuredRoot = dimensionRoot(configuredLevelRoot, key);
|
||||
isExistingSafeDimensionRoot(
|
||||
Objects.requireNonNull(worldContainer, "worldContainer").toPath(),
|
||||
configuredRoot.toPath()
|
||||
);
|
||||
return configuredRoot;
|
||||
}
|
||||
|
||||
public static File requireFrozenPackRoot(File dimensionRoot) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path irisRoot = root.resolve("iris");
|
||||
Path packRoot = irisRoot.resolve("pack");
|
||||
for (Path path : new Path[]{root, irisRoot, packRoot}) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IllegalStateException("Frozen Iris pack path contains a symbolic link: " + path);
|
||||
}
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Frozen Iris pack path is not a directory: " + path);
|
||||
}
|
||||
}
|
||||
if (!Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Frozen Iris pack snapshot is missing: " + packRoot);
|
||||
}
|
||||
return packRoot.toFile();
|
||||
}
|
||||
|
||||
private static boolean isExistingSafeDimensionRoot(Path storageRoot, Path dimensionRoot) {
|
||||
Path root = storageRoot.toAbsolutePath().normalize();
|
||||
Path target = dimensionRoot.toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IllegalStateException("Iris world storage root is a symbolic link: " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Iris world storage root is not a directory: " + root);
|
||||
}
|
||||
if (!target.startsWith(root) || Objects.equals(target, root)) {
|
||||
throw new IllegalStateException("Iris world storage escapes its expected root: " + target);
|
||||
}
|
||||
|
||||
Path relative = root.relativize(target);
|
||||
Path current = root;
|
||||
for (Path segment : relative) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IllegalStateException("Iris world storage contains a symbolic link: " + current);
|
||||
}
|
||||
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Iris world storage path is not a directory: " + current);
|
||||
}
|
||||
}
|
||||
return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class IrisWorlds {
|
||||
@@ -147,8 +147,9 @@ public class IrisWorlds {
|
||||
public synchronized void clean() {
|
||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||
try {
|
||||
File packRoot = packRoot(entry.getKey());
|
||||
return !new File(packRoot, "dimensions/" + entry.getValue() + ".json").exists();
|
||||
Optional<File> packRoot = packRoot(entry.getKey());
|
||||
return packRoot.isEmpty()
|
||||
|| !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return true;
|
||||
}
|
||||
@@ -236,12 +237,20 @@ public class IrisWorlds {
|
||||
.equals(configuredWorldName)) {
|
||||
continue;
|
||||
}
|
||||
Path dimensionRoot = IrisWorldStorage.dimensionRoot(root.toFile(), worldKey)
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
if (Files.isDirectory(dimensionRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
result.put(configuredWorldName, entry.getValue());
|
||||
Path worldContainer = root.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalArgumentException("Selected level root has no world container: " + root);
|
||||
}
|
||||
try {
|
||||
if (IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
root.toFile(),
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
).isPresent()) {
|
||||
result.put(configuredWorldName, entry.getValue());
|
||||
}
|
||||
} catch (IllegalStateException ignored) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -270,14 +279,28 @@ public class IrisWorlds {
|
||||
return filterBukkitWorldsByStorage(levelRoot, result);
|
||||
}
|
||||
|
||||
private File packRoot(String worldIdentity) {
|
||||
private Optional<File> packRoot(String worldIdentity) {
|
||||
NamespacedKey worldKey = WorldIdentity.parse(worldIdentity);
|
||||
return new File(IrisWorldStorage.dimensionRoot(levelRoot.toFile(), worldKey), "iris/pack");
|
||||
Path worldContainer = levelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalStateException("Selected level root has no world container: " + levelRoot);
|
||||
}
|
||||
String configuredWorldName = IrisWorldStorage.configuredWorldName(
|
||||
worldKey,
|
||||
levelRoot.getFileName().toString()
|
||||
);
|
||||
Optional<File> dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
);
|
||||
return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot);
|
||||
}
|
||||
|
||||
private IrisDimension loadDimension(String worldIdentity, String id) {
|
||||
File pack = packRoot(worldIdentity);
|
||||
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
|
||||
File pack = packRoot(worldIdentity).orElse(null);
|
||||
IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id);
|
||||
if (dimension == null) {
|
||||
dimension = IrisData.loadAnyDimension(id, null);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.WorldCreator;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* WorldCreator.ofKey and WorldCreator#key are Paper-API-only. Once a call throws
|
||||
* NoSuchMethodError (plain Spigot/CraftBukkit) this flips and every later call goes
|
||||
* straight to the fallback. The fallback derives names/keys through IrisWorldStorage's
|
||||
* logical mapping so keyFromName(creator.name()) round-trips on Spigot.
|
||||
* current configured-name mapping so persistent Spigot worlds round-trip without changing
|
||||
* their startup directory.
|
||||
*/
|
||||
public final class WorldCreatorCompat {
|
||||
private static volatile boolean keyedCreatorsUnavailable;
|
||||
@@ -16,14 +20,45 @@ public final class WorldCreatorCompat {
|
||||
}
|
||||
|
||||
public static WorldCreator ofKey(NamespacedKey worldKey) {
|
||||
if (!keyedCreatorsUnavailable) {
|
||||
try {
|
||||
return WorldCreator.ofKey(worldKey);
|
||||
} catch (NoSuchMethodError e) {
|
||||
keyedCreatorsUnavailable = true;
|
||||
}
|
||||
return ofKey(worldKey, IrisWorldStorage.logicalName(worldKey));
|
||||
}
|
||||
|
||||
public static WorldCreator ofKey(NamespacedKey worldKey, String fallbackWorldName) {
|
||||
WorldCreator keyedCreator = keyedCreator(worldKey);
|
||||
if (keyedCreator != null) {
|
||||
return keyedCreator;
|
||||
}
|
||||
return new WorldCreator(IrisWorldStorage.logicalName(worldKey));
|
||||
return new WorldCreator(fallbackWorldName);
|
||||
}
|
||||
|
||||
public static WorldCreator ofPersistentKey(NamespacedKey worldKey) {
|
||||
WorldCreator keyedCreator = keyedCreator(worldKey);
|
||||
if (keyedCreator != null) {
|
||||
return keyedCreator;
|
||||
}
|
||||
return new WorldCreator(fallbackPersistentName(worldKey, IrisWorldStorage.levelRoot().getName()));
|
||||
}
|
||||
|
||||
public static File persistentDimensionRoot(NamespacedKey worldKey) {
|
||||
if (keyedCreator(worldKey) != null) {
|
||||
return IrisWorldStorage.requireSafePersistentDimensionRoot(worldKey);
|
||||
}
|
||||
return IrisWorldStorage.configuredDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
IrisWorldStorage.levelRoot(),
|
||||
worldKey
|
||||
);
|
||||
}
|
||||
|
||||
public static File persistentLevelRoot(NamespacedKey worldKey) {
|
||||
if (keyedCreator(worldKey) != null) {
|
||||
return persistentDimensionRoot(worldKey);
|
||||
}
|
||||
return IrisWorldStorage.configuredLevelRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
IrisWorldStorage.levelRoot(),
|
||||
worldKey
|
||||
);
|
||||
}
|
||||
|
||||
public static NamespacedKey keyOf(WorldCreator creator) {
|
||||
@@ -34,14 +69,33 @@ public final class WorldCreatorCompat {
|
||||
keyedCreatorsUnavailable = true;
|
||||
}
|
||||
}
|
||||
return IrisWorldStorage.keyFromName(creator.name());
|
||||
return IrisWorldStorage.keyFromConfiguredWorldName(
|
||||
creator.name(),
|
||||
IrisWorldStorage.levelRoot().getName()
|
||||
);
|
||||
}
|
||||
|
||||
static String fallbackName(NamespacedKey worldKey, String levelName) {
|
||||
return IrisWorldStorage.logicalName(worldKey, levelName);
|
||||
}
|
||||
|
||||
static String fallbackPersistentName(NamespacedKey worldKey, String levelName) {
|
||||
return IrisWorldStorage.configuredWorldName(worldKey, levelName);
|
||||
}
|
||||
|
||||
static NamespacedKey fallbackKey(String creatorName, String levelName) {
|
||||
return IrisWorldStorage.keyFromName(creatorName, levelName);
|
||||
return IrisWorldStorage.keyFromConfiguredWorldName(creatorName, levelName);
|
||||
}
|
||||
|
||||
private static WorldCreator keyedCreator(NamespacedKey worldKey) {
|
||||
if (keyedCreatorsUnavailable) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return WorldCreator.ofKey(worldKey);
|
||||
} catch (NoSuchMethodError e) {
|
||||
keyedCreatorsUnavailable = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,22 +50,21 @@ public final class WorldRemovalPathPolicy {
|
||||
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||
}
|
||||
Path target;
|
||||
StorageLayout storageLayout;
|
||||
try {
|
||||
target = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
storageLayout = resolveStorageLayout(normalizedLevelRoot, worldKey);
|
||||
} catch (RuntimeException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
validateStoragePath(normalizedLevelRoot, worldKey, target);
|
||||
validateStoragePath(normalizedLevelRoot, worldKey, storageLayout.dimensionDirectory());
|
||||
validateStorageRoot(normalizedLevelRoot, worldKey, storageLayout.storageDirectory());
|
||||
return new Target(
|
||||
requestedIdentifier,
|
||||
worldKey,
|
||||
IrisWorldStorage.logicalName(worldKey, mainWorld),
|
||||
normalizedLevelRoot,
|
||||
target
|
||||
storageLayout.dimensionDirectory(),
|
||||
storageLayout.storageDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,11 +76,11 @@ public final class WorldRemovalPathPolicy {
|
||||
}
|
||||
Path expected;
|
||||
try {
|
||||
expected = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
expected = resolveStorageLayout(
|
||||
normalizedLevelRoot,
|
||||
Objects.requireNonNull(worldKey, "worldKey")
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
).dimensionDirectory();
|
||||
} catch (RuntimeException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize();
|
||||
@@ -91,6 +90,28 @@ public final class WorldRemovalPathPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateStorageRoot(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 = resolveStorageLayout(
|
||||
normalizedLevelRoot,
|
||||
Objects.requireNonNull(worldKey, "worldKey")
|
||||
).storageDirectory();
|
||||
} catch (RuntimeException 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 storage directory is outside its exact current platform root.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) {
|
||||
NamespacedKey parsed = identifier.contains(":")
|
||||
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
|
||||
@@ -104,12 +125,14 @@ public final class WorldRemovalPathPolicy {
|
||||
private static Rejection classifyStorageFailure(
|
||||
Path levelRoot,
|
||||
NamespacedKey worldKey,
|
||||
IllegalArgumentException failure
|
||||
RuntimeException failure
|
||||
) {
|
||||
Path dimensions = levelRoot.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(worldKey.getNamespace());
|
||||
Path target = namespace.resolve(worldKey.getKey());
|
||||
RejectionReason reason = Files.isSymbolicLink(dimensions)
|
||||
String failureMessage = String.valueOf(failure.getMessage()).toLowerCase(Locale.ENGLISH);
|
||||
RejectionReason reason = failureMessage.contains("symbolic link")
|
||||
|| Files.isSymbolicLink(dimensions)
|
||||
|| Files.isSymbolicLink(namespace)
|
||||
|| Files.isSymbolicLink(target)
|
||||
? RejectionReason.SYMBOLIC_LINK
|
||||
@@ -117,6 +140,45 @@ public final class WorldRemovalPathPolicy {
|
||||
return new Rejection(reason, failure.getMessage(), failure);
|
||||
}
|
||||
|
||||
private static StorageLayout resolveStorageLayout(Path levelRoot, NamespacedKey worldKey) {
|
||||
Path worldContainer = levelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalArgumentException("Selected level root has no world container: " + levelRoot);
|
||||
}
|
||||
String configuredWorldName = IrisWorldStorage.configuredWorldName(
|
||||
worldKey,
|
||||
levelRoot.getFileName().toString()
|
||||
);
|
||||
Path directDimension = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
levelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
Path dimensionDirectory = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
).map(file -> file.toPath().toAbsolutePath().normalize()).orElse(directDimension);
|
||||
if (dimensionDirectory.equals(directDimension)) {
|
||||
return new StorageLayout(directDimension, directDimension);
|
||||
}
|
||||
|
||||
Path configuredDimension = IrisWorldStorage.configuredDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
if (!dimensionDirectory.equals(configuredDimension)) {
|
||||
throw new IllegalStateException("Iris world storage does not match the current platform layout.");
|
||||
}
|
||||
Path configuredLevel = IrisWorldStorage.configuredLevelRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
return new StorageLayout(configuredDimension, configuredLevel);
|
||||
}
|
||||
|
||||
private static String requireIdentifier(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
|
||||
@@ -129,7 +191,8 @@ public final class WorldRemovalPathPolicy {
|
||||
NamespacedKey worldKey,
|
||||
String logicalName,
|
||||
Path levelRoot,
|
||||
Path worldDirectory
|
||||
Path worldDirectory,
|
||||
Path storageDirectory
|
||||
) {
|
||||
public Target {
|
||||
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
|
||||
@@ -137,9 +200,13 @@ public final class WorldRemovalPathPolicy {
|
||||
Objects.requireNonNull(logicalName, "logicalName");
|
||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
Objects.requireNonNull(worldDirectory, "worldDirectory");
|
||||
Objects.requireNonNull(storageDirectory, "storageDirectory");
|
||||
}
|
||||
}
|
||||
|
||||
private record StorageLayout(Path dimensionDirectory, Path storageDirectory) {
|
||||
}
|
||||
|
||||
public enum RejectionReason {
|
||||
INVALID_IDENTIFIER,
|
||||
CONFIGURED_MAIN_WORLD,
|
||||
|
||||
@@ -165,10 +165,22 @@ public final class BukkitWorldConfiguration {
|
||||
))) {
|
||||
continue;
|
||||
}
|
||||
if (!IrisWorldStorage.isExistingManagedDimensionRoot(
|
||||
requiredLevelRoot.toFile(),
|
||||
namespacedKey
|
||||
)) {
|
||||
Path worldContainer = requiredLevelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||
}
|
||||
boolean storagePresent;
|
||||
try {
|
||||
storagePresent = IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
requiredLevelRoot.toFile(),
|
||||
configuredName,
|
||||
namespacedKey
|
||||
).isPresent();
|
||||
} catch (IllegalStateException failure) {
|
||||
storagePresent = false;
|
||||
}
|
||||
if (!storagePresent) {
|
||||
continue;
|
||||
}
|
||||
String dimension = selectedIrisDimension(configuredGenerator, configuredName);
|
||||
|
||||
@@ -852,7 +852,9 @@ public final class IrisWorldRemovalService {
|
||||
) {
|
||||
return onGlobal(() -> {
|
||||
requireNotTerminal(terminal, "Multiverse unregistration");
|
||||
return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(target.logicalName());
|
||||
return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(
|
||||
bukkitConfigurationWorldName(target)
|
||||
);
|
||||
}).thenCompose(multiverseChanged -> {
|
||||
if (terminal.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
@@ -892,7 +894,10 @@ public final class IrisWorldRemovalService {
|
||||
WorldRemovalPathPolicy.Target target,
|
||||
BooleanSupplier terminal
|
||||
) {
|
||||
Path quarantine = target.worldDirectory().resolveSibling(".iris-delete-" + UUID.randomUUID());
|
||||
Path quarantine = target.levelRoot()
|
||||
.resolve("dimensions/iris/.iris-delete-" + UUID.randomUUID())
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
requireNotTerminal(terminal, "deletion intent");
|
||||
@@ -952,7 +957,12 @@ public final class IrisWorldRemovalService {
|
||||
target.worldKey(),
|
||||
target.worldDirectory()
|
||||
);
|
||||
boolean directoryPresent = Files.isDirectory(target.worldDirectory(), LinkOption.NOFOLLOW_LINKS);
|
||||
WorldRemovalPathPolicy.validateStorageRoot(
|
||||
target.levelRoot(),
|
||||
target.worldKey(),
|
||||
target.storageDirectory()
|
||||
);
|
||||
boolean directoryPresent = Files.isDirectory(target.storageDirectory(), LinkOption.NOFOLLOW_LINKS);
|
||||
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||
String generator = bukkitGenerator(configuration, target);
|
||||
boolean configurationManaged = generator != null
|
||||
@@ -1020,17 +1030,24 @@ public final class IrisWorldRemovalService {
|
||||
target.worldKey(),
|
||||
target.worldDirectory()
|
||||
);
|
||||
Path worldDirectory = target.worldDirectory();
|
||||
if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
WorldRemovalPathPolicy.validateStorageRoot(
|
||||
target.levelRoot(),
|
||||
target.worldKey(),
|
||||
target.storageDirectory()
|
||||
);
|
||||
Path storageDirectory = target.storageDirectory();
|
||||
if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null;
|
||||
}
|
||||
if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new RemovalFailure(
|
||||
RemovalStatus.QUARANTINE_FAILED,
|
||||
new IOException("Iris world target is not a directory: " + worldDirectory)
|
||||
new IOException("Iris world target is not a directory: " + storageDirectory)
|
||||
);
|
||||
}
|
||||
|
||||
requireSafeQuarantineParent(target, quarantine);
|
||||
|
||||
WorldDeletionQueue deletionQueue = IrisServices.getOrNull(WorldDeletionQueue.class);
|
||||
if (deletionQueue == null) {
|
||||
throw new RemovalFailure(
|
||||
@@ -1059,21 +1076,26 @@ public final class IrisWorldRemovalService {
|
||||
target.worldKey(),
|
||||
target.worldDirectory()
|
||||
);
|
||||
Path worldDirectory = target.worldDirectory();
|
||||
if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
WorldRemovalPathPolicy.validateStorageRoot(
|
||||
target.levelRoot(),
|
||||
target.worldKey(),
|
||||
target.storageDirectory()
|
||||
);
|
||||
Path storageDirectory = target.storageDirectory();
|
||||
if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null;
|
||||
}
|
||||
if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new RemovalFailure(
|
||||
RemovalStatus.QUARANTINE_FAILED,
|
||||
new IOException("Iris world target is not a directory: " + worldDirectory)
|
||||
new IOException("Iris world target is not a directory: " + storageDirectory)
|
||||
);
|
||||
}
|
||||
try {
|
||||
try {
|
||||
Files.move(worldDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE);
|
||||
Files.move(storageDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(worldDirectory, quarantine);
|
||||
Files.move(storageDirectory, quarantine);
|
||||
}
|
||||
return quarantine;
|
||||
} catch (IOException failure) {
|
||||
@@ -1081,6 +1103,31 @@ public final class IrisWorldRemovalService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSafeQuarantineParent(
|
||||
WorldRemovalPathPolicy.Target target,
|
||||
Path quarantine
|
||||
) {
|
||||
Path expectedParent = target.levelRoot().resolve("dimensions/iris").toAbsolutePath().normalize();
|
||||
if (!Objects.equals(quarantine.getParent(), expectedParent)) {
|
||||
throw new RemovalFailure(
|
||||
RemovalStatus.QUARANTINE_FAILED,
|
||||
new IOException("Iris quarantine path is outside its exact namespace root: " + quarantine)
|
||||
);
|
||||
}
|
||||
try {
|
||||
Path current = target.levelRoot().toAbsolutePath().normalize();
|
||||
for (Path segment : target.levelRoot().relativize(expectedParent)) {
|
||||
current = current.resolve(segment);
|
||||
if (Files.isSymbolicLink(current)) {
|
||||
throw new IOException("Iris quarantine storage contains a symbolic link: " + current);
|
||||
}
|
||||
}
|
||||
Files.createDirectories(expectedParent);
|
||||
} catch (IOException failure) {
|
||||
throw new RemovalFailure(RemovalStatus.QUARANTINE_FAILED, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private DeleteDisposition deleteQuarantine(Path quarantine) {
|
||||
try {
|
||||
SnapshotDirectoryTreeDeleter.delete(quarantine);
|
||||
|
||||
@@ -40,7 +40,7 @@ public record WorldLifecycleRequest(
|
||||
}
|
||||
|
||||
public WorldCreator toWorldCreator() {
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey, worldName)
|
||||
.environment(environment)
|
||||
.generateStructures(generateStructures)
|
||||
.hardcore(hardcore)
|
||||
|
||||
@@ -44,6 +44,11 @@ public final class WorldLifecycleStaging {
|
||||
return stagedStemGenerators.remove(worldName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static ChunkGenerator peekStemGenerator(@NotNull String worldName) {
|
||||
return stagedStemGenerators.get(worldName);
|
||||
}
|
||||
|
||||
public static void clearGenerator(@NotNull String worldName) {
|
||||
stagedGenerators.remove(worldName);
|
||||
stagedBiomeProviders.remove(worldName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||
@@ -8,6 +9,8 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||
|
||||
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.List;
|
||||
import java.util.Objects;
|
||||
@@ -138,6 +141,7 @@ public final class WorldReplacementBootstrap {
|
||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
try {
|
||||
refreshOverworldEntryGuard(levelRoot, active, paths);
|
||||
WorldReplacementFilesystem.publish(
|
||||
paths,
|
||||
active.originalTargetPresent(),
|
||||
@@ -166,6 +170,7 @@ public final class WorldReplacementBootstrap {
|
||||
}
|
||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||
}
|
||||
refreshOverworldEntryGuard(levelRoot, active, paths);
|
||||
WorldReplacementFilesystem.publish(
|
||||
paths,
|
||||
active.originalTargetPresent(),
|
||||
@@ -176,6 +181,20 @@ public final class WorldReplacementBootstrap {
|
||||
throw conflict(active, "Replacement journal reached an unsupported bootstrap phase.");
|
||||
}
|
||||
|
||||
private static void refreshOverworldEntryGuard(
|
||||
Path levelRoot,
|
||||
Transaction transaction,
|
||||
ReplacementPaths paths
|
||||
) throws IOException {
|
||||
if (!WorldSlotKey.minecraft("overworld").equals(transaction.worldKey())) {
|
||||
return;
|
||||
}
|
||||
Path replacementWorld = Files.isDirectory(paths.stage(), LinkOption.NOFOLLOW_LINKS)
|
||||
? paths.stage()
|
||||
: paths.target();
|
||||
WorldReplacementEntryGuard.refreshPlayers(levelRoot, replacementWorld, transaction.id());
|
||||
}
|
||||
|
||||
private static void rollback(
|
||||
Path dataDirectory,
|
||||
File bukkitConfiguration,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class WorldReplacementEntryGuard {
|
||||
public static final String MARKER_NAME = "replacement-entry.properties";
|
||||
|
||||
private static final Pattern PLAYER_DATA_NAME = Pattern.compile(
|
||||
"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\\.dat$");
|
||||
|
||||
private WorldReplacementEntryGuard() {
|
||||
}
|
||||
|
||||
public static Entry stage(Path levelRoot, Path stagedWorld, UUID transactionId) throws IOException {
|
||||
Path requiredLevelRoot = normalize(levelRoot, "levelRoot");
|
||||
Path requiredStagedWorld = normalize(stagedWorld, "stagedWorld");
|
||||
Entry entry = new Entry(
|
||||
Objects.requireNonNull(transactionId, "transactionId"),
|
||||
discoverPlayers(requiredLevelRoot)
|
||||
);
|
||||
write(requiredStagedWorld, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public static Entry refreshPlayers(Path levelRoot, Path worldDirectory, UUID transactionId) throws IOException {
|
||||
Path requiredLevelRoot = normalize(levelRoot, "levelRoot");
|
||||
Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory");
|
||||
UUID requiredTransactionId = Objects.requireNonNull(transactionId, "transactionId");
|
||||
Optional<Entry> loaded = load(requiredWorldDirectory);
|
||||
if (loaded.isEmpty()) {
|
||||
throw new IOException("The staged Overworld replacement is missing its entry marker.");
|
||||
}
|
||||
Entry current = loaded.get();
|
||||
if (!current.transactionId().equals(requiredTransactionId)) {
|
||||
throw new IOException("Replacement entry marker belongs to another transaction.");
|
||||
}
|
||||
HashSet<UUID> pendingPlayers = new HashSet<>(current.pendingPlayers());
|
||||
pendingPlayers.addAll(discoverPlayers(requiredLevelRoot));
|
||||
Entry refreshed = new Entry(requiredTransactionId, pendingPlayers);
|
||||
write(requiredWorldDirectory, refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
public static Optional<Entry> load(Path worldDirectory) throws IOException {
|
||||
Path marker = marker(worldDirectory);
|
||||
if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (Files.isSymbolicLink(marker) || !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement entry marker is unsafe: " + marker);
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(marker)) {
|
||||
properties.load(input);
|
||||
}
|
||||
UUID transactionId;
|
||||
try {
|
||||
transactionId = UUID.fromString(required(properties, "transaction"));
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement entry marker contains an invalid transaction id.", failure);
|
||||
}
|
||||
HashSet<UUID> pendingPlayers = new HashSet<>();
|
||||
String encodedPlayers = properties.getProperty("pendingPlayers", "").trim();
|
||||
if (!encodedPlayers.isEmpty()) {
|
||||
for (String encodedPlayer : encodedPlayers.split(",", -1)) {
|
||||
try {
|
||||
if (!pendingPlayers.add(UUID.fromString(encodedPlayer))) {
|
||||
throw new IOException("Replacement entry marker contains a duplicate player id.");
|
||||
}
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement entry marker contains an invalid player id.", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.of(new Entry(transactionId, pendingPlayers));
|
||||
}
|
||||
|
||||
public static Optional<Entry> completePlayer(
|
||||
Path worldDirectory,
|
||||
UUID transactionId,
|
||||
UUID playerId
|
||||
) throws IOException {
|
||||
Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory");
|
||||
UUID requiredTransactionId = Objects.requireNonNull(transactionId, "transactionId");
|
||||
UUID requiredPlayerId = Objects.requireNonNull(playerId, "playerId");
|
||||
Optional<Entry> loaded = load(requiredWorldDirectory);
|
||||
if (loaded.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Entry current = loaded.get();
|
||||
if (!current.transactionId().equals(requiredTransactionId)) {
|
||||
throw new IOException("Replacement entry marker belongs to another transaction.");
|
||||
}
|
||||
HashSet<UUID> remaining = new HashSet<>(current.pendingPlayers());
|
||||
remaining.remove(requiredPlayerId);
|
||||
Entry updated = new Entry(requiredTransactionId, remaining);
|
||||
write(requiredWorldDirectory, updated);
|
||||
return Optional.of(updated);
|
||||
}
|
||||
|
||||
public static boolean retireIfEmpty(Path worldDirectory, UUID transactionId) throws IOException {
|
||||
Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory");
|
||||
Optional<Entry> loaded = load(requiredWorldDirectory);
|
||||
if (loaded.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
Entry current = loaded.get();
|
||||
if (!current.transactionId().equals(Objects.requireNonNull(transactionId, "transactionId"))) {
|
||||
throw new IOException("Replacement entry marker belongs to another transaction.");
|
||||
}
|
||||
if (!current.pendingPlayers().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Path marker = marker(requiredWorldDirectory);
|
||||
Files.delete(marker);
|
||||
DirectoryDurability.forceDirectoryAfterCommit(marker.getParent(), "A replacement entry marker retirement");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Set<UUID> discoverPlayers(Path levelRoot) throws IOException {
|
||||
Path playerData = levelRoot.resolve("players/data");
|
||||
if (!Files.exists(playerData, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return Set.of();
|
||||
}
|
||||
if (Files.isSymbolicLink(playerData) || !Files.isDirectory(playerData, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Player data storage is unsafe: " + playerData);
|
||||
}
|
||||
HashSet<UUID> players = new HashSet<>();
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(playerData)) {
|
||||
for (Path file : files) {
|
||||
Matcher matcher = PLAYER_DATA_NAME.matcher(file.getFileName().toString());
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Player data entry is unsafe: " + file);
|
||||
}
|
||||
players.add(UUID.fromString(matcher.group(1)));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(players);
|
||||
}
|
||||
|
||||
private static void write(Path worldDirectory, Entry entry) throws IOException {
|
||||
Path marker = marker(worldDirectory);
|
||||
Path parent = marker.getParent();
|
||||
if (Files.isSymbolicLink(parent)) {
|
||||
throw new IOException("Replacement entry storage is unsafe: " + parent);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
if (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement entry storage is not a directory: " + parent);
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("transaction", entry.transactionId().toString());
|
||||
ArrayList<UUID> orderedPlayers = new ArrayList<>(entry.pendingPlayers());
|
||||
orderedPlayers.sort(Comparator.comparing(UUID::toString));
|
||||
properties.setProperty(
|
||||
"pendingPlayers",
|
||||
String.join(",", orderedPlayers.stream().map(UUID::toString).toList())
|
||||
);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
properties.store(output, null);
|
||||
Path staged = Files.createTempFile(parent, ".replacement-entry-", ".tmp");
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
staged,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(output.toByteArray());
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, marker, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException failure) {
|
||||
throw new IOException("Replacement entry marker requires atomic publication.", failure);
|
||||
}
|
||||
DirectoryDurability.forceDirectoryAfterCommit(parent, "A replacement entry marker change");
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path marker(Path worldDirectory) {
|
||||
return normalize(worldDirectory, "worldDirectory").resolve("iris").resolve(MARKER_NAME);
|
||||
}
|
||||
|
||||
private static Path normalize(Path path, String name) {
|
||||
return Objects.requireNonNull(path, name).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String key) throws IOException {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Replacement entry marker is missing " + key + ".");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public record Entry(UUID transactionId, Set<UUID> pendingPlayers) {
|
||||
public Entry {
|
||||
Objects.requireNonNull(transactionId, "transactionId");
|
||||
pendingPlayers = Set.copyOf(Objects.requireNonNull(pendingPlayers, "pendingPlayers"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,9 +418,7 @@ public class IrisPregenerator {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
|
||||
if (MantleHeapPressure.overPanicWater()) {
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
}
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
}
|
||||
|
||||
private void checkRegion(int x, int z) {
|
||||
|
||||
@@ -18,18 +18,37 @@
|
||||
|
||||
package art.arcane.iris.core.pregenerator;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import javax.management.MBeanServer;
|
||||
import javax.management.ObjectName;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.DoubleConsumer;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
public final class MantleHeapPressure {
|
||||
private static final double HIGH_WATER = 0.92D;
|
||||
private static final double LOW_WATER = 0.82D;
|
||||
private static final double PANIC_WATER = 0.96D;
|
||||
private static final long PANIC_GC_INTERVAL_MS = 30_000L;
|
||||
private static final AtomicBoolean engaged = new AtomicBoolean(false);
|
||||
private static final AtomicLong lastPanicGcAt = new AtomicLong(0L);
|
||||
private static final long MAXIMUM_HYSTERESIS_MS = 60_000L;
|
||||
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
|
||||
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer(
|
||||
new PanicGcPolicy(10_000L, 60_000L, 60_000L, 15 * 60_000L),
|
||||
new PanicGcActions(
|
||||
System::currentTimeMillis,
|
||||
System::gc,
|
||||
() -> invokeHotSpotDiagnosticGc(ManagementFactory.getPlatformMBeanServer()),
|
||||
(double fraction) -> IrisLogging.warn(
|
||||
"Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.",
|
||||
fraction * 100.0D),
|
||||
(String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
|
||||
private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate(
|
||||
HIGH_WATER,
|
||||
LOW_WATER,
|
||||
MAXIMUM_HYSTERESIS_MS,
|
||||
System::currentTimeMillis,
|
||||
PANIC_RECLAIMER::resetEpisode);
|
||||
|
||||
private MantleHeapPressure() {
|
||||
}
|
||||
@@ -50,19 +69,7 @@ public final class MantleHeapPressure {
|
||||
}
|
||||
|
||||
public static boolean overHighWater() {
|
||||
double fraction = usedFraction();
|
||||
if (engaged.get()) {
|
||||
if (fraction <= LOW_WATER) {
|
||||
engaged.set(false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (fraction >= HIGH_WATER) {
|
||||
engaged.set(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return PRESSURE_GATE.update(usedFraction());
|
||||
}
|
||||
|
||||
public static double reclaimUrgency(double fraction) {
|
||||
@@ -75,18 +82,203 @@ public final class MantleHeapPressure {
|
||||
return (fraction - LOW_WATER) / (HIGH_WATER - LOW_WATER);
|
||||
}
|
||||
|
||||
public static boolean overPanicWater() {
|
||||
return usedFraction() >= PANIC_WATER;
|
||||
public static void requestPanicReclaim() {
|
||||
PANIC_RECLAIMER.request(usedFraction());
|
||||
}
|
||||
|
||||
public static void requestPanicReclaim() {
|
||||
long now = System.currentTimeMillis();
|
||||
long last = lastPanicGcAt.get();
|
||||
if (now - last < PANIC_GC_INTERVAL_MS) {
|
||||
return;
|
||||
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
|
||||
if (!server.isRegistered(DIAGNOSTIC_COMMAND)) {
|
||||
throw new UnsupportedOperationException("HotSpot DiagnosticCommand MBean is not registered on this JVM");
|
||||
}
|
||||
if (lastPanicGcAt.compareAndSet(last, now)) {
|
||||
System.gc();
|
||||
server.invoke(
|
||||
DIAGNOSTIC_COMMAND,
|
||||
"gcRun",
|
||||
new Object[0],
|
||||
new String[0]);
|
||||
}
|
||||
|
||||
private static ObjectName diagnosticCommandName() {
|
||||
try {
|
||||
return new ObjectName("com.sun.management:type=DiagnosticCommand");
|
||||
} catch (Exception failure) {
|
||||
throw new ExceptionInInitializerError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
record PanicGcPolicy(
|
||||
long diagnosticDelayMs,
|
||||
long diagnosticCooldownMs,
|
||||
long initialFailureBackoffMs,
|
||||
long maximumFailureBackoffMs
|
||||
) {
|
||||
PanicGcPolicy {
|
||||
if (diagnosticDelayMs < 0L
|
||||
|| diagnosticCooldownMs < 0L
|
||||
|| initialFailureBackoffMs < 0L
|
||||
|| maximumFailureBackoffMs < initialFailureBackoffMs) {
|
||||
throw new IllegalArgumentException("Invalid panic GC timing policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record PanicGcActions(
|
||||
LongSupplier clock,
|
||||
Runnable explicitGc,
|
||||
DiagnosticGc diagnosticGc,
|
||||
DoubleConsumer diagnosticStart,
|
||||
BiConsumer<String, Throwable> failureSink
|
||||
) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DiagnosticGc {
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
static final class HeapPressureGate {
|
||||
private static final long NOT_BELOW_HIGH_WATER = Long.MIN_VALUE;
|
||||
|
||||
private final double highWater;
|
||||
private final double lowWater;
|
||||
private final long maximumHysteresisMs;
|
||||
private final LongSupplier clock;
|
||||
private final Runnable releaseAction;
|
||||
private boolean engaged;
|
||||
private long belowHighWaterSince;
|
||||
|
||||
HeapPressureGate(double highWater, double lowWater, long maximumHysteresisMs, LongSupplier clock, Runnable releaseAction) {
|
||||
if (!Double.isFinite(highWater)
|
||||
|| !Double.isFinite(lowWater)
|
||||
|| lowWater < 0.0D
|
||||
|| highWater <= lowWater
|
||||
|| maximumHysteresisMs < 0L) {
|
||||
throw new IllegalArgumentException("Invalid heap pressure hysteresis policy");
|
||||
}
|
||||
this.highWater = highWater;
|
||||
this.lowWater = lowWater;
|
||||
this.maximumHysteresisMs = maximumHysteresisMs;
|
||||
this.clock = clock;
|
||||
this.releaseAction = releaseAction;
|
||||
this.belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
}
|
||||
|
||||
synchronized boolean update(double fraction) {
|
||||
if (!Double.isFinite(fraction)) {
|
||||
return engaged;
|
||||
}
|
||||
if (!engaged) {
|
||||
if (fraction >= highWater) {
|
||||
engaged = true;
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
}
|
||||
return engaged;
|
||||
}
|
||||
if (fraction <= lowWater) {
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
if (fraction >= highWater) {
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
return true;
|
||||
}
|
||||
|
||||
long now = clock.getAsLong();
|
||||
if (belowHighWaterSince == NOT_BELOW_HIGH_WATER) {
|
||||
belowHighWaterSince = now;
|
||||
return true;
|
||||
}
|
||||
if (elapsed(now, belowHighWaterSince) < maximumHysteresisMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
|
||||
private void release() {
|
||||
engaged = false;
|
||||
belowHighWaterSince = NOT_BELOW_HIGH_WATER;
|
||||
releaseAction.run();
|
||||
}
|
||||
|
||||
private static long elapsed(long now, long then) {
|
||||
return now >= then ? now - then : Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
static final class PanicGcReclaimer {
|
||||
private final PanicGcPolicy policy;
|
||||
private final PanicGcActions actions;
|
||||
private boolean panicEpisode;
|
||||
private long explicitAttemptAt;
|
||||
private long nextDiagnosticAllowedAt;
|
||||
private long failureBackoffMs;
|
||||
|
||||
PanicGcReclaimer(PanicGcPolicy policy, PanicGcActions actions) {
|
||||
this.policy = policy;
|
||||
this.actions = actions;
|
||||
this.failureBackoffMs = policy.initialFailureBackoffMs();
|
||||
}
|
||||
|
||||
synchronized void request(double fraction) {
|
||||
if (!Double.isFinite(fraction) || fraction <= LOW_WATER) {
|
||||
return;
|
||||
}
|
||||
|
||||
long now = actions.clock().getAsLong();
|
||||
if (!panicEpisode) {
|
||||
if (fraction < HIGH_WATER) {
|
||||
return;
|
||||
}
|
||||
beginEpisode(now);
|
||||
return;
|
||||
}
|
||||
if (elapsed(now, explicitAttemptAt) < policy.diagnosticDelayMs()
|
||||
|| now < nextDiagnosticAllowedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
actions.diagnosticStart().accept(fraction);
|
||||
try {
|
||||
actions.diagnosticGc().run();
|
||||
failureBackoffMs = policy.initialFailureBackoffMs();
|
||||
nextDiagnosticAllowedAt = deadline(now, policy.diagnosticCooldownMs());
|
||||
} catch (Exception failure) {
|
||||
actions.failureSink().accept(
|
||||
"Iris could not invoke the current JVM's DiagnosticCommand GC after normal panic reclaim was ineffective; generation remains pressure-limited to avoid an OOM.",
|
||||
failure);
|
||||
nextDiagnosticAllowedAt = deadline(now, failureBackoffMs);
|
||||
failureBackoffMs = Math.min(policy.maximumFailureBackoffMs(), doubled(failureBackoffMs));
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void resetEpisode() {
|
||||
panicEpisode = false;
|
||||
explicitAttemptAt = 0L;
|
||||
}
|
||||
|
||||
private void beginEpisode(long now) {
|
||||
panicEpisode = true;
|
||||
explicitAttemptAt = now;
|
||||
try {
|
||||
actions.explicitGc().run();
|
||||
} catch (RuntimeException failure) {
|
||||
actions.failureSink().accept(
|
||||
"Iris normal panic heap reclaim failed; the diagnostic fallback will be attempted if pressure remains critical.",
|
||||
failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static long elapsed(long now, long then) {
|
||||
return now >= then ? now - then : Long.MAX_VALUE;
|
||||
}
|
||||
|
||||
private static long deadline(long now, long delay) {
|
||||
return delay > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + delay;
|
||||
}
|
||||
|
||||
private static long doubled(long value) {
|
||||
return value > Long.MAX_VALUE / 2L ? Long.MAX_VALUE : value * 2L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ public final class PregenMantleBackpressure {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
|
||||
if (MantleHeapPressure.overPanicWater()) {
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
}
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
|
||||
long elapsed = M.ms() - waitStart;
|
||||
if (elapsed >= timeoutMs) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import java.util.Map;
|
||||
@Builder
|
||||
@Data
|
||||
public class PregenTask {
|
||||
static final int MAX_WORLD_BLOCK = 29_999_984;
|
||||
/**
|
||||
* Saturation limits for block bounds. The full int range is safe downstream: the widest derived value is
|
||||
* regionToChunk(blockToRegionFloor(MAX_BLOCK)) + 31 shifted back to blocks, which lands inside int.
|
||||
@@ -71,6 +72,7 @@ public class PregenTask {
|
||||
if (radiusX <= 0 || radiusZ <= 0) {
|
||||
throw new IllegalArgumentException("Pregen radii must be greater than zero blocks.");
|
||||
}
|
||||
requireWithinWorld(center, radiusX, radiusZ);
|
||||
|
||||
this.gui = gui;
|
||||
this.center = new ProxiedPos(center);
|
||||
@@ -79,6 +81,19 @@ public class PregenTask {
|
||||
bounds.update();
|
||||
}
|
||||
|
||||
private static void requireWithinWorld(Position2 center, int radiusX, int radiusZ) {
|
||||
long minX = (long) center.getX() - radiusX;
|
||||
long maxX = (long) center.getX() + radiusX;
|
||||
long minZ = (long) center.getZ() - radiusZ;
|
||||
long maxZ = (long) center.getZ() + radiusZ;
|
||||
if (minX < -MAX_WORLD_BLOCK || maxX > MAX_WORLD_BLOCK
|
||||
|| minZ < -MAX_WORLD_BLOCK || maxZ > MAX_WORLD_BLOCK) {
|
||||
throw new IllegalArgumentException("Pregen area exceeds Minecraft's coordinate limit of +/-"
|
||||
+ MAX_WORLD_BLOCK + " blocks: center " + center.getX() + "," + center.getZ()
|
||||
+ " radius " + radiusX + "x" + radiusZ + ".");
|
||||
}
|
||||
}
|
||||
|
||||
public static void iterateRegion(int xr, int zr, Spiraled s, Position2 pull) {
|
||||
iterateRegion(xr, zr, s, pull.getX(), pull.getZ());
|
||||
}
|
||||
|
||||
@@ -17,10 +17,16 @@ import org.bukkit.Chunk;
|
||||
import org.bukkit.GameRule;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.Waterlogged;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.world.TimeSkipEvent;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.VoxelShape;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -31,6 +37,26 @@ import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class WorldRuntimeControlService {
|
||||
private static final int MAX_SAFE_ENTRY_HORIZONTAL_RADIUS = 15;
|
||||
private static final int MAX_SAFE_ENTRY_VERTICAL_SEARCH = 64;
|
||||
private static final double BLOCK_CENTER = 0.5D;
|
||||
private static final double COLLISION_EPSILON = 0.000001D;
|
||||
private static final Set<Material> UNSAFE_ENTRY_MATERIALS = Set.of(
|
||||
Material.CACTUS,
|
||||
Material.CAMPFIRE,
|
||||
Material.COBWEB,
|
||||
Material.END_GATEWAY,
|
||||
Material.END_PORTAL,
|
||||
Material.FIRE,
|
||||
Material.MAGMA_BLOCK,
|
||||
Material.NETHER_PORTAL,
|
||||
Material.POINTED_DRIPSTONE,
|
||||
Material.POWDER_SNOW,
|
||||
Material.SOUL_CAMPFIRE,
|
||||
Material.SOUL_FIRE,
|
||||
Material.SWEET_BERRY_BUSH,
|
||||
Material.WITHER_ROSE
|
||||
);
|
||||
private static volatile WorldRuntimeControlService instance;
|
||||
|
||||
private final CapabilitySnapshot capabilities;
|
||||
@@ -352,19 +378,138 @@ public final class WorldRuntimeControlService {
|
||||
}
|
||||
|
||||
static Location findTopSafeLocation(World world, Location source) {
|
||||
int x = source.getBlockX();
|
||||
int z = source.getBlockZ();
|
||||
int sourceX = source.getBlockX();
|
||||
int sourceZ = source.getBlockZ();
|
||||
float yaw = source.getYaw();
|
||||
float pitch = source.getPitch();
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
if (world.isChunkLoaded(x >> 4, z >> 4)) {
|
||||
int raw = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
||||
int y = Math.max(minY, Math.min(maxY, raw + 1));
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
||||
int chunkX = sourceX >> 4;
|
||||
int chunkZ = sourceZ >> 4;
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ)) {
|
||||
return null;
|
||||
}
|
||||
int y = Math.max(minY, Math.min(maxY, source.getBlockY()));
|
||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
||||
|
||||
int minimumFloorY = world.getMinHeight();
|
||||
int maximumFloorY = world.getMaxHeight() - 3;
|
||||
if (minimumFloorY > maximumFloorY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int minimumX = chunkX << 4;
|
||||
int minimumZ = chunkZ << 4;
|
||||
int maximumX = minimumX + 15;
|
||||
int maximumZ = minimumZ + 15;
|
||||
for (int radius = 0; radius <= MAX_SAFE_ENTRY_HORIZONTAL_RADIUS; radius++) {
|
||||
for (int offsetX = -radius; offsetX <= radius; offsetX++) {
|
||||
for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) {
|
||||
if (Math.max(Math.abs(offsetX), Math.abs(offsetZ)) != radius) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int x = sourceX + offsetX;
|
||||
int z = sourceZ + offsetZ;
|
||||
if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Location safeLocation = findSafeLocationInColumn(
|
||||
world,
|
||||
x,
|
||||
z,
|
||||
minimumFloorY,
|
||||
maximumFloorY,
|
||||
yaw,
|
||||
pitch
|
||||
);
|
||||
if (safeLocation != null) {
|
||||
return safeLocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Location findSafeLocationInColumn(
|
||||
World world,
|
||||
int x,
|
||||
int z,
|
||||
int minimumFloorY,
|
||||
int maximumFloorY,
|
||||
float yaw,
|
||||
float pitch
|
||||
) {
|
||||
int highestY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
||||
int startingFloorY = Math.max(minimumFloorY, Math.min(maximumFloorY, highestY));
|
||||
int lowestFloorY = Math.max(minimumFloorY, startingFloorY - MAX_SAFE_ENTRY_VERTICAL_SEARCH + 1);
|
||||
for (int floorY = startingFloorY; floorY >= lowestFloorY; floorY--) {
|
||||
Block floor = world.getBlockAt(x, floorY, z);
|
||||
if (!isSafeFloor(floor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Block feet = world.getBlockAt(x, floorY + 1, z);
|
||||
Block head = world.getBlockAt(x, floorY + 2, z);
|
||||
if (isClearEntryBlock(feet) && isClearEntryBlock(head)) {
|
||||
return new Location(world, x + BLOCK_CENTER, floorY + 1D, z + BLOCK_CENTER, yaw, pitch);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isSafeFloor(Block block) {
|
||||
Material material = block.getType();
|
||||
if (material == null
|
||||
|| isAir(material)
|
||||
|| material.name().endsWith("_LEAVES")
|
||||
|| UNSAFE_ENTRY_MATERIALS.contains(material)
|
||||
|| block.isLiquid()
|
||||
|| block.isPassable()
|
||||
|| isWaterlogged(block)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VoxelShape collisionShape = block.getCollisionShape();
|
||||
if (collisionShape == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (BoundingBox boundingBox : collisionShape.getBoundingBoxes()) {
|
||||
if (boundingBox.getMinX() <= BLOCK_CENTER
|
||||
&& boundingBox.getMaxX() >= BLOCK_CENTER
|
||||
&& boundingBox.getMinZ() <= BLOCK_CENTER
|
||||
&& boundingBox.getMaxZ() >= BLOCK_CENTER
|
||||
&& boundingBox.getMaxY() > COLLISION_EPSILON
|
||||
&& boundingBox.getMaxY() <= 1D + COLLISION_EPSILON) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isClearEntryBlock(Block block) {
|
||||
Material material = block.getType();
|
||||
if (material == null
|
||||
|| block.isLiquid()
|
||||
|| isWaterlogged(block)
|
||||
|| UNSAFE_ENTRY_MATERIALS.contains(material)
|
||||
|| !block.isPassable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VoxelShape collisionShape = block.getCollisionShape();
|
||||
return collisionShape != null && collisionShape.getBoundingBoxes().isEmpty();
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private static boolean isWaterlogged(Block block) {
|
||||
BlockData blockData = block.getBlockData();
|
||||
return blockData instanceof Waterlogged waterlogged && waterlogged.isWaterlogged();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -67,7 +67,7 @@ public final class EngineMaintenance {
|
||||
long unloadStart = System.nanoTime();
|
||||
int unloadedTectonicPlates = engine.getMantle().unloadTectonicPlate(
|
||||
plan.multicoreUnload() ? 0 : Integer.MAX_VALUE);
|
||||
if (plan.heapPressure() && MantleHeapPressure.overPanicWater()) {
|
||||
if (plan.heapPressure()) {
|
||||
MantleHeapPressure.requestPanicReclaim();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import art.arcane.iris.util.common.plugin.IrisService;
|
||||
import art.arcane.volmlib.util.scheduling.Looper;
|
||||
import lombok.NonNull;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -145,7 +146,21 @@ public class GlobalCacheSVC implements IrisService {
|
||||
|
||||
private static PregenCache createDefault0(String worldIdentity) {
|
||||
if (disabled) return PregenCache.EMPTY;
|
||||
File dimensionRoot = IrisWorldStorage.dimensionRoot(WorldIdentity.parse(worldIdentity));
|
||||
NamespacedKey worldKey = WorldIdentity.parse(worldIdentity);
|
||||
File dimensionRoot = requireCacheDimensionRoot(
|
||||
Bukkit.getWorldContainer(),
|
||||
IrisWorldStorage.levelRoot(),
|
||||
worldKey
|
||||
);
|
||||
return PregenCache.create(new File(dimensionRoot, "iris/pregen")).sync();
|
||||
}
|
||||
|
||||
static File requireCacheDimensionRoot(File worldContainer, File levelRoot, NamespacedKey worldKey) {
|
||||
return IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()),
|
||||
worldKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.WorldCreatorCompat;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||
@@ -165,7 +166,7 @@ public class IrisCreator {
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
} catch (RuntimeException e) {
|
||||
throw new IrisException(e.getMessage(), e);
|
||||
}
|
||||
name = IrisWorldStorage.logicalName(worldKey);
|
||||
@@ -197,12 +198,19 @@ public class IrisCreator {
|
||||
|
||||
private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException {
|
||||
File dimensionRoot;
|
||||
File storageRoot;
|
||||
try {
|
||||
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (!studio && !benchmark) {
|
||||
dimensionRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey);
|
||||
storageRoot = WorldCreatorCompat.persistentLevelRoot(worldKey);
|
||||
} else {
|
||||
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
storageRoot = dimensionRoot;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IrisException(e.getMessage(), e);
|
||||
}
|
||||
if (Files.exists(dimensionRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) {
|
||||
if (Files.exists(storageRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) {
|
||||
throw new IrisException("World \"" + name + "\" already exists or is loaded.");
|
||||
}
|
||||
if (sender == null) {
|
||||
@@ -249,6 +257,7 @@ public class IrisCreator {
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.studio(studio)
|
||||
.persistent(!studio && !benchmark)
|
||||
.create();
|
||||
reportStudioTiming("prepare_studio_generator", generatorPrepareStart);
|
||||
reportStudioProgress(0.40D, "install_datapacks");
|
||||
@@ -343,7 +352,7 @@ public class IrisCreator {
|
||||
}
|
||||
return world;
|
||||
} catch (Throwable failure) {
|
||||
rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure);
|
||||
rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure);
|
||||
if (failure instanceof IrisException irisException) {
|
||||
throw irisException;
|
||||
}
|
||||
@@ -692,7 +701,7 @@ public class IrisCreator {
|
||||
NamespacedKey worldKey,
|
||||
World createdWorld,
|
||||
PlatformChunkGenerator stagedGenerator,
|
||||
File dimensionRoot,
|
||||
File storageRoot,
|
||||
boolean bukkitRegistered,
|
||||
Throwable failure
|
||||
) {
|
||||
@@ -749,7 +758,12 @@ public class IrisCreator {
|
||||
if (bukkitRegistered) {
|
||||
try {
|
||||
CompletableFuture<Boolean> multiverseRemoval = J.sfut(
|
||||
() -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig(name)
|
||||
() -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig(
|
||||
IrisWorldStorage.configuredWorldName(
|
||||
worldKey,
|
||||
IrisWorldStorage.levelRoot().getName()
|
||||
)
|
||||
)
|
||||
);
|
||||
if (multiverseRemoval == null) {
|
||||
throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\".");
|
||||
@@ -782,7 +796,7 @@ public class IrisCreator {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(dimensionRoot.toPath());
|
||||
AtomicDirectoryPublisher.deleteTree(storageRoot.toPath());
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
queueRollbackDeletion(name, failure);
|
||||
|
||||
@@ -38,6 +38,7 @@ public class IrisWorldCreator {
|
||||
private String dimensionName = null;
|
||||
private IrisDimension dimension;
|
||||
private long seed = 1337;
|
||||
private boolean persistent;
|
||||
|
||||
public IrisWorldCreator() {
|
||||
|
||||
@@ -75,26 +76,35 @@ public class IrisWorldCreator {
|
||||
return this;
|
||||
}
|
||||
|
||||
public IrisWorldCreator persistent(boolean persistent) {
|
||||
this.persistent = persistent;
|
||||
return this;
|
||||
}
|
||||
|
||||
public WorldCreator create() {
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(name);
|
||||
World.Environment environment = findEnvironment();
|
||||
WorldCreator creator = persistent
|
||||
? WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||
: WorldCreatorCompat.ofKey(worldKey);
|
||||
File worldFolder = persistent
|
||||
? WorldCreatorCompat.persistentDimensionRoot(worldKey)
|
||||
: IrisWorldStorage.dimensionRoot(worldKey);
|
||||
|
||||
IrisWorld w = IrisWorld.builder()
|
||||
.platformIdentity(worldKey.toString())
|
||||
.name(name)
|
||||
.name(creator.name())
|
||||
.minHeight(dim.getMinHeight())
|
||||
.maxHeight(dim.getMaxHeight())
|
||||
.seed(seed)
|
||||
.worldFolder(IrisWorldStorage.dimensionRoot(worldKey))
|
||||
.worldFolder(worldFolder)
|
||||
.build();
|
||||
ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio
|
||||
? dim.getLoader().getDataFolder() :
|
||||
new File(w.worldFolder(), "iris/pack"), dimensionName);
|
||||
|
||||
|
||||
return WorldCreatorCompat.ofKey(worldKey)
|
||||
.environment(environment)
|
||||
return creator.environment(environment)
|
||||
.generateStructures(true)
|
||||
.generator(g).seed(seed);
|
||||
}
|
||||
|
||||
@@ -118,23 +118,33 @@ final class EngineRuntimeBuilder {
|
||||
engine.runtime = null;
|
||||
}
|
||||
|
||||
try {
|
||||
next.worldManager().start();
|
||||
} catch (Throwable e) {
|
||||
Throwable cleanupFailure = engine.shutdownSequence.closeRuntime(next, e);
|
||||
if (cleanupFailure != e) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
throw new IllegalStateException("Failed to start the Iris world manager.", e);
|
||||
}
|
||||
|
||||
engine.runtime = next;
|
||||
engine.publishedTarget = next.target();
|
||||
engine.getGenerationSessions().activateNextSession();
|
||||
engine.lifecycleState = LifecycleState.RUNNING;
|
||||
engine.getClosing().set(false);
|
||||
engine.backgroundTasks.openBackgroundTaskAdmission();
|
||||
try {
|
||||
next.worldManager().start();
|
||||
} catch (Throwable e) {
|
||||
engine.getClosing().set(true);
|
||||
engine.backgroundTasks.closeBackgroundTaskAdmission();
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
try {
|
||||
engine.getGenerationSessions().sealAndAwait(
|
||||
"failed world manager start",
|
||||
IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS,
|
||||
true
|
||||
);
|
||||
} catch (Throwable drainFailure) {
|
||||
e.addSuppressed(drainFailure);
|
||||
}
|
||||
engine.shutdownSequence.closeRuntime(next, e);
|
||||
if (engine.runtime == next) {
|
||||
engine.runtime = null;
|
||||
}
|
||||
throw new IllegalStateException("Failed to start the Iris world manager.", e);
|
||||
}
|
||||
scheduleRuntimeTasks(next);
|
||||
IrisLogging.debug("Engine Setup Complete " + next.cacheId());
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.util.project.interpolation.IrisInterpolation;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -14,18 +16,20 @@ import java.util.Map;
|
||||
public final class IrisDimensionCarvingResolver {
|
||||
private static final int MAX_CHILD_DEPTH = 32;
|
||||
private static final long CHILD_SEED_SALT = 0x9E3779B97F4A7C15L;
|
||||
private static final ThreadLocal<State> THREAD_STATE = ThreadLocal.withInitial(State::new);
|
||||
private static final ThreadLocal<WeakReference<State>> THREAD_STATE =
|
||||
ThreadLocal.withInitial(() -> new WeakReference<>(null));
|
||||
|
||||
private IrisDimensionCarvingResolver() {
|
||||
|
||||
}
|
||||
|
||||
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) {
|
||||
return resolveRootEntry(engine, worldY, THREAD_STATE.get());
|
||||
return resolveRootEntry(engine, worldY, threadState());
|
||||
}
|
||||
|
||||
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) {
|
||||
State resolvedState = state == null ? THREAD_STATE.get() : state;
|
||||
State resolvedState = state == null ? threadState() : state;
|
||||
resolvedState.bind(engine);
|
||||
if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) {
|
||||
return resolvedState.rootEntriesByWorldY.get(worldY);
|
||||
}
|
||||
@@ -51,11 +55,12 @@ public final class IrisDimensionCarvingResolver {
|
||||
}
|
||||
|
||||
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) {
|
||||
return resolveFromRoot(engine, rootEntry, worldX, worldZ, THREAD_STATE.get());
|
||||
return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState());
|
||||
}
|
||||
|
||||
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) {
|
||||
State resolvedState = state == null ? THREAD_STATE.get() : state;
|
||||
State resolvedState = state == null ? threadState() : state;
|
||||
resolvedState.bind(engine);
|
||||
if (rootEntry == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -103,6 +108,7 @@ public final class IrisDimensionCarvingResolver {
|
||||
return entry.getRealBiome(engine.getData());
|
||||
}
|
||||
|
||||
state.bind(engine);
|
||||
if (state.biomeCache.containsKey(entry)) {
|
||||
return state.biomeCache.get(entry);
|
||||
}
|
||||
@@ -266,12 +272,48 @@ public final class IrisDimensionCarvingResolver {
|
||||
return state.childSeed;
|
||||
}
|
||||
|
||||
private static State threadState() {
|
||||
WeakReference<State> reference = THREAD_STATE.get();
|
||||
State state = reference.get();
|
||||
if (state != null) {
|
||||
return state;
|
||||
}
|
||||
State replacement = new State();
|
||||
THREAD_STATE.set(new WeakReference<>(replacement));
|
||||
return replacement;
|
||||
}
|
||||
|
||||
public static final class State {
|
||||
private final Map<Integer, IrisDimensionCarvingEntry> rootEntriesByWorldY = new HashMap<>();
|
||||
private final Map<IrisDimensionCarvingEntry, ParentSelectionPlan> selectionPlans = new IdentityHashMap<>();
|
||||
private final Map<IrisDimensionCarvingEntry, IrisBiome> biomeCache = new IdentityHashMap<>();
|
||||
private WeakReference<Engine> engineIdentity;
|
||||
private WeakReference<IrisDimension> dimensionIdentity;
|
||||
private WeakReference<IrisData> dataIdentity;
|
||||
private Map<String, IrisDimensionCarvingEntry> entryIndex;
|
||||
private Long childSeed;
|
||||
|
||||
private void bind(Engine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
IrisData data = engine.getData();
|
||||
if (references(engineIdentity, engine)
|
||||
&& references(dimensionIdentity, dimension)
|
||||
&& references(dataIdentity, data)) {
|
||||
return;
|
||||
}
|
||||
engineIdentity = new WeakReference<>(engine);
|
||||
dimensionIdentity = new WeakReference<>(dimension);
|
||||
dataIdentity = new WeakReference<>(data);
|
||||
rootEntriesByWorldY.clear();
|
||||
selectionPlans.clear();
|
||||
biomeCache.clear();
|
||||
entryIndex = null;
|
||||
childSeed = null;
|
||||
}
|
||||
|
||||
private static boolean references(WeakReference<?> identity, Object value) {
|
||||
return identity != null && identity.get() == value;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ParentSelectionPlan {
|
||||
|
||||
@@ -250,7 +250,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
int minY = world.getMinHeight() + 1;
|
||||
int maxY = world.getMaxHeight() - 2;
|
||||
int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn)));
|
||||
int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn) + 1));
|
||||
world.setSpawnLocation(new Location(world, initialSpawn.getX(), y, initialSpawn.getZ(), initialSpawn.getYaw(), initialSpawn.getPitch()));
|
||||
}
|
||||
|
||||
|
||||
@@ -255,4 +255,164 @@ public class IrisWorldStorageTest {
|
||||
assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, file));
|
||||
assertTrue(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, directory));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentDimensionRootAllowsOnlyManagedAndExactVanillaSlots() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("persistent-root");
|
||||
|
||||
assertEquals(
|
||||
IrisWorldStorage.dimensionRoot(levelRoot, new NamespacedKey("iris", "moon")).getCanonicalFile(),
|
||||
IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
new NamespacedKey("iris", "moon")
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("overworld")).getCanonicalFile(),
|
||||
IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("overworld")
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("the_nether")).getCanonicalFile(),
|
||||
IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("the_nether")
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("the_end")).getCanonicalFile(),
|
||||
IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("the_end")
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("custom")
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> IrisWorldStorage.requireSafePersistentDimensionRoot(
|
||||
levelRoot,
|
||||
new NamespacedKey("foreign", "moon")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenDimensionRootUsesCanonicalLevelStorageWhenPresent() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("server-direct");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "moon");
|
||||
File dimensionRoot = IrisWorldStorage.dimensionRoot(levelRoot, worldKey);
|
||||
Files.createDirectories(dimensionRoot.toPath());
|
||||
|
||||
assertEquals(
|
||||
dimensionRoot,
|
||||
IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
"world_iris_moon",
|
||||
worldKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenDimensionRootUsesCurrentCraftBukkitConfiguredStorage() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("server-configured");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "moon");
|
||||
File configuredLevelRoot = new File(worldContainer, "world_iris_moon");
|
||||
File configuredDimensionRoot = IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey);
|
||||
Files.createDirectories(configuredDimensionRoot.toPath());
|
||||
|
||||
assertEquals(
|
||||
configuredDimensionRoot,
|
||||
IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
"world_iris_moon",
|
||||
worldKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenDimensionRootRejectsMissingAndAmbiguousStorage() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("server-ambiguous");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "moon");
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
"world_iris_moon",
|
||||
worldKey
|
||||
)
|
||||
);
|
||||
|
||||
Files.createDirectories(IrisWorldStorage.dimensionRoot(levelRoot, worldKey).toPath());
|
||||
File configuredLevelRoot = new File(worldContainer, "world_iris_moon");
|
||||
Files.createDirectories(IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey).toPath());
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
"world_iris_moon",
|
||||
worldKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenDimensionRootRejectsSymlinkedStorage() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("server-symlink");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
Path namespaceRoot = Files.createDirectories(levelRoot.toPath().resolve("dimensions"));
|
||||
Path outside = temporaryFolder.newFolder("frozen-outside").toPath();
|
||||
Files.createSymbolicLink(namespaceRoot.resolve("iris"), outside);
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenDimensionRoot(
|
||||
worldContainer,
|
||||
levelRoot,
|
||||
"world_iris_moon",
|
||||
new NamespacedKey("iris", "moon")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenPackRootRequiresRealWorldLocalSnapshot() throws Exception {
|
||||
File dimensionRoot = temporaryFolder.newFolder("frozen-pack-world");
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)
|
||||
);
|
||||
|
||||
Path irisRoot = Files.createDirectory(dimensionRoot.toPath().resolve("iris"));
|
||||
Path externalPack = temporaryFolder.newFolder("external-pack").toPath();
|
||||
Files.createSymbolicLink(irisRoot.resolve("pack"), externalPack);
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)
|
||||
);
|
||||
|
||||
Files.delete(irisRoot.resolve("pack"));
|
||||
Path packRoot = Files.createDirectory(irisRoot.resolve("pack"));
|
||||
assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,20 @@ public class IrisWorldsTest {
|
||||
assertEquals(Set.of("world", "world_iris_moon"), selected.keySet());
|
||||
assertEquals(Set.of("archive_iris_foreign"), other.keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitWorldFilteringRecognizesCurrentCraftBukkitConfiguredStorage() throws Exception {
|
||||
Path worldContainer = temporaryFolder.newFolder("configured-server").toPath();
|
||||
Path levelRoot = Files.createDirectory(worldContainer.resolve("world"));
|
||||
Files.createDirectories(
|
||||
worldContainer.resolve("world_iris_moon/dimensions/iris/moon")
|
||||
);
|
||||
|
||||
Map<String, String> selected = IrisWorlds.filterBukkitWorldsByStorage(
|
||||
levelRoot,
|
||||
Map.of("world_iris_moon", "overworld")
|
||||
);
|
||||
|
||||
assertEquals(Map.of("world_iris_moon", "overworld"), selected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,23 @@ public class WorldCreatorCompatTest {
|
||||
assertEquals("world_the_end", WorldCreatorCompat.fallbackName(NamespacedKey.minecraft("the_end"), "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentFallbackUsesExactConfiguredStartupName() {
|
||||
assertEquals(
|
||||
"world_iris_compat_world",
|
||||
WorldCreatorCompat.fallbackPersistentName(new NamespacedKey("iris", "compat_world"), "world")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallbackKeyRoundTripsCreatorName() {
|
||||
assertEquals(new NamespacedKey("iris", "compat_world"), WorldCreatorCompat.fallbackKey("compat_world", "world"));
|
||||
assertEquals(NamespacedKey.minecraft("overworld"), WorldCreatorCompat.fallbackKey("world", "world"));
|
||||
assertEquals(NamespacedKey.minecraft("the_nether"), WorldCreatorCompat.fallbackKey("world_nether", "world"));
|
||||
assertEquals(
|
||||
new NamespacedKey("iris", "compat_world"),
|
||||
WorldCreatorCompat.fallbackKey("world_iris_compat_world", "world")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -112,6 +112,23 @@ public class BukkitWorldConfigurationTest {
|
||||
)), bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void admitsCurrentCraftBukkitConfiguredWorldStorage() throws Exception {
|
||||
Path worldContainer = temporaryFolder.newFolder("configured-binding-server").toPath();
|
||||
File configuration = worldContainer.resolve("bukkit.yml").toFile();
|
||||
Path levelRoot = Files.createDirectory(worldContainer.resolve("world"));
|
||||
Files.createDirectories(worldContainer.resolve("world_iris_moon/dimensions/iris/moon"));
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world_iris_moon.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
assertEquals(List.of(new BukkitWorldConfiguration.IrisGeneratorBinding(
|
||||
"world_iris_moon",
|
||||
new WorldSlotKey("iris", "moon"),
|
||||
"overworld"
|
||||
)), BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void excludesSymlinkedCustomWorldTarget() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("symlink-admission-bukkit.yml");
|
||||
|
||||
@@ -30,6 +30,18 @@ public class WorldLifecycleStagingTest {
|
||||
assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagedStemGeneratorCanBeInspectedWithoutConsumption() {
|
||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||
|
||||
WorldLifecycleStaging.stageStemGenerator("world", generator);
|
||||
|
||||
assertSame(generator, WorldLifecycleStaging.peekStemGenerator("world"));
|
||||
assertSame(generator, WorldLifecycleStaging.peekStemGenerator("world"));
|
||||
assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world"));
|
||||
assertNull(WorldLifecycleStaging.peekStemGenerator("world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagedStemGeneratorCannotBeConsumedByDifferentWorldName() {
|
||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||
|
||||
@@ -28,6 +28,28 @@ public class WorldRemovalPathPolicyTest {
|
||||
levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(),
|
||||
target.worldDirectory()
|
||||
);
|
||||
assertEquals(target.worldDirectory(), target.storageDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesCurrentCraftBukkitConfiguredDimensionDirectory() throws Exception {
|
||||
Path worldContainer = temporaryFolder.newFolder("configured-removal-server").toPath();
|
||||
Path levelRoot = Files.createDirectory(worldContainer.resolve("world"));
|
||||
Path dimensionRoot = Files.createDirectories(
|
||||
worldContainer.resolve("world_iris_moon/dimensions/iris/moon")
|
||||
);
|
||||
|
||||
WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve("moon", "world", levelRoot);
|
||||
|
||||
assertEquals(dimensionRoot.toAbsolutePath().normalize(), target.worldDirectory());
|
||||
assertEquals(worldContainer.resolve("world_iris_moon").toAbsolutePath().normalize(),
|
||||
target.storageDirectory());
|
||||
WorldRemovalPathPolicy.validateStoragePath(levelRoot, target.worldKey(), dimensionRoot);
|
||||
WorldRemovalPathPolicy.validateStorageRoot(
|
||||
levelRoot,
|
||||
target.worldKey(),
|
||||
worldContainer.resolve("world_iris_moon")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -320,6 +320,7 @@ public class WorldReplacementBootstrapTest {
|
||||
Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json");
|
||||
Files.createDirectories(overworldDimension.getParent());
|
||||
Files.writeString(overworldDimension, "overworld-replacement");
|
||||
WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId);
|
||||
String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
overworldPaths.stage().resolve("iris/pack")
|
||||
);
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class WorldReplacementEntryGuardTest {
|
||||
private static final UUID TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID OTHER_TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID FIRST_PLAYER = UUID.fromString("10000000-0000-0000-0000-000000000001");
|
||||
private static final UUID SECOND_PLAYER = UUID.fromString("20000000-0000-0000-0000-000000000002");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void stagesAndRefreshesCurrentPlayerReceipts() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("level-refresh").toPath();
|
||||
Path stagedWorld = temporaryFolder.newFolder("stage-refresh").toPath();
|
||||
writePlayer(levelRoot, FIRST_PLAYER, ".dat");
|
||||
writePlayer(levelRoot, SECOND_PLAYER, ".dat_old");
|
||||
Files.writeString(levelRoot.resolve("players/data/.DS_Store"), "ignored");
|
||||
|
||||
WorldReplacementEntryGuard.Entry staged = WorldReplacementEntryGuard.stage(
|
||||
levelRoot,
|
||||
stagedWorld,
|
||||
TRANSACTION_ID
|
||||
);
|
||||
|
||||
assertEquals(Set.of(FIRST_PLAYER), staged.pendingPlayers());
|
||||
writePlayer(levelRoot, SECOND_PLAYER, ".dat");
|
||||
|
||||
WorldReplacementEntryGuard.Entry refreshed = WorldReplacementEntryGuard.refreshPlayers(
|
||||
levelRoot,
|
||||
stagedWorld,
|
||||
TRANSACTION_ID
|
||||
);
|
||||
|
||||
assertEquals(Set.of(FIRST_PLAYER, SECOND_PLAYER), refreshed.pendingPlayers());
|
||||
assertEquals(refreshed, WorldReplacementEntryGuard.load(stagedWorld).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsFinalReceiptUntilSafeSpawnAllowsMarkerRetirement() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("level-retire").toPath();
|
||||
Path stagedWorld = temporaryFolder.newFolder("stage-retire").toPath();
|
||||
writePlayer(levelRoot, FIRST_PLAYER, ".dat");
|
||||
WorldReplacementEntryGuard.stage(levelRoot, stagedWorld, TRANSACTION_ID);
|
||||
|
||||
assertFalse(WorldReplacementEntryGuard.retireIfEmpty(stagedWorld, TRANSACTION_ID));
|
||||
|
||||
Optional<WorldReplacementEntryGuard.Entry> completed = WorldReplacementEntryGuard.completePlayer(
|
||||
stagedWorld,
|
||||
TRANSACTION_ID,
|
||||
FIRST_PLAYER
|
||||
);
|
||||
|
||||
assertTrue(completed.isPresent());
|
||||
assertTrue(completed.orElseThrow().pendingPlayers().isEmpty());
|
||||
assertTrue(Files.isRegularFile(marker(stagedWorld)));
|
||||
assertTrue(WorldReplacementEntryGuard.retireIfEmpty(stagedWorld, TRANSACTION_ID));
|
||||
assertFalse(Files.exists(marker(stagedWorld)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAReceiptFromAnotherTransactionWithoutChangingTheMarker() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("level-mismatch").toPath();
|
||||
Path stagedWorld = temporaryFolder.newFolder("stage-mismatch").toPath();
|
||||
writePlayer(levelRoot, FIRST_PLAYER, ".dat");
|
||||
WorldReplacementEntryGuard.stage(levelRoot, stagedWorld, TRANSACTION_ID);
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementEntryGuard.completePlayer(
|
||||
stagedWorld,
|
||||
OTHER_TRANSACTION_ID,
|
||||
FIRST_PLAYER
|
||||
)
|
||||
);
|
||||
|
||||
assertTrue(failure.getMessage().contains("another transaction"));
|
||||
assertEquals(
|
||||
Set.of(FIRST_PLAYER),
|
||||
WorldReplacementEntryGuard.load(stagedWorld).orElseThrow().pendingPlayers()
|
||||
);
|
||||
}
|
||||
|
||||
private static void writePlayer(Path levelRoot, UUID playerId, String suffix) throws IOException {
|
||||
Path playerData = levelRoot.resolve("players/data");
|
||||
Files.createDirectories(playerData);
|
||||
Files.writeString(playerData.resolve(playerId + suffix), "player");
|
||||
}
|
||||
|
||||
private static Path marker(Path worldDirectory) {
|
||||
return worldDirectory.resolve("iris").resolve(WorldReplacementEntryGuard.MARKER_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.pregenerator;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import javax.management.MBeanServer;
|
||||
import javax.management.ObjectName;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MantleHeapPressureTest {
|
||||
private static final MantleHeapPressure.PanicGcPolicy POLICY =
|
||||
new MantleHeapPressure.PanicGcPolicy(10_000L, 60_000L, 60_000L, 240_000L);
|
||||
|
||||
@Test
|
||||
public void normalPressureNeverRequestsEitherGcPath() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.addAndGet(120_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
|
||||
assertEquals(0, fixture.explicitCalls.get());
|
||||
assertEquals(0, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sustainedPanicInvokesDiagnosticOnlyAfterNormalReclaimGrace() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(9_999L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.99D);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
assertEquals(List.of(0.97D), fixture.diagnosticFractions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveredEpisodeResetsButDiagnosticCooldownStillApplies() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.reclaimer.resetEpisode();
|
||||
fixture.clock.set(20_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(30_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.97D);
|
||||
|
||||
assertEquals(2, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void highWaterEpisodeDiagnosesAfterNormalReclaimLeavesHeapBelowHighWater() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.93D);
|
||||
fixture.clock.set(9_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(1, fixture.diagnosticCalls.get());
|
||||
assertEquals(List.of(0.91D), fixture.diagnosticFractions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulDiagnosticRetriesAfterCooldownWhenPressureRemainsHigh() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(0);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
assertEquals(List.of(0.91D, 0.91D), fixture.diagnosticFractions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedDiagnosticRetriesAfterBackoffWithinSameEpisode() {
|
||||
ReclaimerFixture fixture = new ReclaimerFixture(1);
|
||||
|
||||
fixture.reclaimer.request(0.97D);
|
||||
fixture.clock.set(10_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(69_999L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
fixture.clock.set(70_000L);
|
||||
fixture.reclaimer.request(0.91D);
|
||||
|
||||
assertEquals(1, fixture.explicitCalls.get());
|
||||
assertEquals(2, fixture.diagnosticCalls.get());
|
||||
assertEquals(1, fixture.failures.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sustainedSubHighPressureReleasesAfterBoundedHysteresis() {
|
||||
AtomicLong clock = new AtomicLong(1_000L);
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D,
|
||||
0.82D,
|
||||
60_000L,
|
||||
clock::get,
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
assertEquals(true, gate.update(0.89D));
|
||||
clock.set(60_999L);
|
||||
assertEquals(true, gate.update(0.89D));
|
||||
clock.set(61_000L);
|
||||
assertEquals(false, gate.update(0.89D));
|
||||
assertEquals(1, releases.get());
|
||||
assertEquals(false, gate.update(0.89D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renewedHighPressureRestartsHysteresisAndCanReengageAfterRelease() {
|
||||
AtomicLong clock = new AtomicLong(5_000L);
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D,
|
||||
0.82D,
|
||||
60_000L,
|
||||
clock::get,
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(64_999L);
|
||||
assertEquals(true, gate.update(0.93D));
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(124_998L);
|
||||
assertEquals(true, gate.update(0.88D));
|
||||
clock.set(124_999L);
|
||||
assertEquals(false, gate.update(0.88D));
|
||||
assertEquals(1, releases.get());
|
||||
assertEquals(true, gate.update(0.92D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowWaterReleasesImmediately() {
|
||||
AtomicLong clock = new AtomicLong();
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||
0.92D,
|
||||
0.82D,
|
||||
60_000L,
|
||||
clock::get,
|
||||
releases::incrementAndGet);
|
||||
|
||||
assertEquals(true, gate.update(0.95D));
|
||||
assertEquals(false, gate.update(0.82D));
|
||||
assertEquals(1, releases.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void diagnosticInvocationUsesCurrentJvmCommandMBean() throws Exception {
|
||||
MBeanServer server = mock(MBeanServer.class);
|
||||
when(server.isRegistered(any(ObjectName.class))).thenReturn(true);
|
||||
|
||||
MantleHeapPressure.invokeHotSpotDiagnosticGc(server);
|
||||
|
||||
ObjectName name = new ObjectName("com.sun.management:type=DiagnosticCommand");
|
||||
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
|
||||
ArgumentCaptor<String[]> signature = ArgumentCaptor.forClass(String[].class);
|
||||
verify(server).invoke(
|
||||
eq(name),
|
||||
eq("gcRun"),
|
||||
parameters.capture(),
|
||||
signature.capture());
|
||||
assertArrayEquals(new Object[0], parameters.getValue());
|
||||
assertArrayEquals(new String[0], signature.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedJvmFailsBeforeInvokingDiagnosticCommand() throws Exception {
|
||||
MBeanServer server = mock(MBeanServer.class);
|
||||
when(server.isRegistered(any(ObjectName.class))).thenReturn(false);
|
||||
|
||||
assertThrows(UnsupportedOperationException.class, () -> MantleHeapPressure.invokeHotSpotDiagnosticGc(server));
|
||||
verify(server, never()).invoke(any(ObjectName.class), any(), any(), any());
|
||||
}
|
||||
|
||||
private static final class ReclaimerFixture {
|
||||
private final AtomicLong clock = new AtomicLong();
|
||||
private final AtomicInteger explicitCalls = new AtomicInteger();
|
||||
private final AtomicInteger diagnosticCalls = new AtomicInteger();
|
||||
private final List<Double> diagnosticFractions = new ArrayList<>();
|
||||
private final List<Throwable> failures = new ArrayList<>();
|
||||
private final MantleHeapPressure.PanicGcReclaimer reclaimer;
|
||||
|
||||
private ReclaimerFixture(int failuresBeforeSuccess) {
|
||||
MantleHeapPressure.PanicGcActions actions = new MantleHeapPressure.PanicGcActions(
|
||||
clock::get,
|
||||
explicitCalls::incrementAndGet,
|
||||
() -> {
|
||||
int call = diagnosticCalls.incrementAndGet();
|
||||
if (call <= failuresBeforeSuccess) {
|
||||
throw new IllegalStateException("unsupported");
|
||||
}
|
||||
},
|
||||
diagnosticFractions::add,
|
||||
(String context, Throwable failure) -> failures.add(failure));
|
||||
this.reclaimer = new MantleHeapPressure.PanicGcReclaimer(POLICY, actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-23
@@ -9,31 +9,27 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PregenTaskBoundsOverflowTest {
|
||||
@Test
|
||||
public void farPositiveCenterKeepsRegionBoundsOrdered() {
|
||||
PregenTask task = PregenTask.builder()
|
||||
.center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16))
|
||||
.radiusX(4096)
|
||||
.radiusZ(4096)
|
||||
.build();
|
||||
public void farPositiveCenterIsRejectedBeforeTraversal() {
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> PregenTask.builder()
|
||||
.center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16))
|
||||
.radiusX(4096)
|
||||
.radiusZ(4096)
|
||||
.build());
|
||||
|
||||
int[] bounds = task.regionBounds();
|
||||
|
||||
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
|
||||
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
|
||||
assertTrue(failure.getMessage().contains("coordinate limit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void farNegativeCenterKeepsRegionBoundsOrdered() {
|
||||
PregenTask task = PregenTask.builder()
|
||||
.center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16))
|
||||
.radiusX(4096)
|
||||
.radiusZ(4096)
|
||||
.build();
|
||||
public void farNegativeCenterIsRejectedBeforeTraversal() {
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> PregenTask.builder()
|
||||
.center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16))
|
||||
.radiusX(4096)
|
||||
.radiusZ(4096)
|
||||
.build());
|
||||
|
||||
int[] bounds = task.regionBounds();
|
||||
|
||||
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
|
||||
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
|
||||
assertTrue(failure.getMessage().contains("coordinate limit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -49,11 +45,11 @@ public class PregenTaskBoundsOverflowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldLimitRadiusIsAccepted() {
|
||||
public void exactWorldLimitRadiusIsAccepted() {
|
||||
PregenTask task = PregenTask.builder()
|
||||
.center(new Position2(0, 0))
|
||||
.radiusX(30_000_000)
|
||||
.radiusZ(30_000_000)
|
||||
.radiusX(PregenTask.MAX_WORLD_BLOCK)
|
||||
.radiusZ(PregenTask.MAX_WORLD_BLOCK)
|
||||
.build();
|
||||
|
||||
int[] bounds = task.regionBounds();
|
||||
@@ -62,6 +58,30 @@ public class PregenTaskBoundsOverflowTest {
|
||||
assertEquals(58594, bounds[2]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oneBlockPastWorldLimitIsRejectedOnEveryEdge() {
|
||||
int limit = PregenTask.MAX_WORLD_BLOCK;
|
||||
|
||||
assertWorldLimitFailure(limit, 0, 1, 1);
|
||||
assertWorldLimitFailure(-limit, 0, 1, 1);
|
||||
assertWorldLimitFailure(0, limit, 1, 1);
|
||||
assertWorldLimitFailure(0, -limit, 1, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void offsetAreaEndingExactlyAtWorldLimitIsAccepted() {
|
||||
int radius = 1000;
|
||||
PregenTask task = PregenTask.builder()
|
||||
.center(new Position2(PregenTask.MAX_WORLD_BLOCK - radius, -PregenTask.MAX_WORLD_BLOCK + radius))
|
||||
.radiusX(radius)
|
||||
.radiusZ(radius)
|
||||
.build();
|
||||
|
||||
int[] bounds = task.regionBounds();
|
||||
assertTrue(bounds[0] <= bounds[2]);
|
||||
assertTrue(bounds[1] <= bounds[3]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryBoundsAreUnchanged() {
|
||||
PregenTask task = PregenTask.builder()
|
||||
@@ -87,4 +107,14 @@ public class PregenTaskBoundsOverflowTest {
|
||||
assertEquals("bounds[" + index + "]", expected[index], actual[index]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertWorldLimitFailure(int centerX, int centerZ, int radiusX, int radiusZ) {
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> PregenTask.builder()
|
||||
.center(new Position2(centerX, centerZ))
|
||||
.radiusX(radiusX)
|
||||
.radiusZ(radiusZ)
|
||||
.build());
|
||||
assertTrue(failure.getMessage().contains("coordinate limit"));
|
||||
}
|
||||
}
|
||||
|
||||
+183
-11
@@ -4,18 +4,31 @@ import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.Waterlogged;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.VoxelShape;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class WorldRuntimeControlServiceSafeEntryTest {
|
||||
private static final BoundingBox FULL_BLOCK = new BoundingBox(0D, 0D, 0D, 1D, 1D, 1D);
|
||||
|
||||
@Test
|
||||
public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() {
|
||||
World world = mock(World.class);
|
||||
@@ -47,21 +60,180 @@ public class WorldRuntimeControlServiceSafeEntryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesSafeEntryImmediatelyWhenColumnIsAllWater() {
|
||||
World world = mock(World.class);
|
||||
Block stub = mock(Block.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
doReturn(-64).when(world).getMinHeight();
|
||||
doReturn(320).when(world).getMaxHeight();
|
||||
doReturn(true).when(world).isChunkLoaded(0, 0);
|
||||
doReturn(62).when(world).getHighestBlockYAt(0, 0);
|
||||
doReturn(62).when(world).getHighestBlockYAt(0, 0, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
||||
doReturn(stub).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
public void resolvesEntryAboveDryCollisionSupportingFloor() {
|
||||
World world = loadedWorld(0, 0);
|
||||
Block stone = block(Material.STONE, false, false, FULL_BLOCK);
|
||||
Block air = block(Material.AIR, false, true);
|
||||
doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||
doAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
return y == 62 ? stone : air;
|
||||
}).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
|
||||
Location source = new Location(world, 0.5D, 62D, 0.5D);
|
||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
||||
|
||||
assertNotNull("Safe entry must resolve to a non-null location even for water-only columns", result);
|
||||
assertNotNull(result);
|
||||
assertEquals(63, result.getBlockY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchesOnlyTheOwnedChunkForNearbySolidGround() {
|
||||
World world = loadedWorld(0, 0);
|
||||
Block water = block(Material.WATER, true, true);
|
||||
Block stone = block(Material.STONE, false, false, FULL_BLOCK);
|
||||
Block air = block(Material.AIR, false, true);
|
||||
doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||
doAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
if (x < 0 || x > 15 || z < 0 || z > 15) {
|
||||
throw new AssertionError("Safe-entry search crossed its Folia-owned source chunk");
|
||||
}
|
||||
if (x == 1 && z == 0) {
|
||||
return y == 62 ? stone : air;
|
||||
}
|
||||
return water;
|
||||
}).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
|
||||
Location source = new Location(world, 0.5D, 63D, 0.5D);
|
||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getBlockX());
|
||||
assertEquals(63, result.getBlockY());
|
||||
assertEquals(0, result.getBlockZ());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsFluidHazardousAndCollisionBlockedCandidates() {
|
||||
World world = loadedWorld(0, 0);
|
||||
Block water = block(Material.WATER, true, true);
|
||||
Block air = block(Material.AIR, false, true);
|
||||
Block stone = block(Material.STONE, false, false, FULL_BLOCK);
|
||||
Block leaves = block(Material.OAK_LEAVES, false, false, FULL_BLOCK);
|
||||
Block powderSnow = block(Material.POWDER_SNOW, false, true);
|
||||
Block magma = block(Material.MAGMA_BLOCK, false, false, FULL_BLOCK);
|
||||
Block cactus = block(Material.CACTUS, false, false, FULL_BLOCK);
|
||||
Block cobweb = block(Material.COBWEB, false, true, FULL_BLOCK);
|
||||
Block fence = block(Material.OAK_FENCE, false, false,
|
||||
new BoundingBox(0.375D, 0D, 0.375D, 0.625D, 1.5D, 0.625D));
|
||||
Block waterloggedSlab = waterloggedBlock(
|
||||
Material.OAK_SLAB,
|
||||
new BoundingBox(0D, 0D, 0D, 1D, 0.5D, 1D)
|
||||
);
|
||||
doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||
doAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
if (y == 62) {
|
||||
if (x == 7 && z == 7) {
|
||||
return leaves;
|
||||
}
|
||||
if (x == 7 && z == 8) {
|
||||
return powderSnow;
|
||||
}
|
||||
if (x == 7 && z == 9) {
|
||||
return magma;
|
||||
}
|
||||
if (x == 8 && z == 9) {
|
||||
return stone;
|
||||
}
|
||||
if (x == 9 && z == 7) {
|
||||
return waterloggedSlab;
|
||||
}
|
||||
if (x == 9 && z == 8) {
|
||||
return cactus;
|
||||
}
|
||||
if (x == 9 && z == 9) {
|
||||
return stone;
|
||||
}
|
||||
if (x == 10 && z == 10) {
|
||||
return stone;
|
||||
}
|
||||
}
|
||||
if (x == 8 && z == 9 && y == 63) {
|
||||
return cobweb;
|
||||
}
|
||||
if (x == 9 && z == 9 && y == 63) {
|
||||
return air;
|
||||
}
|
||||
if (x == 9 && z == 9 && y == 64) {
|
||||
return fence;
|
||||
}
|
||||
if (x == 10 && z == 10 && (y == 63 || y == 64)) {
|
||||
return air;
|
||||
}
|
||||
return water;
|
||||
}).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
|
||||
Location source = new Location(world, 8.5D, 63D, 8.5D);
|
||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(10, result.getBlockX());
|
||||
assertEquals(63, result.getBlockY());
|
||||
assertEquals(10, result.getBlockZ());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsNullForWaterOnlyChunkAndBoundsVerticalSearch() {
|
||||
World world = loadedWorld(0, 0);
|
||||
Block water = block(Material.WATER, true, true);
|
||||
AtomicInteger lowestReadY = new AtomicInteger(Integer.MAX_VALUE);
|
||||
doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||
doAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
lowestReadY.accumulateAndGet(y, Math::min);
|
||||
return water;
|
||||
}).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
|
||||
Location source = new Location(world, 0.5D, 63D, 0.5D);
|
||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
||||
|
||||
assertNull(result);
|
||||
assertEquals(-1, lowestReadY.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsNullWithoutReadingAnUnloadedChunk() {
|
||||
World world = loadedWorld(0, 0);
|
||||
doReturn(false).when(world).isChunkLoaded(0, 0);
|
||||
|
||||
Location source = new Location(world, 0.5D, 63D, 0.5D);
|
||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
||||
|
||||
assertNull(result);
|
||||
verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
private static World loadedWorld(int chunkX, int chunkZ) {
|
||||
World world = mock(World.class);
|
||||
doReturn(-64).when(world).getMinHeight();
|
||||
doReturn(320).when(world).getMaxHeight();
|
||||
doReturn(true).when(world).isChunkLoaded(chunkX, chunkZ);
|
||||
return world;
|
||||
}
|
||||
|
||||
private static Block block(Material material, boolean liquid, boolean passable, BoundingBox... boundingBoxes) {
|
||||
Block block = mock(Block.class);
|
||||
VoxelShape collisionShape = mock(VoxelShape.class);
|
||||
doReturn(material).when(block).getType();
|
||||
doReturn(liquid).when(block).isLiquid();
|
||||
doReturn(passable).when(block).isPassable();
|
||||
doReturn(collisionShape).when(block).getCollisionShape();
|
||||
doReturn(List.of(boundingBoxes)).when(collisionShape).getBoundingBoxes();
|
||||
return block;
|
||||
}
|
||||
|
||||
private static Block waterloggedBlock(Material material, BoundingBox... boundingBoxes) {
|
||||
Block block = block(material, false, false, boundingBoxes);
|
||||
Waterlogged blockData = mock(Waterlogged.class);
|
||||
doReturn(true).when(blockData).isWaterlogged();
|
||||
doReturn(blockData).when(block).getBlockData();
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GlobalCacheSVCTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void cacheUsesFrozenCurrentCraftBukkitDimensionRoot() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("server");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "underworld");
|
||||
File configuredLevelRoot = Files.createDirectory(
|
||||
worldContainer.toPath().resolve("world_iris_underworld")
|
||||
).toFile();
|
||||
File configuredDimensionRoot = IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey);
|
||||
Files.createDirectories(configuredDimensionRoot.toPath().resolve("iris/pack"));
|
||||
|
||||
assertEquals(
|
||||
configuredDimensionRoot,
|
||||
GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheFailsClosedWhenFrozenStorageIsAmbiguous() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("ambiguous-server");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "overworld");
|
||||
Files.createDirectories(IrisWorldStorage.dimensionRoot(levelRoot, worldKey).toPath());
|
||||
File configuredLevelRoot = Files.createDirectory(
|
||||
worldContainer.toPath().resolve("world_iris_overworld")
|
||||
).toFile();
|
||||
Files.createDirectories(IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey).toPath());
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheFailsClosedWhenFrozenStorageIsMissing() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("missing-server");
|
||||
File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile();
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "overworld");
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package art.arcane.iris.core.tools;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisPersistentWorldCreationContractTest {
|
||||
@Test
|
||||
public void productionCreateFreezesIntoCurrentPlatformStorageBeforeWorldCreation() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/tools/IrisCreator.java"));
|
||||
int createStart = source.indexOf("private World createReserved(");
|
||||
int createEnd = source.indexOf("static Player createTeleportTarget(", createStart);
|
||||
String create = source.substring(createStart, createEnd);
|
||||
|
||||
int dimensionRoot = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int levelRoot = create.indexOf("WorldCreatorCompat.persistentLevelRoot(worldKey)");
|
||||
int existingStorage = create.indexOf("Files.exists(storageRoot.toPath())");
|
||||
int freezePack = create.indexOf(".installIntoWorld(sender, resolvedDimension, dimensionRoot)");
|
||||
int persistentCreator = create.indexOf(".persistent(!studio && !benchmark)");
|
||||
int bukkitCreate = create.indexOf("INMS.get().createWorldAsync(wc, request)");
|
||||
int rollbackStorage = create.indexOf(
|
||||
"rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure)"
|
||||
);
|
||||
|
||||
assertTrue(dimensionRoot >= 0);
|
||||
assertTrue(levelRoot > dimensionRoot);
|
||||
assertTrue(existingStorage > levelRoot);
|
||||
assertTrue(freezePack > existingStorage);
|
||||
assertTrue(persistentCreator > freezePack);
|
||||
assertTrue(bukkitCreate > persistentCreator);
|
||||
assertTrue(rollbackStorage > bukkitCreate);
|
||||
assertFalse(create.contains("copySeed"));
|
||||
assertFalse(create.contains("level.dat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentCreatorPreservesConfiguredNameAndCanonicalIdentity() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java"));
|
||||
int createStart = source.indexOf("public WorldCreator create()");
|
||||
int createEnd = source.indexOf("private World.Environment findEnvironment()", createStart);
|
||||
String create = source.substring(createStart, createEnd);
|
||||
|
||||
int persistentCreator = create.indexOf("WorldCreatorCompat.ofPersistentKey(worldKey)");
|
||||
int persistentStorage = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int canonicalIdentity = create.indexOf(".platformIdentity(worldKey.toString())");
|
||||
int configuredName = create.indexOf(".name(creator.name())");
|
||||
int exactPack = create.indexOf("new File(w.worldFolder(), \"iris/pack\")");
|
||||
|
||||
assertTrue(persistentCreator >= 0);
|
||||
assertTrue(persistentStorage > persistentCreator);
|
||||
assertTrue(canonicalIdentity > persistentStorage);
|
||||
assertTrue(configuredName > canonicalIdentity);
|
||||
assertTrue(exactPack > configuredName);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publicBukkitBackendRebuildKeepsExactFallbackWorldName() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java"
|
||||
));
|
||||
int rebuildStart = source.indexOf("public WorldCreator toWorldCreator()");
|
||||
int rebuildEnd = source.indexOf("return creator;", rebuildStart);
|
||||
String rebuild = source.substring(rebuildStart, rebuildEnd);
|
||||
|
||||
assertTrue(rebuild.contains("WorldCreatorCompat.ofKey(worldKey, worldName)"));
|
||||
assertFalse(rebuild.contains("WorldCreatorCompat.ofKey(worldKey)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 EngineRuntimePublicationContractTest {
|
||||
@Test
|
||||
public void worldManagerStartsOnlyAfterTheRuntimeSessionIsReady() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java"));
|
||||
int publishStart = source.indexOf("void publishRuntime(");
|
||||
int publishEnd = source.indexOf("private void scheduleRuntimeTasks", publishStart);
|
||||
String publish = source.substring(publishStart, publishEnd);
|
||||
|
||||
assertBefore(publish, "engine.runtime = next", "next.worldManager().start()");
|
||||
assertBefore(publish, "activateNextSession()", "next.worldManager().start()");
|
||||
assertBefore(publish, "engine.lifecycleState = LifecycleState.RUNNING", "next.worldManager().start()");
|
||||
assertBefore(publish, "engine.getClosing().set(false)", "next.worldManager().start()");
|
||||
assertBefore(publish, "openBackgroundTaskAdmission()", "next.worldManager().start()");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedManagerStartClosesAdmissionBeforeRuntimeCleanup() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java"));
|
||||
int publishStart = source.indexOf("void publishRuntime(");
|
||||
int publishEnd = source.indexOf("private void scheduleRuntimeTasks", publishStart);
|
||||
String publish = source.substring(publishStart, publishEnd);
|
||||
|
||||
assertBefore(publish, "engine.getClosing().set(true)", "closeRuntime(next, e)");
|
||||
assertBefore(publish, "closeBackgroundTaskAdmission()", "closeRuntime(next, e)");
|
||||
assertBefore(publish, "sealAndAwait(", "closeRuntime(next, e)");
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
}
|
||||
+188
@@ -14,13 +14,25 @@ import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -136,6 +148,173 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedResolverStateNeverCarriesDimensionEntriesAcrossEngines() {
|
||||
Fixture first = createFixture();
|
||||
Fixture second = createMixedDepthFixture();
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, 80);
|
||||
IrisDimensionCarvingEntry secondExpected = legacyResolveRootEntry(second.engine, 80);
|
||||
|
||||
assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, 80, state));
|
||||
assertSame(secondExpected, IrisDimensionCarvingResolver.resolveRootEntry(second.engine, 80, state));
|
||||
assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, 80, state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadLocalResolverNeverCarriesDimensionEntriesAcrossEngines() {
|
||||
Fixture first = createFixture();
|
||||
Fixture second = createMixedDepthFixture();
|
||||
int worldY = 83;
|
||||
|
||||
IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, worldY);
|
||||
IrisDimensionCarvingEntry secondExpected = legacyResolveRootEntry(second.engine, worldY);
|
||||
|
||||
assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, worldY));
|
||||
assertSame(secondExpected, IrisDimensionCarvingResolver.resolveRootEntry(second.engine, worldY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadLocalResolverInvalidatesWhenTheSameEnginePublishesReplacementData() {
|
||||
Fixture first = createFixture();
|
||||
Fixture replacement = createMixedDepthFixture();
|
||||
AtomicReference<IrisDimension> dimension = new AtomicReference<>(first.engine.getDimension());
|
||||
AtomicReference<IrisData> data = new AtomicReference<>(first.engine.getData());
|
||||
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
doAnswer((InvocationOnMock invocation) -> dimension.get()).when(engine).getDimension();
|
||||
doAnswer((InvocationOnMock invocation) -> data.get()).when(engine).getData();
|
||||
|
||||
int worldY = 83;
|
||||
IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, worldY);
|
||||
IrisDimensionCarvingEntry replacementExpected = legacyResolveRootEntry(replacement.engine, worldY);
|
||||
assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY));
|
||||
|
||||
dimension.set(replacement.engine.getDimension());
|
||||
data.set(replacement.engine.getData());
|
||||
assertSame(replacementExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolverStateUsesWeakRuntimeIdentityBindings() throws NoSuchFieldException {
|
||||
Field engineIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("engineIdentity");
|
||||
Field dimensionIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("dimensionIdentity");
|
||||
Field dataIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("dataIdentity");
|
||||
|
||||
assertSame(WeakReference.class, engineIdentity.getType());
|
||||
assertSame(WeakReference.class, dimensionIdentity.getType());
|
||||
assertSame(WeakReference.class, dataIdentity.getType());
|
||||
}
|
||||
|
||||
@Test(timeout = 20_000L)
|
||||
public void longLivedWorkerDoesNotRetainPopulatedThreadLocalStateOrEngine() throws Exception {
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
CountDownLatch blockerStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseBlocker = new CountDownLatch(1);
|
||||
try {
|
||||
LifetimeReferences references = executor.submit(this::populateThreadLocalLifetimeReferences).get();
|
||||
Future<?> blocker = executor.submit(() -> {
|
||||
blockerStarted.countDown();
|
||||
releaseBlocker.await();
|
||||
return null;
|
||||
});
|
||||
assertTrue(blockerStarted.await(5L, TimeUnit.SECONDS));
|
||||
|
||||
awaitCollection(references);
|
||||
|
||||
releaseBlocker.countDown();
|
||||
blocker.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
releaseBlocker.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private LifetimeReferences populateThreadLocalLifetimeReferences() throws Exception {
|
||||
RetainedFixture fixture = createRetainedFixture();
|
||||
assertNotNull(IrisDimensionCarvingResolver.resolveRootEntry(fixture.engine(), 80));
|
||||
|
||||
Field threadStateField = IrisDimensionCarvingResolver.class.getDeclaredField("THREAD_STATE");
|
||||
threadStateField.setAccessible(true);
|
||||
ThreadLocal<?> threadState = (ThreadLocal<?>) threadStateField.get(null);
|
||||
Object value = threadState.get();
|
||||
assertTrue(value instanceof WeakReference<?>);
|
||||
@SuppressWarnings("unchecked")
|
||||
WeakReference<IrisDimensionCarvingResolver.State> state =
|
||||
(WeakReference<IrisDimensionCarvingResolver.State>) value;
|
||||
IrisDimensionCarvingResolver.State populatedState = state.get();
|
||||
assertNotNull(populatedState);
|
||||
Field biomeCacheField = IrisDimensionCarvingResolver.State.class.getDeclaredField("biomeCache");
|
||||
biomeCacheField.setAccessible(true);
|
||||
assertFalse(((Map<?, ?>) biomeCacheField.get(populatedState)).isEmpty());
|
||||
return new LifetimeReferences(
|
||||
state,
|
||||
new WeakReference<>(fixture.engine()));
|
||||
}
|
||||
|
||||
private static void awaitCollection(LifetimeReferences references) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 100; attempt++) {
|
||||
System.gc();
|
||||
if (references.state().get() == null
|
||||
&& references.engine().get() == null) {
|
||||
return;
|
||||
}
|
||||
byte[] pressure = new byte[1_048_576];
|
||||
pressure[0] = (byte) attempt;
|
||||
Thread.sleep(10L);
|
||||
}
|
||||
assertTrue("Thread-local State was retained", references.state().get() == null);
|
||||
assertTrue("Engine was retained", references.engine().get() == null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitStateRemainsStronglyCallerOwned() throws Exception {
|
||||
RetainedFixture fixture = createRetainedFixture();
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
assertSame(fixture.entry(), IrisDimensionCarvingResolver.resolveRootEntry(
|
||||
fixture.engine(), 80, state));
|
||||
|
||||
Field rootEntriesField = IrisDimensionCarvingResolver.State.class
|
||||
.getDeclaredField("rootEntriesByWorldY");
|
||||
rootEntriesField.setAccessible(true);
|
||||
System.gc();
|
||||
|
||||
assertSame(fixture.entry(), ((Map<?, ?>) rootEntriesField.get(state)).get(80));
|
||||
}
|
||||
|
||||
private RetainedFixture createRetainedFixture() {
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoader(data);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ResourceLoader<IrisBiome> biomeLoader = mock(ResourceLoader.class);
|
||||
doReturn(biome).when(biomeLoader).load("retained");
|
||||
doReturn(biomeLoader).when(data).getBiomeLoader();
|
||||
|
||||
IrisDimensionCarvingEntry entry = buildEntry(
|
||||
"retained", "retained", new IrisRange(-64, 320), 0, List.of());
|
||||
KList<IrisDimensionCarvingEntry> carvingEntries = new KList<>();
|
||||
carvingEntries.add(entry);
|
||||
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setCarving(carvingEntries);
|
||||
|
||||
Engine engine = (Engine) Proxy.newProxyInstance(
|
||||
Engine.class.getClassLoader(),
|
||||
new Class<?>[]{Engine.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getDimension" -> dimension;
|
||||
case "getData" -> data;
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == arguments[0];
|
||||
case "toString" -> "carving-retention-test-engine";
|
||||
default -> throw new UnsupportedOperationException(method.toString());
|
||||
});
|
||||
return new RetainedFixture(engine, entry);
|
||||
}
|
||||
|
||||
private Fixture createFixture() {
|
||||
IrisBiome rootLowBiome = mock(IrisBiome.class);
|
||||
IrisBiome rootHighBiome = mock(IrisBiome.class);
|
||||
@@ -454,6 +633,15 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
private record Fixture(Engine engine) {
|
||||
}
|
||||
|
||||
private record RetainedFixture(Engine engine, IrisDimensionCarvingEntry entry) {
|
||||
}
|
||||
|
||||
private record LifetimeReferences(
|
||||
WeakReference<IrisDimensionCarvingResolver.State> state,
|
||||
WeakReference<Engine> engine
|
||||
) {
|
||||
}
|
||||
|
||||
private static final class LegacyCarvingChoice implements IRare {
|
||||
private final IrisDimensionCarvingEntry entry;
|
||||
private final int rarity;
|
||||
|
||||
Reference in New Issue
Block a user