mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
content
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ExactWorldSlotPathPolicy {
|
||||
private static final Pattern SAFE_IRIS_KEY = Pattern.compile("^[a-z0-9_-]+$");
|
||||
|
||||
private ExactWorldSlotPathPolicy() {
|
||||
}
|
||||
|
||||
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
|
||||
SlotKind slotKind = classify(requiredWorldKey);
|
||||
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
|
||||
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
|
||||
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
|
||||
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
|
||||
throw new Rejection(
|
||||
RejectionReason.PATH_TRAVERSAL,
|
||||
"World key escapes its exact dimension namespace: " + requiredWorldKey
|
||||
);
|
||||
}
|
||||
|
||||
requireDirectoryOrAbsent(dimensionsRoot, "dimension storage");
|
||||
requireDirectoryOrAbsent(namespaceRoot, "dimension namespace");
|
||||
requireDirectoryOrAbsent(worldDirectory, "world slot");
|
||||
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
|
||||
}
|
||||
|
||||
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
|
||||
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
|
||||
rejectTraversal(requiredCandidate, "World candidate");
|
||||
Target target = resolve(levelRoot, worldKey);
|
||||
Path normalizedCandidate = requiredCandidate.toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.equals(target.worldDirectory())) {
|
||||
throw new Rejection(
|
||||
RejectionReason.PATH_MISMATCH,
|
||||
"World candidate is not the exact expected dimension slot."
|
||||
);
|
||||
}
|
||||
requireDirectoryOrAbsent(normalizedCandidate, "world slot");
|
||||
return target;
|
||||
}
|
||||
|
||||
private static Path canonicalLevelRoot(Path levelRoot) {
|
||||
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
rejectTraversal(requiredLevelRoot, "Level root");
|
||||
Path normalizedLevelRoot = requiredLevelRoot.toAbsolutePath().normalize();
|
||||
if (normalizedLevelRoot.getParent() == null) {
|
||||
throw new Rejection(RejectionReason.UNSAFE_ENTRY, "A filesystem root cannot be a level root.");
|
||||
}
|
||||
requireDirectory(normalizedLevelRoot, "level root", true);
|
||||
try {
|
||||
return normalizedLevelRoot.toRealPath();
|
||||
} catch (IOException exception) {
|
||||
throw new Rejection(
|
||||
RejectionReason.UNSAFE_ENTRY,
|
||||
"Could not canonicalize the level root: " + normalizedLevelRoot,
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static SlotKind classify(NamespacedKey worldKey) {
|
||||
if ("iris".equals(worldKey.getNamespace())) {
|
||||
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
|
||||
throw new Rejection(
|
||||
RejectionReason.INVALID_IRIS_KEY,
|
||||
"Iris world keys must be safe single path segments."
|
||||
);
|
||||
}
|
||||
return SlotKind.IRIS_MANAGED;
|
||||
}
|
||||
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
|
||||
throw new Rejection(
|
||||
RejectionReason.FOREIGN_NAMESPACE,
|
||||
"Only Iris-managed and exact vanilla dimension slots can be replaced."
|
||||
);
|
||||
}
|
||||
return switch (worldKey.getKey()) {
|
||||
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
|
||||
case "the_nether" -> SlotKind.VANILLA_NETHER;
|
||||
case "the_end" -> SlotKind.VANILLA_END;
|
||||
default -> throw new Rejection(
|
||||
RejectionReason.UNSUPPORTED_MINECRAFT_SLOT,
|
||||
"Only minecraft:overworld, minecraft:the_nether, and minecraft:the_end can be replaced."
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private static void rejectTraversal(Path path, String label) {
|
||||
for (Path component : path) {
|
||||
if ("..".equals(component.toString())) {
|
||||
throw new Rejection(
|
||||
RejectionReason.PATH_TRAVERSAL,
|
||||
label + " contains path traversal."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDirectoryOrAbsent(Path path, String label) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new Rejection(
|
||||
RejectionReason.SYMBOLIC_LINK,
|
||||
"The " + label + " is a symbolic link: " + path
|
||||
);
|
||||
}
|
||||
requireDirectory(path, label, false);
|
||||
}
|
||||
|
||||
private static void requireDirectory(Path path, String label, boolean required) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new Rejection(
|
||||
RejectionReason.SYMBOLIC_LINK,
|
||||
"The " + label + " is a symbolic link: " + path
|
||||
);
|
||||
}
|
||||
BasicFileAttributes attributes;
|
||||
try {
|
||||
attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
} catch (NoSuchFileException exception) {
|
||||
if (!required) {
|
||||
return;
|
||||
}
|
||||
throw new Rejection(
|
||||
RejectionReason.MISSING_LEVEL_ROOT,
|
||||
"The level root does not exist: " + path,
|
||||
exception
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
throw new Rejection(
|
||||
RejectionReason.UNSAFE_ENTRY,
|
||||
"Could not inspect the " + label + ": " + path,
|
||||
exception
|
||||
);
|
||||
}
|
||||
if (!attributes.isDirectory()) {
|
||||
throw new Rejection(
|
||||
RejectionReason.UNSAFE_ENTRY,
|
||||
"The " + label + " is not a directory: " + path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record Target(
|
||||
NamespacedKey worldKey,
|
||||
SlotKind slotKind,
|
||||
Path levelRoot,
|
||||
Path namespaceRoot,
|
||||
Path worldDirectory
|
||||
) {
|
||||
public Target {
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(slotKind, "slotKind");
|
||||
levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
|
||||
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
|
||||
SlotKind expectedSlotKind = classify(worldKey);
|
||||
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
|
||||
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
|
||||
if (slotKind != expectedSlotKind
|
||||
|| !namespaceRoot.equals(expectedNamespaceRoot)
|
||||
|| !worldDirectory.equals(expectedWorldDirectory)) {
|
||||
throw new IllegalArgumentException("World slot paths do not form an exact dimension hierarchy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SlotKind {
|
||||
IRIS_MANAGED,
|
||||
VANILLA_OVERWORLD,
|
||||
VANILLA_NETHER,
|
||||
VANILLA_END
|
||||
}
|
||||
|
||||
public enum RejectionReason {
|
||||
INVALID_IRIS_KEY,
|
||||
FOREIGN_NAMESPACE,
|
||||
UNSUPPORTED_MINECRAFT_SLOT,
|
||||
MISSING_LEVEL_ROOT,
|
||||
SYMBOLIC_LINK,
|
||||
UNSAFE_ENTRY,
|
||||
PATH_TRAVERSAL,
|
||||
PATH_MISMATCH
|
||||
}
|
||||
|
||||
public static final class Rejection extends IllegalArgumentException {
|
||||
private final RejectionReason reason;
|
||||
|
||||
private Rejection(RejectionReason reason, String message) {
|
||||
super(message);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
private Rejection(RejectionReason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
public RejectionReason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,7 +642,7 @@ public final class DatapackIngestService {
|
||||
if (report.changed()) {
|
||||
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
|
||||
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
|
||||
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled.");
|
||||
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact.");
|
||||
if (!restart) {
|
||||
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
|
||||
}
|
||||
@@ -1491,7 +1491,8 @@ public final class DatapackIngestService {
|
||||
stagedDir, worldFolders, existing, stripOverrides, cacheDir.getParentFile());
|
||||
installs.add(execution);
|
||||
InstallResult installResult = execution.result();
|
||||
recordInstallResult(sender, report, existing, installResult, resolved.getVersionNumber());
|
||||
recordInstallResult(
|
||||
sender, report, stagedDir, worldFolders, existing, installResult, resolved.getVersionNumber());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1515,7 +1516,8 @@ public final class DatapackIngestService {
|
||||
installs.add(execution);
|
||||
InstallResult installResult = execution.result();
|
||||
manifest.put(updated);
|
||||
recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber());
|
||||
recordInstallResult(
|
||||
sender, report, stagedDir, worldFolders, updated, installResult, resolved.getVersionNumber());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1536,7 +1538,8 @@ public final class DatapackIngestService {
|
||||
InstallResult installResult = execution.result();
|
||||
writeOwnership(stagedDir, updated);
|
||||
manifest.put(updated);
|
||||
recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber());
|
||||
recordInstallResult(
|
||||
sender, report, stagedDir, worldFolders, updated, installResult, resolved.getVersionNumber());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1565,6 +1568,7 @@ public final class DatapackIngestService {
|
||||
}
|
||||
installs.add(execution);
|
||||
InstallResult installResult = execution.result();
|
||||
recordInstallMetadata(stagedDir, worldFolders, entry);
|
||||
manifest.put(entry);
|
||||
|
||||
report.updated.add(id + " (" + safe(resolved.getVersionNumber()) + ")");
|
||||
@@ -1793,8 +1797,16 @@ public final class DatapackIngestService {
|
||||
requireDirectoryIdentity(directory, "datapack install root");
|
||||
}
|
||||
|
||||
private static void recordInstallResult(VolmitSender sender, Report report, Entry entry, InstallResult result, String versionNumber) {
|
||||
forgetInstallMetadata(entry);
|
||||
static void recordInstallResult(
|
||||
VolmitSender sender,
|
||||
Report report,
|
||||
File stagedDir,
|
||||
KList<File> worldFolders,
|
||||
Entry entry,
|
||||
InstallResult result,
|
||||
String versionNumber
|
||||
) {
|
||||
recordInstallMetadata(stagedDir, worldFolders, entry);
|
||||
if (result.changed()) {
|
||||
report.updated.add(entry.id + " (" + safe(versionNumber) + ")");
|
||||
report.requiresRestart = true;
|
||||
|
||||
@@ -12,8 +12,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
@@ -55,6 +55,60 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
public static WorldGeneratorSnapshot snapshot(File configurationFile, String worldName) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
synchronized (MUTATION_LOCK) {
|
||||
return snapshot(load(configurationFile), requiredWorldName);
|
||||
}
|
||||
}
|
||||
|
||||
public static GeneratorReplacement replaceIfMatching(
|
||||
File configurationFile,
|
||||
String worldName,
|
||||
WorldGeneratorSnapshot expected,
|
||||
String dimension,
|
||||
Long seed
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
WorldGeneratorSnapshot requiredExpected = Objects.requireNonNull(expected, "expected");
|
||||
String requiredDimension = requireName(dimension, "Dimension");
|
||||
WorldGeneratorSnapshot replacement = WorldGeneratorSnapshot.configured(requiredDimension, seed);
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
WorldGeneratorSnapshot current = snapshot(configuration, requiredWorldName);
|
||||
if (!current.matchesGeneratorAndSeed(requiredExpected)) {
|
||||
return new GeneratorReplacement(false, current, replacement);
|
||||
}
|
||||
apply(configuration, requiredWorldName, replacement);
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return new GeneratorReplacement(true, current, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean restoreIfMatching(
|
||||
File configurationFile,
|
||||
String worldName,
|
||||
WorldGeneratorSnapshot expectedCurrent,
|
||||
WorldGeneratorSnapshot restoration
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
WorldGeneratorSnapshot requiredExpected = Objects.requireNonNull(expectedCurrent, "expectedCurrent");
|
||||
WorldGeneratorSnapshot requiredRestoration = Objects.requireNonNull(restoration, "restoration");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
WorldGeneratorSnapshot current = snapshot(configuration, requiredWorldName);
|
||||
if (!current.matchesGeneratorAndSeed(requiredExpected)) {
|
||||
return false;
|
||||
}
|
||||
apply(configuration, requiredWorldName, requiredRestoration);
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean remove(File configurationFile, String worldName) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
@@ -172,6 +226,97 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot snapshot(
|
||||
YamlConfiguration configuration,
|
||||
String worldName
|
||||
) throws IOException {
|
||||
Object rawWorlds = configuration.get("worlds");
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (rawWorlds != null && worlds == null) {
|
||||
throw new IOException("bukkit.yml worlds entry is not a section and was not changed.");
|
||||
}
|
||||
if (worlds == null) {
|
||||
return WorldGeneratorSnapshot.absent();
|
||||
}
|
||||
|
||||
Object rawWorld = worlds.get(worldName);
|
||||
ConfigurationSection world = worlds.getConfigurationSection(worldName);
|
||||
if (rawWorld != null && world == null) {
|
||||
throw new IOException("bukkit.yml world entry \"" + worldName + "\" is not a section and was not changed.");
|
||||
}
|
||||
if (world == null) {
|
||||
return WorldGeneratorSnapshot.absentWorld(true);
|
||||
}
|
||||
|
||||
boolean generatorPresent = world.getKeys(false).contains("generator");
|
||||
String generator = null;
|
||||
if (generatorPresent) {
|
||||
Object rawGenerator = world.get("generator");
|
||||
if (!(rawGenerator instanceof String generatorValue)) {
|
||||
throw new IOException("bukkit.yml generator for world \"" + worldName
|
||||
+ "\" is not a string and was not changed.");
|
||||
}
|
||||
generator = generatorValue;
|
||||
}
|
||||
|
||||
boolean seedPresent = world.getKeys(false).contains("seed");
|
||||
Long seed = null;
|
||||
if (seedPresent) {
|
||||
Object rawSeed = world.get("seed");
|
||||
if (!(rawSeed instanceof Byte
|
||||
|| rawSeed instanceof Short
|
||||
|| rawSeed instanceof Integer
|
||||
|| rawSeed instanceof Long)) {
|
||||
throw new IOException("bukkit.yml seed for world \"" + worldName
|
||||
+ "\" is not an integer and was not changed.");
|
||||
}
|
||||
seed = ((Number) rawSeed).longValue();
|
||||
}
|
||||
|
||||
return new WorldGeneratorSnapshot(
|
||||
true,
|
||||
true,
|
||||
generatorPresent,
|
||||
generator,
|
||||
seedPresent,
|
||||
seed
|
||||
);
|
||||
}
|
||||
|
||||
private static void apply(
|
||||
YamlConfiguration configuration,
|
||||
String worldName,
|
||||
WorldGeneratorSnapshot snapshot
|
||||
) throws IOException {
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
Object rawWorlds = configuration.get("worlds");
|
||||
if (rawWorlds != null) {
|
||||
throw new IOException("bukkit.yml worlds entry is not a section and was not changed.");
|
||||
}
|
||||
worlds = configuration.createSection("worlds");
|
||||
}
|
||||
|
||||
ConfigurationSection world = worlds.getConfigurationSection(worldName);
|
||||
if (world == null) {
|
||||
Object rawWorld = worlds.get(worldName);
|
||||
if (rawWorld != null) {
|
||||
throw new IOException("bukkit.yml world entry \"" + worldName
|
||||
+ "\" is not a section and was not changed.");
|
||||
}
|
||||
world = worlds.createSection(worldName);
|
||||
}
|
||||
|
||||
world.set("generator", snapshot.generatorPresent() ? snapshot.generator() : null);
|
||||
world.set("seed", snapshot.seedPresent() ? snapshot.seed() : null);
|
||||
if (!snapshot.worldSectionPresent() && world.getKeys(false).isEmpty()) {
|
||||
worlds.set(worldName, null);
|
||||
}
|
||||
if (!snapshot.worldsSectionPresent() && worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireWorldName(String value) {
|
||||
String worldName = requireName(value, "World name");
|
||||
if (!worldName.matches("[a-z0-9_-]+")) {
|
||||
@@ -191,4 +336,58 @@ public final class BukkitWorldConfiguration {
|
||||
CREATED,
|
||||
UNCHANGED
|
||||
}
|
||||
|
||||
public record WorldGeneratorSnapshot(
|
||||
boolean worldsSectionPresent,
|
||||
boolean worldSectionPresent,
|
||||
boolean generatorPresent,
|
||||
String generator,
|
||||
boolean seedPresent,
|
||||
Long seed
|
||||
) {
|
||||
public WorldGeneratorSnapshot {
|
||||
if (worldSectionPresent && !worldsSectionPresent) {
|
||||
throw new IllegalArgumentException("A world section requires a worlds section.");
|
||||
}
|
||||
if (!worldSectionPresent && (generatorPresent || seedPresent)) {
|
||||
throw new IllegalArgumentException("Generator and seed values require a world section.");
|
||||
}
|
||||
if (generatorPresent != (generator != null)) {
|
||||
throw new IllegalArgumentException("Generator presence and value must agree.");
|
||||
}
|
||||
if (seedPresent != (seed != null)) {
|
||||
throw new IllegalArgumentException("Seed presence and value must agree.");
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot absent() {
|
||||
return new WorldGeneratorSnapshot(false, false, false, null, false, null);
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot absentWorld(boolean worldsSectionPresent) {
|
||||
return new WorldGeneratorSnapshot(worldsSectionPresent, false, false, null, false, null);
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot configured(String dimension, Long seed) {
|
||||
return new WorldGeneratorSnapshot(true, true, true, "Iris:" + dimension, seed != null, seed);
|
||||
}
|
||||
|
||||
public boolean matchesGeneratorAndSeed(WorldGeneratorSnapshot other) {
|
||||
return generatorPresent == other.generatorPresent
|
||||
&& Objects.equals(generator, other.generator)
|
||||
&& seedPresent == other.seedPresent
|
||||
&& Objects.equals(seed, other.seed);
|
||||
}
|
||||
}
|
||||
|
||||
public record GeneratorReplacement(
|
||||
boolean applied,
|
||||
WorldGeneratorSnapshot observed,
|
||||
WorldGeneratorSnapshot replacement
|
||||
) {
|
||||
public GeneratorReplacement {
|
||||
Objects.requireNonNull(observed, "observed");
|
||||
Objects.requireNonNull(replacement, "replacement");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ public final class LifecycleOperationCoordinator {
|
||||
WORLD_LOAD,
|
||||
WORLD_UNLOAD,
|
||||
WORLD_REMOVE,
|
||||
WORLD_REPLACE,
|
||||
WORLD_PROMOTE,
|
||||
STUDIO_OPEN,
|
||||
STUDIO_CLOSE,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
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.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class WorldReplacementFilesystem {
|
||||
private static final Pattern STAGE_NAME = Pattern.compile(
|
||||
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
|
||||
private static final Pattern BACKUP_NAME = Pattern.compile(
|
||||
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.backup$");
|
||||
|
||||
private WorldReplacementFilesystem() {
|
||||
}
|
||||
|
||||
public static void publish(
|
||||
ReplacementPaths paths,
|
||||
boolean originalTargetPresent,
|
||||
String expectedPackFingerprint
|
||||
) throws IOException {
|
||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||
String expectedFingerprint = requireFingerprint(expectedPackFingerprint);
|
||||
State state = inspect(requiredPaths);
|
||||
if (state.stagePresent()) {
|
||||
requireSafeTree(requiredPaths.stage(), "replacement stage");
|
||||
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
|
||||
if (state.targetPresent()) {
|
||||
if (state.backupPresent()) {
|
||||
throw new IOException("Replacement target, stage, and backup are all present.");
|
||||
}
|
||||
if (!originalTargetPresent) {
|
||||
throw new IOException("Replacement target appeared after an absent target was staged.");
|
||||
}
|
||||
move(requiredPaths.target(), requiredPaths.backup());
|
||||
state = inspect(requiredPaths);
|
||||
}
|
||||
if (state.targetPresent()) {
|
||||
throw new IOException("Replacement target is still present after backup publication.");
|
||||
}
|
||||
if (originalTargetPresent != state.backupPresent()) {
|
||||
throw new IOException("Replacement backup state does not match the original target state.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.stage(), "replacement stage");
|
||||
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
|
||||
move(requiredPaths.stage(), requiredPaths.target());
|
||||
state = inspect(requiredPaths);
|
||||
}
|
||||
|
||||
if (!state.targetPresent() || state.stagePresent()) {
|
||||
throw new IOException("Replacement publication did not produce one exact target directory.");
|
||||
}
|
||||
if (originalTargetPresent != state.backupPresent()) {
|
||||
throw new IOException("Replacement publication lost or unexpectedly created its backup.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
}
|
||||
|
||||
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
|
||||
prepareRollback(paths, originalTargetPresent);
|
||||
discardStage(paths);
|
||||
}
|
||||
|
||||
public static void prepareRollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
|
||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||
State state = inspect(requiredPaths);
|
||||
if (originalTargetPresent) {
|
||||
if (state.backupPresent()) {
|
||||
if (state.targetPresent()) {
|
||||
if (state.stagePresent()) {
|
||||
throw new IOException("Rollback cannot quarantine two replacement directories.");
|
||||
}
|
||||
move(requiredPaths.target(), requiredPaths.stage());
|
||||
}
|
||||
move(requiredPaths.backup(), requiredPaths.target());
|
||||
state = inspect(requiredPaths);
|
||||
}
|
||||
if (!state.targetPresent() || state.backupPresent()) {
|
||||
throw new IOException("Rollback could not restore the original world target.");
|
||||
}
|
||||
} else {
|
||||
if (state.backupPresent()) {
|
||||
throw new IOException("An originally absent world acquired an unexpected backup.");
|
||||
}
|
||||
if (state.targetPresent()) {
|
||||
if (state.stagePresent()) {
|
||||
throw new IOException("Rollback cannot quarantine two replacement directories.");
|
||||
}
|
||||
move(requiredPaths.target(), requiredPaths.stage());
|
||||
state = inspect(requiredPaths);
|
||||
}
|
||||
if (state.targetPresent()) {
|
||||
throw new IOException("Rollback could not remove the replacement target.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void discardStage(ReplacementPaths paths) throws IOException {
|
||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||
State state = inspect(requiredPaths);
|
||||
if (state.backupPresent()) {
|
||||
throw new IOException("Cannot discard a replacement stage after a backup was published.");
|
||||
}
|
||||
if (state.stagePresent()) {
|
||||
SnapshotDirectoryTreeDeleter.delete(requiredPaths.stage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void cleanupBackup(ReplacementPaths paths) throws IOException {
|
||||
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
|
||||
State state = inspect(requiredPaths);
|
||||
if (!state.targetPresent() || state.stagePresent()) {
|
||||
throw new IOException("Cannot clean a replacement backup before publication is complete.");
|
||||
}
|
||||
if (state.backupPresent()) {
|
||||
SnapshotDirectoryTreeDeleter.delete(requiredPaths.backup());
|
||||
}
|
||||
}
|
||||
|
||||
public static String fingerprintPack(Path packRoot) throws IOException {
|
||||
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
||||
requireDirectory(root, "pack root");
|
||||
MessageDigest digest = sha256();
|
||||
List<Path> files;
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
files = stream
|
||||
.filter(path -> !path.equals(root))
|
||||
.filter(path -> !containsMetadataSegment(root.relativize(path)))
|
||||
.sorted(Comparator.comparing(path -> root.relativize(path).toString()))
|
||||
.toList();
|
||||
}
|
||||
for (Path file : files) {
|
||||
BasicFileAttributes attributes = requireSafeEntry(file);
|
||||
Path relative = root.relativize(file);
|
||||
update(digest, relative.toString().replace(file.getFileSystem().getSeparator(), "/"));
|
||||
digest.update((byte) (attributes.isDirectory() ? 1 : 0));
|
||||
if (!attributes.isRegularFile()) {
|
||||
continue;
|
||||
}
|
||||
update(digest, Long.toString(attributes.size()));
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static State inspect(ReplacementPaths paths) throws IOException {
|
||||
return new State(
|
||||
directoryPresent(paths.target(), "replacement target"),
|
||||
directoryPresent(paths.stage(), "replacement stage"),
|
||||
directoryPresent(paths.backup(), "replacement backup")
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean directoryPresent(Path path, String label) throws IOException {
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
requireDirectory(path, label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void requireFingerprint(Path packRoot, String expected) throws IOException {
|
||||
String actual = fingerprintPack(packRoot);
|
||||
if (!actual.equals(expected)) {
|
||||
throw new IOException("Staged world pack fingerprint changed before publication.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFingerprint(String value) {
|
||||
String fingerprint = Objects.requireNonNull(value, "expectedPackFingerprint");
|
||||
if (!fingerprint.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Pack fingerprint must be lowercase SHA-256.");
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
private static boolean containsMetadataSegment(Path relative) {
|
||||
for (Path component : relative) {
|
||||
if (".iris".equals(component.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
path,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (attributes.isSymbolicLink()) {
|
||||
throw new IOException("Replacement storage contains a symbolic link: " + path);
|
||||
}
|
||||
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
|
||||
throw new IOException("Replacement storage contains an unsafe entry: " + path);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static void requireDirectory(Path path, String label) throws IOException {
|
||||
BasicFileAttributes attributes = requireSafeEntry(path);
|
||||
if (!attributes.isDirectory()) {
|
||||
throw new IOException("The " + label + " is not a directory: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSafeTree(Path root, String label) throws IOException {
|
||||
requireDirectory(root, label);
|
||||
List<Path> entries;
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
entries = stream.sorted().toList();
|
||||
}
|
||||
for (Path entry : entries) {
|
||||
requireSafeEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void move(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(source, target);
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReplacementPaths(Path target, Path stage, Path backup) {
|
||||
public ReplacementPaths {
|
||||
target = normalize(target, "target");
|
||||
stage = normalize(stage, "stage");
|
||||
backup = normalize(backup, "backup");
|
||||
Path parent = target.getParent();
|
||||
if (parent == null || !parent.equals(stage.getParent()) || !parent.equals(backup.getParent())) {
|
||||
throw new IllegalArgumentException("Replacement paths must have one exact parent.");
|
||||
}
|
||||
if (target.equals(stage) || target.equals(backup) || stage.equals(backup)) {
|
||||
throw new IllegalArgumentException("Replacement paths must be distinct.");
|
||||
}
|
||||
if (!STAGE_NAME.matcher(stage.getFileName().toString()).matches()
|
||||
|| !BACKUP_NAME.matcher(backup.getFileName().toString()).matches()) {
|
||||
throw new IllegalArgumentException("Replacement artifact names are invalid.");
|
||||
}
|
||||
String stageStem = stage.getFileName().toString().replaceFirst("\\.stage$", "");
|
||||
String backupStem = backup.getFileName().toString().replaceFirst("\\.backup$", "");
|
||||
if (!stageStem.equals(backupStem)) {
|
||||
throw new IllegalArgumentException("Replacement stage and backup do not belong to one transaction.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Path normalize(Path path, String label) {
|
||||
Path required = Objects.requireNonNull(path, label).toAbsolutePath();
|
||||
for (Path component : required) {
|
||||
if ("..".equals(component.toString())) {
|
||||
throw new IllegalArgumentException("Replacement " + label + " contains path traversal.");
|
||||
}
|
||||
}
|
||||
return required.normalize();
|
||||
}
|
||||
}
|
||||
|
||||
private record State(boolean targetPresent, boolean stagePresent, boolean backupPresent) {
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,10 @@ public final class DirectorCommandMessages {
|
||||
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world",
|
||||
"Whether or not to automatically use this world as the main world"
|
||||
);
|
||||
public static final TextKey COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART = TextKey.of(
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
|
||||
"Replace the exact existing world slot on the next restart"
|
||||
);
|
||||
public static final TextKey COMMAND_IRIS_DIRECTOR_TELEPORT_ANOTHER_WORLD = TextKey.of(
|
||||
"iris.director.commandiris.director.teleport_another_world",
|
||||
"Teleport to another world"
|
||||
@@ -951,6 +955,7 @@ public final class DirectorCommandMessages {
|
||||
COMMAND_IRIS_PARAM_DIMENSION_PACK_CREATE_WORLD_WITH,
|
||||
COMMAND_IRIS_PARAM_SEED_GENERATE_WORLD_WITH,
|
||||
COMMAND_IRIS_PARAM_WHETHER_NOT_AUTOMATICALLY_USE_THIS_WORLD_AS_MAIN_WORLD,
|
||||
COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART,
|
||||
COMMAND_IRIS_DIRECTOR_TELEPORT_ANOTHER_WORLD,
|
||||
COMMAND_IRIS_PARAM_WORLD_TELEPORT,
|
||||
COMMAND_IRIS_PARAM_PLAYER_TELEPORT,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.core.nms;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
@@ -231,7 +232,8 @@ public interface INMSBinding {
|
||||
DatapackStructureScopeResult scopeDatapackStructures(
|
||||
World world,
|
||||
DatapackStructureScopeIndex scopeIndex,
|
||||
Set<String> declaredSources
|
||||
Set<String> declaredSources,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) throws NoSuchFieldException, IllegalAccessException;
|
||||
|
||||
void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException;
|
||||
|
||||
@@ -35,6 +35,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
"sound": "minecraft:music.game"
|
||||
}
|
||||
},
|
||||
"minecraft:visual/ambient_light_color": "#0a0a0a",
|
||||
"minecraft:visual/cloud_color": "#ccffffff",
|
||||
"minecraft:visual/fog_color": "#c0d8ff",
|
||||
"minecraft:visual/sky_color": "#78a7ff"
|
||||
@@ -49,6 +50,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
"attributes": {
|
||||
"minecraft:gameplay/sky_light_level": 4.0,
|
||||
"minecraft:gameplay/snow_golem_melts": true,
|
||||
"minecraft:visual/ambient_light_color": "#302821",
|
||||
"minecraft:visual/fog_end_distance": 96.0,
|
||||
"minecraft:visual/fog_start_distance": 10.0,
|
||||
"minecraft:visual/sky_light_color": "#7a7aff",
|
||||
@@ -81,6 +83,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
"sound": "minecraft:music.end"
|
||||
}
|
||||
},
|
||||
"minecraft:visual/ambient_light_color": "#3f473f",
|
||||
"minecraft:visual/fog_color": "#181318",
|
||||
"minecraft:visual/sky_color": "#000000",
|
||||
"minecraft:visual/sky_light_color": "#e580ff",
|
||||
@@ -93,7 +96,28 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
|
||||
@Override
|
||||
public JSONObject fixCustomBiome(IrisBiomeCustom biome, JSONObject json) {
|
||||
return super.fixCustomBiome(biome, json);
|
||||
JSONObject fixed = super.fixCustomBiome(biome, json);
|
||||
JSONObject effects = fixed.getJSONObject("effects");
|
||||
JSONObject attributes = fixed.optJSONObject("attributes");
|
||||
if (attributes == null) {
|
||||
attributes = new JSONObject();
|
||||
fixed.put("attributes", attributes);
|
||||
}
|
||||
|
||||
moveAttribute(effects, attributes, "sky_color", "minecraft:visual/sky_color");
|
||||
moveAttribute(effects, attributes, "fog_color", "minecraft:visual/fog_color");
|
||||
moveAttribute(effects, attributes, "water_fog_color", "minecraft:visual/water_fog_color");
|
||||
|
||||
JSONObject particle = effects.optJSONObject("particle");
|
||||
if (particle != null) {
|
||||
JSONObject ambientParticle = new JSONObject();
|
||||
ambientParticle.put("particle", particle.remove("options"));
|
||||
ambientParticle.put("probability", particle.remove("probability"));
|
||||
attributes.put("minecraft:visual/ambient_particles", new JSONArray().put(ambientParticle));
|
||||
effects.remove("particle");
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -144,6 +168,18 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
json.remove("effects");
|
||||
JSONObject defaults = new JSONObject(DIMENSIONS.get(dimension));
|
||||
merge(json, defaults);
|
||||
|
||||
Object ambientLight = json.opt("ambient_light");
|
||||
if (ambientLight instanceof Number number && number.doubleValue() >= 1D) {
|
||||
json.getJSONObject("attributes").put("minecraft:visual/ambient_light_color", "#ffffff");
|
||||
}
|
||||
}
|
||||
|
||||
private void moveAttribute(JSONObject source, JSONObject target, String sourceKey, String targetKey) {
|
||||
Object value = source.remove(sourceKey);
|
||||
if (value != null) {
|
||||
target.put(targetKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
private void merge(JSONObject base, JSONObject override) {
|
||||
|
||||
@@ -20,6 +20,7 @@ package art.arcane.iris.core.nms.v1X;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.core.nms.container.BiomeColor;
|
||||
@@ -109,7 +110,8 @@ public class NMSBinding1X implements INMSBinding {
|
||||
public DatapackStructureScopeResult scopeDatapackStructures(
|
||||
World world,
|
||||
DatapackStructureScopeIndex scopeIndex,
|
||||
Set<String> declaredSources
|
||||
Set<String> declaredSources,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) {
|
||||
throw new IllegalStateException("Iris-managed datapack structure isolation requires the supported NMS binding");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 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.pack;
|
||||
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
final class PackCaveProfileValidator {
|
||||
private static final String CAVE_PROFILE_SNIPPET_FOLDER = "snippet/cave-profile";
|
||||
private static final List<LegacyField> LEGACY_FIELDS = List.of(
|
||||
new LegacyField("allowWater", "allowFluid"),
|
||||
new LegacyField("waterMinDepthBelowSurface", "fluidMinDepthBelowSurface"),
|
||||
new LegacyField("waterRequiresFloor", "fluidRequiresFloor")
|
||||
);
|
||||
|
||||
private PackCaveProfileValidator() {
|
||||
}
|
||||
|
||||
static List<String> validateLegacyFields(File packFolder) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
|
||||
File resourceFolder = new File(packFolder, folderName);
|
||||
if (!resourceFolder.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
List<File> resourceFiles = PackValidationIo.listJsonRecursive(resourceFolder);
|
||||
resourceFiles.sort(Comparator.comparing(File::getPath));
|
||||
String resourceType = PackStructurePlacementValidator.structureHostType(folderName);
|
||||
for (File resourceFile : resourceFiles) {
|
||||
JSONObject resource = PackValidationIo.readJson(resourceFile);
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject caveProfile = resource.optJSONObject("caveProfile");
|
||||
if (caveProfile == null) {
|
||||
continue;
|
||||
}
|
||||
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
|
||||
validateFields(resourceType + " '" + resourceKey + "' caveProfile.", "caveProfile.", caveProfile,
|
||||
blockingErrors);
|
||||
}
|
||||
}
|
||||
|
||||
File snippetFolder = new File(packFolder, CAVE_PROFILE_SNIPPET_FOLDER);
|
||||
if (!snippetFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
}
|
||||
List<File> snippetFiles = PackValidationIo.listJsonRecursive(snippetFolder);
|
||||
snippetFiles.sort(Comparator.comparing(File::getPath));
|
||||
for (File snippetFile : snippetFiles) {
|
||||
JSONObject caveProfile = PackValidationIo.readJson(snippetFile);
|
||||
if (caveProfile == null) {
|
||||
continue;
|
||||
}
|
||||
String snippetKey = PackValidationIo.deriveKey(snippetFolder, snippetFile);
|
||||
validateFields("Cave-profile snippet '" + snippetKey + "' ", "", caveProfile, blockingErrors);
|
||||
}
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
private static void validateFields(String location, String replacementPrefix, JSONObject caveProfile,
|
||||
List<String> blockingErrors) {
|
||||
for (LegacyField field : LEGACY_FIELDS) {
|
||||
if (caveProfile.has(field.oldName())) {
|
||||
blockingErrors.add(location + field.oldName() + " was removed; use " + replacementPrefix
|
||||
+ field.newName()
|
||||
+ ". Cave aquifers use the dimension fluidPalette, which defaults to water.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record LegacyField(String oldName, String newName) {
|
||||
}
|
||||
}
|
||||
@@ -105,13 +105,15 @@ final class PackDimensionValidator {
|
||||
}
|
||||
if (policy.has("mode")) {
|
||||
blockingErrors.add("Dimension '" + dimensionKey
|
||||
+ "' importedStructures.mode is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled.");
|
||||
+ "' importedStructures.mode is not supported. Native structures are enabled by default; deny families in importedStructures.disabled or complete keys in importedStructures.disabledExact.");
|
||||
}
|
||||
if (policy.has("enabled")) {
|
||||
blockingErrors.add("Dimension '" + dimensionKey
|
||||
+ "' importedStructures.enabled is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled.");
|
||||
+ "' importedStructures.enabled is not supported. Native structures are enabled by default; deny families in importedStructures.disabled or complete keys in importedStructures.disabledExact.");
|
||||
}
|
||||
validateStructureKeyList(dimensionKey, policy, "disabled", blockingErrors);
|
||||
validateStructureKeyList(dimensionKey, policy, "disabledExact", blockingErrors);
|
||||
validateFrequencyOverrides(dimensionKey, policy, blockingErrors);
|
||||
JSONArray adjustments = policy.optJSONArray("adjustments");
|
||||
if (adjustments == null) {
|
||||
if (policy.has("adjustments")) {
|
||||
@@ -138,6 +140,36 @@ final class PackDimensionValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateFrequencyOverrides(String dimensionKey, JSONObject policy,
|
||||
List<String> blockingErrors) {
|
||||
if (!policy.has("frequencyOverrides")) {
|
||||
return;
|
||||
}
|
||||
JSONArray overrides = policy.optJSONArray("frequencyOverrides");
|
||||
if (overrides == null) {
|
||||
blockingErrors.add("Dimension '" + dimensionKey
|
||||
+ "' importedStructures.frequencyOverrides must be an array.");
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < overrides.length(); index++) {
|
||||
JSONObject override = overrides.optJSONObject(index);
|
||||
String path = "Dimension '" + dimensionKey
|
||||
+ "' importedStructures.frequencyOverrides[" + index + "]";
|
||||
if (override == null) {
|
||||
blockingErrors.add(path + " must be an object.");
|
||||
continue;
|
||||
}
|
||||
Object rawKey = override.opt("structureSet");
|
||||
if (!(rawKey instanceof String key)
|
||||
|| key.isBlank()
|
||||
|| !PackValidator.RESOURCE_KEY_PATTERN.matcher(key.trim()).matches()) {
|
||||
blockingErrors.add(path + ".structureSet must be a namespaced registry key.");
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalDoubleRange(
|
||||
path, override, "multiplier", 0.01D, 16D, blockingErrors);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAdjustmentYBand(String dimensionKey, JSONObject adjustment, int index,
|
||||
List<String> blockingErrors) {
|
||||
if (!adjustment.has("yBand") || adjustment.opt("yBand") == JSONObject.NULL) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class PackValidationCache {
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
private static final int SCHEMA_VERSION = 2;
|
||||
private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L;
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -25,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class PackValidationRegistry {
|
||||
private static final Map<String, PackValidationResult> RESULTS = new ConcurrentHashMap<>();
|
||||
private static final Map<Path, PackValidationResult> ROOT_RESULTS = new ConcurrentHashMap<>();
|
||||
|
||||
private PackValidationRegistry() {
|
||||
}
|
||||
@@ -36,6 +40,13 @@ public final class PackValidationRegistry {
|
||||
RESULTS.put(result.getPackName(), result);
|
||||
}
|
||||
|
||||
public static void publish(Path packRoot, PackValidationResult result) {
|
||||
if (packRoot == null || result == null) {
|
||||
return;
|
||||
}
|
||||
ROOT_RESULTS.put(normalize(packRoot), result);
|
||||
}
|
||||
|
||||
public static PackValidationResult get(String packName) {
|
||||
if (packName == null || packName.isBlank()) {
|
||||
return null;
|
||||
@@ -43,6 +54,13 @@ public final class PackValidationRegistry {
|
||||
return RESULTS.get(packName);
|
||||
}
|
||||
|
||||
public static PackValidationResult get(Path packRoot) {
|
||||
if (packRoot == null) {
|
||||
return null;
|
||||
}
|
||||
return ROOT_RESULTS.get(normalize(packRoot));
|
||||
}
|
||||
|
||||
public static PackValidationResult requireLoadable(String packName) {
|
||||
if (packName == null || packName.isBlank()) {
|
||||
throw new IllegalArgumentException("Pack name is required for validation");
|
||||
@@ -58,11 +76,32 @@ public final class PackValidationRegistry {
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PackValidationResult requireLoadable(Path packRoot) {
|
||||
if (packRoot == null) {
|
||||
throw new IllegalArgumentException("Pack root is required for validation");
|
||||
}
|
||||
Path normalizedRoot = normalize(packRoot);
|
||||
PackValidationResult result = get(normalizedRoot);
|
||||
if (result == null) {
|
||||
throw new BrokenPackException(normalizedRoot.toString(), List.of(
|
||||
"Required pack validation has not completed. World creation fails closed until validation succeeds."));
|
||||
}
|
||||
if (!result.isLoadable()) {
|
||||
throw new BrokenPackException(normalizedRoot.toString(), result.getBlockingErrors());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static boolean isBroken(String packName) {
|
||||
PackValidationResult result = get(packName);
|
||||
return result != null && !result.isLoadable();
|
||||
}
|
||||
|
||||
public static boolean isBroken(Path packRoot) {
|
||||
PackValidationResult result = get(packRoot);
|
||||
return result != null && !result.isLoadable();
|
||||
}
|
||||
|
||||
public static Map<String, PackValidationResult> snapshot() {
|
||||
return Collections.unmodifiableMap(RESULTS);
|
||||
}
|
||||
@@ -74,7 +113,26 @@ public final class PackValidationRegistry {
|
||||
RESULTS.remove(packName);
|
||||
}
|
||||
|
||||
public static void remove(Path packRoot) {
|
||||
if (packRoot == null) {
|
||||
return;
|
||||
}
|
||||
ROOT_RESULTS.remove(normalize(packRoot));
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
RESULTS.clear();
|
||||
ROOT_RESULTS.clear();
|
||||
}
|
||||
|
||||
private static Path normalize(Path packRoot) {
|
||||
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
|
||||
try {
|
||||
return normalizedRoot.toRealPath();
|
||||
} catch (NoSuchFileException exception) {
|
||||
return normalizedRoot;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("Unable to resolve Iris pack root: " + normalizedRoot, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public final class PackValidator {
|
||||
}
|
||||
|
||||
PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
|
||||
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
|
||||
blockingErrors.addAll(PackLootValidator.validateLootGraph(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
|
||||
|
||||
@@ -197,7 +197,11 @@ public class SchemaBuilder {
|
||||
}
|
||||
|
||||
private JSONArray vanillaStructureSets() {
|
||||
return keysAsArray(IrisPlatforms.get().structureHooks().structureSetKeys());
|
||||
if (IrisPlatforms.get().structureHooks() == null) {
|
||||
return new JSONArray();
|
||||
}
|
||||
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
|
||||
return keysAsArray(keys == null ? List.of() : keys);
|
||||
}
|
||||
|
||||
private JSONArray nativeJigsawPools() {
|
||||
|
||||
@@ -224,6 +224,7 @@ public class StudioSVC implements IrisService {
|
||||
}
|
||||
publication = AtomicDirectoryPublisher.publish(stage, target);
|
||||
stage = null;
|
||||
validatePublishedPack(target);
|
||||
|
||||
IrisData installedData;
|
||||
boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty();
|
||||
@@ -256,6 +257,9 @@ public class StudioSVC implements IrisService {
|
||||
return installedDimension;
|
||||
} catch (Throwable e) {
|
||||
rollbackFailedPublication(createdData, publication, e);
|
||||
if (publication != null) {
|
||||
invalidatePackValidation(target);
|
||||
}
|
||||
if (refreshedPreviousData) {
|
||||
try {
|
||||
previousData.hotloaded();
|
||||
@@ -277,6 +281,17 @@ public class StudioSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
static void invalidatePackValidation(Path packRoot) {
|
||||
PackValidationRegistry.remove(packRoot);
|
||||
}
|
||||
|
||||
static PackValidationResult validatePublishedPack(Path packRoot) {
|
||||
invalidatePackValidation(packRoot);
|
||||
PackValidationResult result = PackValidator.validate(packRoot.toFile());
|
||||
PackValidationRegistry.publish(packRoot, result);
|
||||
return PackValidationRegistry.requireLoadable(packRoot);
|
||||
}
|
||||
|
||||
static void rollbackFailedPublication(
|
||||
IrisData createdData,
|
||||
AtomicDirectoryPublisher.Publication publication,
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
public record NativeStructureFrequencyScale(float frequency, int spacing) {
|
||||
public static NativeStructureFrequencyScale randomSpread(
|
||||
float frequency,
|
||||
int spacing,
|
||||
int separation,
|
||||
double multiplier
|
||||
) {
|
||||
if (!Float.isFinite(frequency) || frequency < 0F || frequency > 1F) {
|
||||
throw new IllegalArgumentException("Native structure frequency must be between 0 and 1");
|
||||
}
|
||||
if (spacing <= separation) {
|
||||
throw new IllegalArgumentException("Native structure spacing must exceed separation");
|
||||
}
|
||||
requireMultiplier(multiplier);
|
||||
if (frequency == 0F || multiplier == 1D) {
|
||||
return new NativeStructureFrequencyScale(frequency, spacing);
|
||||
}
|
||||
|
||||
double requestedFrequency = frequency * multiplier;
|
||||
float scaledFrequency = (float) Math.min(1D, requestedFrequency);
|
||||
double remainingDensity = requestedFrequency / scaledFrequency;
|
||||
int scaledSpacing = spacing;
|
||||
if (remainingDensity > 1D) {
|
||||
int requestedSpacing = (int) Math.round(spacing / Math.sqrt(remainingDensity));
|
||||
scaledSpacing = Math.max(separation + 1, requestedSpacing);
|
||||
}
|
||||
return new NativeStructureFrequencyScale(scaledFrequency, scaledSpacing);
|
||||
}
|
||||
|
||||
public static float probability(float frequency, double multiplier) {
|
||||
if (!Float.isFinite(frequency) || frequency < 0F || frequency > 1F) {
|
||||
throw new IllegalArgumentException("Native structure frequency must be between 0 and 1");
|
||||
}
|
||||
requireMultiplier(multiplier);
|
||||
return (float) Math.min(1D, frequency * multiplier);
|
||||
}
|
||||
|
||||
private static void requireMultiplier(double multiplier) {
|
||||
if (!Double.isFinite(multiplier) || multiplier < 0.01D || multiplier > 16D) {
|
||||
throw new IllegalArgumentException("Native structure frequency multiplier must be between 0.01 and 16");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
|
||||
final class CaveCarveScratch {
|
||||
final int[] columnMaxY = new int[256];
|
||||
final int[] waterMaxY = new int[256];
|
||||
final int[] fluidMaxY = new int[256];
|
||||
final int[] surfaceBreakFloorY = new int[256];
|
||||
final boolean[] surfaceBreakColumn = new boolean[256];
|
||||
final double[] columnThreshold = new double[256];
|
||||
|
||||
+11
-11
@@ -28,11 +28,11 @@ import java.util.BitSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class CaveWaterSupportPlan {
|
||||
private final IdentityHashMap<MatterCavern, WaterCandidateGroup> groups = new IdentityHashMap<>();
|
||||
final class CaveFluidSupportPlan {
|
||||
private final IdentityHashMap<MatterCavern, FluidCandidateGroup> groups = new IdentityHashMap<>();
|
||||
|
||||
void add(int localX, int y, int localZ, MatterCavern water, MatterCavern air) {
|
||||
WaterCandidateGroup group = groups.computeIfAbsent(water, key -> new WaterCandidateGroup(water, air));
|
||||
void add(int localX, int y, int localZ, MatterCavern fluid, MatterCavern air) {
|
||||
FluidCandidateGroup group = groups.computeIfAbsent(fluid, key -> new FluidCandidateGroup(fluid, air));
|
||||
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
|
||||
group.positions.set((y << 8) | columnIndex);
|
||||
}
|
||||
@@ -43,15 +43,15 @@ final class CaveWaterSupportPlan {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Map.Entry<MatterCavern, WaterCandidateGroup> entry : groups.entrySet()) {
|
||||
WaterCandidateGroup group = entry.getValue();
|
||||
for (Map.Entry<MatterCavern, FluidCandidateGroup> entry : groups.entrySet()) {
|
||||
FluidCandidateGroup group = entry.getValue();
|
||||
for (int position = group.positions.nextSetBit(0); position >= 0; position = group.positions.nextSetBit(position + 1)) {
|
||||
int y = position >>> 8;
|
||||
int columnIndex = position & 255;
|
||||
int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex);
|
||||
int localZ = columnIndex & 15;
|
||||
MatterCavern current = getCavern(chunk, localX, y, localZ);
|
||||
if (current != group.water || hasCupSupport(chunk, localX, y, localZ)) {
|
||||
if (current != group.fluid || hasCupSupport(chunk, localX, y, localZ)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -107,13 +107,13 @@ final class CaveWaterSupportPlan {
|
||||
return cavernSlice == null ? null : cavernSlice.get(localX, y & 15, localZ);
|
||||
}
|
||||
|
||||
private static final class WaterCandidateGroup {
|
||||
private final MatterCavern water;
|
||||
private static final class FluidCandidateGroup {
|
||||
private final MatterCavern fluid;
|
||||
private final MatterCavern air;
|
||||
private final BitSet positions = new BitSet();
|
||||
|
||||
private WaterCandidateGroup(MatterCavern water, MatterCavern air) {
|
||||
this.water = water;
|
||||
private FluidCandidateGroup(MatterCavern fluid, MatterCavern air) {
|
||||
this.fluid = fluid;
|
||||
this.air = air;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import java.util.List;
|
||||
|
||||
public class IrisCaveCarver3D {
|
||||
private static final byte LIQUID_AIR = 0;
|
||||
private static final byte LIQUID_WATER = 1;
|
||||
private static final byte LIQUID_FLUID = 1;
|
||||
private static final byte LIQUID_LAVA = 2;
|
||||
private static final byte LIQUID_FORCED_AIR = 3;
|
||||
private static final int ADAPTIVE_MIN_PLANE_COLUMNS = 16;
|
||||
@@ -60,7 +60,7 @@ public class IrisCaveCarver3D {
|
||||
private final CaveFieldModuleState[] modules;
|
||||
private final double inverseNormalization;
|
||||
private final MatterCavern carveAir;
|
||||
private final MatterCavern carveWater;
|
||||
private final MatterCavern carveFluid;
|
||||
private final MatterCavern carveLava;
|
||||
private final MatterCavern carveForcedAir;
|
||||
private final double normalizationFactor;
|
||||
@@ -72,9 +72,9 @@ public class IrisCaveCarver3D {
|
||||
private final boolean hasWarp;
|
||||
private final boolean hasModules;
|
||||
private final int warpResolution;
|
||||
private final boolean allowWater;
|
||||
private final boolean waterRequiresFloor;
|
||||
private final int waterMinDepthBelowSurface;
|
||||
private final boolean allowFluid;
|
||||
private final boolean fluidRequiresFloor;
|
||||
private final int fluidMinDepthBelowSurface;
|
||||
private final int fluidHeight;
|
||||
private final int aquiferCeilingY;
|
||||
private final ThreadLocal<CaveCarveScratch> scratchCache = ThreadLocal.withInitial(CaveCarveScratch::new);
|
||||
@@ -84,7 +84,7 @@ public class IrisCaveCarver3D {
|
||||
this.data = engine.getData();
|
||||
this.profile = profile;
|
||||
this.carveAir = new MatterCavern(true, "", LIQUID_AIR);
|
||||
this.carveWater = new MatterCavern(true, "", LIQUID_WATER);
|
||||
this.carveFluid = new MatterCavern(true, "", LIQUID_FLUID);
|
||||
this.carveLava = new MatterCavern(true, "", LIQUID_LAVA);
|
||||
this.carveForcedAir = new MatterCavern(true, "", LIQUID_FORCED_AIR);
|
||||
List<CaveFieldModuleState> moduleStates = new ArrayList<>();
|
||||
@@ -100,9 +100,9 @@ public class IrisCaveCarver3D {
|
||||
this.warpStrength = profile.getWarpStrength();
|
||||
this.hasWarp = this.warpStrength > 0D;
|
||||
this.warpResolution = 2;
|
||||
this.allowWater = profile.isAllowWater();
|
||||
this.waterRequiresFloor = profile.isWaterRequiresFloor();
|
||||
this.waterMinDepthBelowSurface = Math.max(0, profile.getWaterMinDepthBelowSurface());
|
||||
this.allowFluid = profile.isAllowFluid();
|
||||
this.fluidRequiresFloor = profile.isFluidRequiresFloor();
|
||||
this.fluidMinDepthBelowSurface = Math.max(0, profile.getFluidMinDepthBelowSurface());
|
||||
this.fluidHeight = engine.getDimension().getFluidHeight();
|
||||
this.aquiferCeilingY = engine.getHeight() - 1;
|
||||
|
||||
@@ -181,10 +181,10 @@ public class IrisCaveCarver3D {
|
||||
int[] precomputedSurfaceHeights,
|
||||
IrisRange overrideVerticalRange
|
||||
) {
|
||||
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
|
||||
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
|
||||
int carved = carve(writer, chunkX, chunkZ, columnWeights, minWeight, thresholdPenalty,
|
||||
worldYRange, precomputedSurfaceHeights, overrideVerticalRange, waterSupportPlan);
|
||||
waterSupportPlan.resolve(writer.acquireChunk(chunkX, chunkZ));
|
||||
worldYRange, precomputedSurfaceHeights, overrideVerticalRange, fluidSupportPlan);
|
||||
fluidSupportPlan.resolve(writer.acquireChunk(chunkX, chunkZ));
|
||||
return carved;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ public class IrisCaveCarver3D {
|
||||
IrisRange worldYRange,
|
||||
int[] precomputedSurfaceHeights,
|
||||
IrisRange overrideVerticalRange,
|
||||
CaveWaterSupportPlan waterSupportPlan
|
||||
CaveFluidSupportPlan fluidSupportPlan
|
||||
) {
|
||||
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
|
||||
try {
|
||||
@@ -246,7 +246,7 @@ public class IrisCaveCarver3D {
|
||||
int x0 = PowerOfTwoCoordinates.chunkToBlock(chunkX);
|
||||
int z0 = PowerOfTwoCoordinates.chunkToBlock(chunkZ);
|
||||
int[] columnMaxY = scratch.columnMaxY;
|
||||
int[] waterMaxY = scratch.waterMaxY;
|
||||
int[] fluidMaxY = scratch.fluidMaxY;
|
||||
int[] surfaceBreakFloorY = scratch.surfaceBreakFloorY;
|
||||
boolean[] surfaceBreakColumn = scratch.surfaceBreakColumn;
|
||||
double[] columnThreshold = scratch.columnThreshold;
|
||||
@@ -291,8 +291,8 @@ public class IrisCaveCarver3D {
|
||||
: clearanceTopY;
|
||||
|
||||
columnMaxY[index] = columnTopY;
|
||||
waterMaxY[index] = allowWater
|
||||
? Math.min(fluidHeight, columnSurfaceY - waterMinDepthBelowSurface)
|
||||
fluidMaxY[index] = allowFluid
|
||||
? Math.min(fluidHeight, columnSurfaceY - fluidMinDepthBelowSurface)
|
||||
: Integer.MIN_VALUE;
|
||||
surfaceBreakFloorY[index] = Math.max(minY, columnSurfaceY - surfaceBreakDepth);
|
||||
surfaceBreakColumn[index] = breakColumn;
|
||||
@@ -316,14 +316,14 @@ public class IrisCaveCarver3D {
|
||||
adaptiveThresholdMargin,
|
||||
surfaceBreakThresholdBoost,
|
||||
columnMaxY,
|
||||
waterMaxY,
|
||||
fluidMaxY,
|
||||
surfaceBreakFloorY,
|
||||
surfaceBreakColumn,
|
||||
columnThreshold,
|
||||
clampedWeights,
|
||||
verticalEdgeFade,
|
||||
matterByY,
|
||||
waterRequiresFloor ? waterSupportPlan : null,
|
||||
fluidRequiresFloor ? fluidSupportPlan : null,
|
||||
resolvedMinWeight,
|
||||
resolvedThresholdPenalty,
|
||||
0D,
|
||||
@@ -338,14 +338,14 @@ public class IrisCaveCarver3D {
|
||||
maxY,
|
||||
surfaceBreakThresholdBoost,
|
||||
columnMaxY,
|
||||
waterMaxY,
|
||||
fluidMaxY,
|
||||
surfaceBreakFloorY,
|
||||
surfaceBreakColumn,
|
||||
columnThreshold,
|
||||
clampedWeights,
|
||||
verticalEdgeFade,
|
||||
matterByY,
|
||||
waterRequiresFloor ? waterSupportPlan : null,
|
||||
fluidRequiresFloor ? fluidSupportPlan : null,
|
||||
resolvedMinWeight,
|
||||
resolvedThresholdPenalty,
|
||||
0D,
|
||||
@@ -363,14 +363,14 @@ public class IrisCaveCarver3D {
|
||||
latticeStep,
|
||||
surfaceBreakThresholdBoost,
|
||||
columnMaxY,
|
||||
waterMaxY,
|
||||
fluidMaxY,
|
||||
surfaceBreakFloorY,
|
||||
surfaceBreakColumn,
|
||||
columnThreshold,
|
||||
clampedWeights,
|
||||
verticalEdgeFade,
|
||||
matterByY,
|
||||
waterRequiresFloor ? waterSupportPlan : null,
|
||||
fluidRequiresFloor ? fluidSupportPlan : null,
|
||||
resolvedMinWeight,
|
||||
resolvedThresholdPenalty,
|
||||
0D,
|
||||
@@ -386,14 +386,14 @@ public class IrisCaveCarver3D {
|
||||
sampleStep,
|
||||
surfaceBreakThresholdBoost,
|
||||
columnMaxY,
|
||||
waterMaxY,
|
||||
fluidMaxY,
|
||||
surfaceBreakFloorY,
|
||||
surfaceBreakColumn,
|
||||
columnThreshold,
|
||||
clampedWeights,
|
||||
verticalEdgeFade,
|
||||
matterByY,
|
||||
waterRequiresFloor ? waterSupportPlan : null,
|
||||
fluidRequiresFloor ? fluidSupportPlan : null,
|
||||
resolvedMinWeight,
|
||||
resolvedThresholdPenalty,
|
||||
0D,
|
||||
@@ -416,14 +416,14 @@ public class IrisCaveCarver3D {
|
||||
int maxY,
|
||||
double surfaceBreakThresholdBoost,
|
||||
int[] columnMaxY,
|
||||
int[] waterMaxY,
|
||||
int[] fluidMaxY,
|
||||
int[] surfaceBreakFloorY,
|
||||
boolean[] surfaceBreakColumn,
|
||||
double[] columnThreshold,
|
||||
double[] clampedWeights,
|
||||
double[] verticalEdgeFade,
|
||||
MatterCavern[] matterByY,
|
||||
CaveWaterSupportPlan waterSupportPlan,
|
||||
CaveFluidSupportPlan fluidSupportPlan,
|
||||
double minWeight,
|
||||
double thresholdPenalty,
|
||||
double thresholdBoost,
|
||||
@@ -506,8 +506,8 @@ public class IrisCaveCarver3D {
|
||||
|
||||
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
|
||||
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
|
||||
columnIndex, waterMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
|
||||
columnIndex, fluidMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
continue;
|
||||
@@ -523,8 +523,8 @@ public class IrisCaveCarver3D {
|
||||
int localZ = columnIndex & 15;
|
||||
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
|
||||
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
|
||||
columnIndex, waterMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
|
||||
columnIndex, fluidMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
}
|
||||
@@ -543,14 +543,14 @@ public class IrisCaveCarver3D {
|
||||
double adaptiveThresholdMargin,
|
||||
double surfaceBreakThresholdBoost,
|
||||
int[] columnMaxY,
|
||||
int[] waterMaxY,
|
||||
int[] fluidMaxY,
|
||||
int[] surfaceBreakFloorY,
|
||||
boolean[] surfaceBreakColumn,
|
||||
double[] columnThreshold,
|
||||
double[] clampedWeights,
|
||||
double[] verticalEdgeFade,
|
||||
MatterCavern[] matterByY,
|
||||
CaveWaterSupportPlan waterSupportPlan,
|
||||
CaveFluidSupportPlan fluidSupportPlan,
|
||||
double minWeight,
|
||||
double thresholdPenalty,
|
||||
double thresholdBoost,
|
||||
@@ -650,8 +650,8 @@ public class IrisCaveCarver3D {
|
||||
|
||||
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
|
||||
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
|
||||
columnIndex, waterMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
|
||||
columnIndex, fluidMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
continue;
|
||||
@@ -667,8 +667,8 @@ public class IrisCaveCarver3D {
|
||||
int localZ = columnIndex & 15;
|
||||
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
|
||||
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
|
||||
columnIndex, waterMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
|
||||
columnIndex, fluidMaxY, localThreshold);
|
||||
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
}
|
||||
@@ -698,14 +698,14 @@ public class IrisCaveCarver3D {
|
||||
int latticeStep,
|
||||
double surfaceBreakThresholdBoost,
|
||||
int[] columnMaxY,
|
||||
int[] waterMaxY,
|
||||
int[] fluidMaxY,
|
||||
int[] surfaceBreakFloorY,
|
||||
boolean[] surfaceBreakColumn,
|
||||
double[] columnThreshold,
|
||||
double[] clampedWeights,
|
||||
double[] verticalEdgeFade,
|
||||
MatterCavern[] matterByY,
|
||||
CaveWaterSupportPlan waterSupportPlan,
|
||||
CaveFluidSupportPlan fluidSupportPlan,
|
||||
double minWeight,
|
||||
double thresholdPenalty,
|
||||
double thresholdBoost,
|
||||
@@ -814,16 +814,16 @@ public class IrisCaveCarver3D {
|
||||
int worldX = x0 + localX;
|
||||
int worldZ = z0 + localZ;
|
||||
MatterCavern matter = resolveMatter(verticalMatter, worldX, yy, worldZ,
|
||||
index, waterMaxY, localThreshold);
|
||||
index, fluidMaxY, localThreshold);
|
||||
if (skipExistingCarved) {
|
||||
if (cavernSlice.get(localX, localY, localZ) == null) {
|
||||
writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan);
|
||||
writeCavern(cavernSlice, localX, yy, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan);
|
||||
writeCavern(cavernSlice, localX, yy, localZ, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
}
|
||||
@@ -843,14 +843,14 @@ public class IrisCaveCarver3D {
|
||||
int sampleStep,
|
||||
double surfaceBreakThresholdBoost,
|
||||
int[] columnMaxY,
|
||||
int[] waterMaxY,
|
||||
int[] fluidMaxY,
|
||||
int[] surfaceBreakFloorY,
|
||||
boolean[] surfaceBreakColumn,
|
||||
double[] columnThreshold,
|
||||
double[] clampedWeights,
|
||||
double[] verticalEdgeFade,
|
||||
MatterCavern[] matterByY,
|
||||
CaveWaterSupportPlan waterSupportPlan,
|
||||
CaveFluidSupportPlan fluidSupportPlan,
|
||||
double minWeight,
|
||||
double thresholdPenalty,
|
||||
double thresholdBoost,
|
||||
@@ -893,18 +893,18 @@ public class IrisCaveCarver3D {
|
||||
for (int yy = y; yy <= carveMaxY; yy++) {
|
||||
MatterCavern verticalMatter = matterByY[yy - minY];
|
||||
MatterCavern matter = resolveMatter(verticalMatter, x, yy, z,
|
||||
index, waterMaxY, localThreshold);
|
||||
index, fluidMaxY, localThreshold);
|
||||
MatterSlice<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, PowerOfTwoCoordinates.floorDivPow2(yy, 4));
|
||||
int localY = yy & 15;
|
||||
if (skipExistingCarved) {
|
||||
if (cavernSlice.get(lx, localY, lz) == null) {
|
||||
writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan);
|
||||
writeCavern(cavernSlice, lx, yy, lz, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan);
|
||||
writeCavern(cavernSlice, lx, yy, lz, matter, fluidSupportPlan);
|
||||
carved++;
|
||||
}
|
||||
}
|
||||
@@ -2171,11 +2171,11 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private MatterCavern resolveMatter(MatterCavern verticalMatter, int x, int y, int z,
|
||||
int columnIndex, int[] waterMaxY, double localThreshold) {
|
||||
int columnIndex, int[] fluidMaxY, double localThreshold) {
|
||||
if (verticalMatter != carveLava
|
||||
&& y <= waterMaxY[columnIndex]
|
||||
&& y <= fluidMaxY[columnIndex]
|
||||
&& isAquiferCandidate(x, y, z, localThreshold)) {
|
||||
return carveWater;
|
||||
return carveFluid;
|
||||
}
|
||||
return verticalMatter;
|
||||
}
|
||||
@@ -2186,7 +2186,7 @@ public class IrisCaveCarver3D {
|
||||
if (detailDensity.noiseFastSigned3D(x, y * 0.5D, z) <= cutoff) {
|
||||
return false;
|
||||
}
|
||||
return !waterRequiresFloor || hasAquiferCupSupport(x, y, z, localThreshold);
|
||||
return !fluidRequiresFloor || hasAquiferCupSupport(x, y, z, localThreshold);
|
||||
}
|
||||
|
||||
private boolean hasAquiferCupSupport(int x, int y, int z, double threshold) {
|
||||
@@ -2224,10 +2224,10 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private void writeCavern(MatterSlice<MatterCavern> cavernSlice, int localX, int y, int localZ,
|
||||
MatterCavern matter, CaveWaterSupportPlan waterSupportPlan) {
|
||||
MatterCavern matter, CaveFluidSupportPlan fluidSupportPlan) {
|
||||
cavernSlice.set(localX, y & 15, localZ, matter);
|
||||
if (waterSupportPlan != null && matter == carveWater) {
|
||||
waterSupportPlan.add(localX, y, localZ, carveWater, carveAir);
|
||||
if (fluidSupportPlan != null && matter == carveFluid) {
|
||||
fluidSupportPlan.add(localX, y, localZ, carveFluid, carveAir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -91,16 +91,16 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start();
|
||||
List<WeightedProfile> weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState);
|
||||
getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds());
|
||||
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
|
||||
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
|
||||
for (WeightedProfile weightedProfile : weightedProfiles) {
|
||||
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, waterSupportPlan);
|
||||
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, fluidSupportPlan);
|
||||
}
|
||||
|
||||
UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext();
|
||||
if (upperCtx != null && getDimension().isUpperDimensionCarving()) {
|
||||
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, waterSupportPlan);
|
||||
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, fluidSupportPlan);
|
||||
}
|
||||
waterSupportPlan.resolve(writer.acquireChunk(x, z));
|
||||
fluidSupportPlan.resolve(writer.acquireChunk(x, z));
|
||||
|
||||
if (!weightedProfiles.isEmpty()) {
|
||||
CarveOrphanSweep.sweepChunk(
|
||||
@@ -122,15 +122,15 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
|
||||
@ChunkCoordinates
|
||||
private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz,
|
||||
int[] chunkSurfaceHeights, CaveWaterSupportPlan waterSupportPlan) {
|
||||
int[] chunkSurfaceHeights, CaveFluidSupportPlan fluidSupportPlan) {
|
||||
IrisCaveCarver3D carver = getCarver(weightedProfile.profile);
|
||||
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY,
|
||||
weightedProfile.worldYRange, chunkSurfaceHeights, null, waterSupportPlan);
|
||||
weightedProfile.worldYRange, chunkSurfaceHeights, null, fluidSupportPlan);
|
||||
}
|
||||
|
||||
private void carveUpperTerrain(UpperDimensionContext upperCtx, List<WeightedProfile> normalProfiles,
|
||||
MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights,
|
||||
CaveWaterSupportPlan waterSupportPlan) {
|
||||
CaveFluidSupportPlan fluidSupportPlan) {
|
||||
int chunkHeight = getEngineMantle().getEngine().getHeight();
|
||||
int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight();
|
||||
int gap = getDimension().getUpperDimensionGap();
|
||||
@@ -178,7 +178,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
}
|
||||
IrisCaveCarver3D carver = getCarver(weightedProfile.profile);
|
||||
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY,
|
||||
constrainedRange, ceilingSurfaceHeights, fullVerticalRange, waterSupportPlan);
|
||||
constrainedRange, ceilingSurfaceHeights, fullVerticalRange, fluidSupportPlan);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState> {
|
||||
private static final byte LIQUID_FLUID = 1;
|
||||
private static final ThreadLocal<CarveScratch> SCRATCH = ThreadLocal.withInitial(CarveScratch::new);
|
||||
private static final int CAVE_BIOME_BLEND_RADIUS = 3;
|
||||
private static final int CAVE_BIOME_BLEND_CENTER_WEIGHT = 4;
|
||||
@@ -141,9 +142,9 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
}
|
||||
|
||||
if (explicitCarveIntent) {
|
||||
// Only a water cavern consumes the fluid sample, and on the maintenance path that
|
||||
// Only a fluid cavern consumes the fluid sample, and on the maintenance path that
|
||||
// sample is a full procedural stream evaluation, so never take it per voxel.
|
||||
PlatformBlockState fluid = c.isWater() ? context.getFluid().get(rx, rz) : null;
|
||||
PlatformBlockState fluid = isFluidIntent(c) ? context.getFluid().get(rx, rz) : null;
|
||||
output.setRaw(rx, yy, rz, resolveExplicitCarveState(c, fluid, LAVA, AIR));
|
||||
} else if (usesDefaultLava(caveLavaHeight, yy)) {
|
||||
output.setRaw(rx, yy, rz, LAVA);
|
||||
@@ -203,7 +204,11 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
}
|
||||
|
||||
static boolean hasExplicitCarveIntent(MatterCavern cavern) {
|
||||
return cavern != null && (cavern.isWater() || cavern.isLava() || cavern.getLiquid() == 3);
|
||||
return cavern != null && (isFluidIntent(cavern) || cavern.isLava() || cavern.getLiquid() == 3);
|
||||
}
|
||||
|
||||
static boolean isFluidIntent(MatterCavern cavern) {
|
||||
return cavern != null && cavern.getLiquid() == LIQUID_FLUID;
|
||||
}
|
||||
|
||||
static boolean shouldPreserveExistingFluid(MatterCavern cavern, PlatformBlockState current) {
|
||||
@@ -219,7 +224,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
if (cavern == null) {
|
||||
return null;
|
||||
}
|
||||
if (cavern.isWater()) {
|
||||
if (isFluidIntent(cavern)) {
|
||||
return fluid;
|
||||
}
|
||||
if (cavern.isLava()) {
|
||||
|
||||
@@ -132,16 +132,16 @@ public class IrisCaveProfile {
|
||||
@Desc("Maximum random column retries while searching a valid cave object anchor in the chunk.")
|
||||
private int anchorSearchAttempts = 6;
|
||||
|
||||
@Desc("Allow cave water placement below fluid level.")
|
||||
private boolean allowWater = true;
|
||||
@Desc("Allow cave fluid placement from the dimension fluid palette below fluid level.")
|
||||
private boolean allowFluid = true;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(64)
|
||||
@Desc("Minimum depth below terrain surface required before cave water may be placed.")
|
||||
private int waterMinDepthBelowSurface = 12;
|
||||
@Desc("Minimum depth below terrain surface required before cave fluid may be placed.")
|
||||
private int fluidMinDepthBelowSurface = 12;
|
||||
|
||||
@Desc("Require solid floor support below cave water to reduce cascading cave waterfalls.")
|
||||
private boolean waterRequiresFloor = true;
|
||||
@Desc("Require solid floor support below cave fluid to reduce unsupported fluid flows.")
|
||||
private boolean fluidRequiresFloor = true;
|
||||
|
||||
@Desc("Allow cave lava placement based on lava height.")
|
||||
private boolean allowLava = true;
|
||||
|
||||
@@ -59,7 +59,10 @@ import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -260,7 +263,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
private double rockZoom = 5;
|
||||
@Desc("The palette of blocks for 'stone'")
|
||||
private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone");
|
||||
@Desc("The palette of blocks for 'water'")
|
||||
@Desc("The dimension fluid block palette used for ocean columns and cave aquifers.")
|
||||
private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water");
|
||||
@Desc("Prevent cartographers to generate explorer maps (Iris worlds only)\nONLY TOUCH IF YOUR SERVER CRASHES WHILE GENERATING EXPLORER MAPS")
|
||||
private boolean disableExplorerMaps = false;
|
||||
@@ -484,14 +487,70 @@ public class IrisDimension extends IrisRegistrant {
|
||||
|
||||
public KList<IrisBiome> getReachableBiomes(DataProvider g) {
|
||||
KMap<String, IrisBiome> biomes = new KMap<>();
|
||||
if (g == null) {
|
||||
return biomes.v();
|
||||
}
|
||||
|
||||
for (IrisRegion region : getAllRegions(g)) {
|
||||
if (region == null) {
|
||||
IrisData data = g.getData();
|
||||
if (data == null || data.getRegionLoader() == null || data.getBiomeLoader() == null) {
|
||||
return biomes.v();
|
||||
}
|
||||
|
||||
Deque<String> pending = new ArrayDeque<>();
|
||||
KList<String> regionKeys = getRegions();
|
||||
if (regionKeys != null) {
|
||||
for (String regionKey : regionKeys) {
|
||||
IrisRegion region = data.getRegionLoader().load(regionKey);
|
||||
if (region == null) {
|
||||
continue;
|
||||
}
|
||||
addReachableBiomeKeys(pending, region.getAllBiomeIds());
|
||||
}
|
||||
}
|
||||
|
||||
KList<IrisDimensionCarvingEntry> carvingEntries = getCarving();
|
||||
if (carvingEntries != null) {
|
||||
for (IrisDimensionCarvingEntry entry : carvingEntries) {
|
||||
if (entry != null && entry.isEnabled()) {
|
||||
addReachableBiomeKey(pending, entry.getBiome());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> visited = new HashSet<>();
|
||||
Map<String, IrisDimensionCarvingEntry> carvingEntryIndex = getCarvingEntryIndex();
|
||||
while (!pending.isEmpty()) {
|
||||
String biomeKey = pending.removeFirst();
|
||||
if (!visited.add(biomeKey)) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiome biome : region.getAllBiomes(g)) {
|
||||
if (biome != null) {
|
||||
biomes.put(biome.getLoadKey(), biome);
|
||||
|
||||
IrisBiome biome = data.getBiomeLoader().load(biomeKey);
|
||||
if (biome == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String loadKey = biome.getLoadKey();
|
||||
if (loadKey == null || loadKey.isBlank() || biomes.containsKey(loadKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(loadKey);
|
||||
biomes.put(loadKey, biome);
|
||||
addReachableBiomeKeys(pending, biome.getChildren());
|
||||
addReachableBiomeKey(pending, biome.getCarvingBiome());
|
||||
|
||||
KList<IrisFloatingChildBiomes> floatingChildren = biome.getFloatingChildBiomes();
|
||||
if (floatingChildren == null) {
|
||||
continue;
|
||||
}
|
||||
for (IrisFloatingChildBiomes floatingChild : floatingChildren) {
|
||||
if (floatingChild != null) {
|
||||
addReachableBiomeKey(pending, floatingChild.getBiome());
|
||||
String carvingReference = floatingChild.getCarving();
|
||||
IrisDimensionCarvingEntry carvingEntry = carvingEntryIndex.get(carvingReference);
|
||||
addReachableBiomeKey(pending,
|
||||
carvingEntry == null ? carvingReference : carvingEntry.getBiome());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,6 +558,21 @@ public class IrisDimension extends IrisRegistrant {
|
||||
return biomes.v();
|
||||
}
|
||||
|
||||
private void addReachableBiomeKeys(Deque<String> pending, Iterable<String> biomeKeys) {
|
||||
if (biomeKeys == null) {
|
||||
return;
|
||||
}
|
||||
for (String biomeKey : biomeKeys) {
|
||||
addReachableBiomeKey(pending, biomeKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void addReachableBiomeKey(Deque<String> pending, String biomeKey) {
|
||||
if (biomeKey != null && !biomeKey.isBlank()) {
|
||||
pending.addLast(biomeKey);
|
||||
}
|
||||
}
|
||||
|
||||
public KList<IrisBiome> getAllAnyBiomes() {
|
||||
KList<IrisBiome> r = new KList<>();
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.Objects;
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Every registered structure generates through its native placement unless its key is explicitly listed in 'disabled' or a viable dimension-level Iris placement explicitly replaces its source. Family matching uses namespace, slash, or underscore boundaries, so 'minecraft:village' covers every village variant without matching unrelated names. Run '/iris structure list <dimension>' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.")
|
||||
@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Every registered structure generates through its native placement unless its key matches 'disabled', equals a key in 'disabledExact', or a viable dimension-level Iris placement explicitly replaces its source. Family matching uses namespace, slash, or underscore boundaries, while exact matching compares normalized complete keys only. Run '/iris structure list <dimension>' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.")
|
||||
@Data
|
||||
public class IrisImportedStructureControl {
|
||||
@ArrayType(type = String.class, min = 1)
|
||||
@@ -43,6 +43,11 @@ public class IrisImportedStructureControl {
|
||||
@Desc("Structure keys to deny explicitly, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village variant and 'minecraft:ruined_portal' disables every ruined portal. Every key not matched here remains enabled.")
|
||||
private KList<String> disabled = new KList<>();
|
||||
|
||||
@ArrayType(type = String.class, min = 1)
|
||||
@RegistryListVanillaStructure(prefixes = false)
|
||||
@Desc("Exact structure keys to deny after trimming and case normalization. Unlike 'disabled', entries never match key families, so 'minecraft:ruined_portal' does not disable 'minecraft:ruined_portal_nether'.")
|
||||
private KList<String> disabledExact = new KList<>();
|
||||
|
||||
@MinNumber(-512)
|
||||
@MaxNumber(512)
|
||||
@Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES, UNDERGROUND_DECORATION, and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.")
|
||||
@@ -51,12 +56,18 @@ public class IrisImportedStructureControl {
|
||||
@Desc("Controls whether ingested datapacks may replace minecraft-namespaced structure definitions, sets, pools, and templates. When false, those overrides are stripped from installed datapack copies so vanilla definitions stay intact. Non-minecraft structures from datapacks and mods remain governed by disabled because namespace alone cannot identify their origin. Resolved globally across loaded packs: if any dimension sets this false, minecraft-namespaced overrides are stripped from every installed datapack copy.")
|
||||
private boolean datapackOverrides = true;
|
||||
|
||||
@ArrayType(type = IrisStructureSetFrequencyOverride.class, min = 1)
|
||||
@Desc("Exact registered structure-set frequency overrides for native generation. The last entry for a normalized structure-set key wins. These do not convert native structures into Iris placements and affect only newly generated chunks.")
|
||||
private KList<IrisStructureSetFrequencyOverride> frequencyOverrides = new KList<>();
|
||||
|
||||
@ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1)
|
||||
@Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. A matching preserveSourceY option disables Iris burial repositioning for that structure. The last matching entry with stilt settings controls foundation columns, and likewise for terrain and yBand settings. A structure suppressed by an Iris placement is unaffected.")
|
||||
private KList<IrisVanillaStructureAdjustment> adjustments = new KList<>();
|
||||
|
||||
public boolean shouldGenerate(String key) {
|
||||
return key != null && !key.isBlank() && !matches(disabled, key);
|
||||
return key != null && !key.isBlank()
|
||||
&& !matches(disabled, key)
|
||||
&& !matchesExact(disabledExact, key);
|
||||
}
|
||||
|
||||
public IrisNativeStructureDecision resolve(String key, boolean undergroundStep) {
|
||||
@@ -86,11 +97,32 @@ public class IrisImportedStructureControl {
|
||||
generationStatus(key), y, yBand, preserveSourceY, stilt, terrain);
|
||||
}
|
||||
|
||||
public double frequencyMultiplier(String structureSetKey) {
|
||||
KList<IrisStructureSetFrequencyOverride> activeOverrides = Objects.requireNonNull(
|
||||
frequencyOverrides, "importedStructures.frequencyOverrides must not be null");
|
||||
String normalizedKey = normalizeKey(structureSetKey);
|
||||
if (normalizedKey.isEmpty()) {
|
||||
return 1D;
|
||||
}
|
||||
double multiplier = 1D;
|
||||
for (IrisStructureSetFrequencyOverride override : activeOverrides) {
|
||||
if (override != null && normalizeKey(override.getStructureSet()).equals(normalizedKey)) {
|
||||
multiplier = override.getMultiplier();
|
||||
}
|
||||
}
|
||||
return multiplier;
|
||||
}
|
||||
|
||||
public boolean hasFrequencyOverrides() {
|
||||
return !Objects.requireNonNull(
|
||||
frequencyOverrides, "importedStructures.frequencyOverrides must not be null").isEmpty();
|
||||
}
|
||||
|
||||
private NativeStructureGenerationStatus generationStatus(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return NativeStructureGenerationStatus.INVALID_REGISTRY_KEY;
|
||||
}
|
||||
if (matches(disabled, key)) {
|
||||
if (matches(disabled, key) || matchesExact(disabledExact, key)) {
|
||||
return NativeStructureGenerationStatus.DISABLED_BY_PACK;
|
||||
}
|
||||
return NativeStructureGenerationStatus.GENERATE_NATIVE;
|
||||
@@ -110,6 +142,19 @@ public class IrisImportedStructureControl {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean matchesExact(KList<String> list, String key) {
|
||||
KList<String> activeList = Objects.requireNonNull(
|
||||
list, "importedStructures.disabledExact must not be null");
|
||||
String normalizedKey = normalizeKey(key);
|
||||
for (String entry : activeList) {
|
||||
String normalizedEntry = normalizeKey(entry);
|
||||
if (!normalizedEntry.isEmpty() && normalizedEntry.equals(normalizedKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean matchesKey(String pattern, String key) {
|
||||
if (pattern == null || key == null) {
|
||||
return false;
|
||||
@@ -129,4 +174,8 @@ public class IrisImportedStructureControl {
|
||||
char boundary = normalizedKey.charAt(normalizedPattern.length());
|
||||
return boundary == '/' || boundary == '_';
|
||||
}
|
||||
|
||||
private static String normalizeKey(String key) {
|
||||
return key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Desc("An exact registered structure-set frequency multiplier. Iris retains the set's structures, weights, biome eligibility, placement algorithm, salt, exclusion zone, starts, processors, mobs, loot, and locate behavior while scaling only its native placement density.")
|
||||
@Data
|
||||
public class IrisStructureSetFrequencyOverride {
|
||||
@RegistryListVanillaStructureSet
|
||||
@Desc("Exact registered structure-set key, for example 'minecraft:nether_complexes'. Structure keys such as 'minecraft:fortress' are not valid here.")
|
||||
private String structureSet = "";
|
||||
|
||||
@MinNumber(0.01)
|
||||
@MaxNumber(16)
|
||||
@Desc("Requested placement-density multiplier. Random-spread sets scale their placement frequency first and then their integer chunk spacing; integer spacing and separation constraints can make the realized increase slightly lower or higher. The default 1 leaves the registered placement unchanged.")
|
||||
private double multiplier = 1D;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class ExactWorldSlotPathPolicyTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void resolvesIrisAndExactVanillaSlots() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("world").toPath();
|
||||
Path canonicalRoot = levelRoot.toRealPath();
|
||||
List<SlotExpectation> expectations = List.of(
|
||||
new SlotExpectation(
|
||||
new NamespacedKey("iris", "underworld"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
|
||||
"dimensions/iris/underworld"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("overworld"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
|
||||
"dimensions/minecraft/overworld"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("the_nether"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
|
||||
"dimensions/minecraft/the_nether"
|
||||
),
|
||||
new SlotExpectation(
|
||||
NamespacedKey.minecraft("the_end"),
|
||||
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
|
||||
"dimensions/minecraft/the_end"
|
||||
)
|
||||
);
|
||||
|
||||
for (SlotExpectation expectation : expectations) {
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||
levelRoot,
|
||||
expectation.worldKey()
|
||||
);
|
||||
|
||||
assertEquals(expectation.worldKey(), target.worldKey());
|
||||
assertEquals(expectation.slotKind(), target.slotKind());
|
||||
assertEquals(canonicalRoot, target.levelRoot());
|
||||
assertEquals(canonicalRoot.resolve(expectation.relativePath()), target.worldDirectory());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsAnExistingExactDirectorySlot() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("existing-world").toPath();
|
||||
Path worldDirectory = Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_nether"));
|
||||
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||
levelRoot,
|
||||
NamespacedKey.minecraft("the_nether")
|
||||
);
|
||||
|
||||
assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsForeignNestedAndUnsupportedKeys() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("key-policy").toPath();
|
||||
|
||||
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom"))
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.INVALID_IRIS_KEY, nestedIris.reason());
|
||||
assertEquals(
|
||||
ExactWorldSlotPathPolicy.RejectionReason.UNSUPPORTED_MINECRAFT_SLOT,
|
||||
unsupportedMinecraft.reason()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesOnlyTheExactExpectedCandidate() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath();
|
||||
NamespacedKey worldKey = NamespacedKey.minecraft("the_nether");
|
||||
Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether");
|
||||
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
|
||||
levelRoot,
|
||||
worldKey,
|
||||
expected
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection mismatch = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.validate(
|
||||
levelRoot,
|
||||
worldKey,
|
||||
levelRoot.resolve("dimensions/iris/the_nether")
|
||||
)
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection traversal = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.validate(
|
||||
levelRoot,
|
||||
worldKey,
|
||||
levelRoot.resolve("dimensions/minecraft/unused/../the_nether")
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(expected, target.worldDirectory());
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_MISMATCH, mismatch.reason());
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_TRAVERSAL, traversal.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTraversalInLevelRoot() throws Exception {
|
||||
Path parent = temporaryFolder.newFolder("level-traversal").toPath();
|
||||
Path levelRoot = Files.createDirectory(parent.resolve("world"));
|
||||
|
||||
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(
|
||||
levelRoot.resolve("child/.."),
|
||||
new NamespacedKey("iris", "underworld")
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_TRAVERSAL, failure.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksAtEveryManagedPathComponent() throws Exception {
|
||||
Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath();
|
||||
Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
|
||||
Files.createSymbolicLink(levelLink, linkedLevelTarget);
|
||||
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld"));
|
||||
|
||||
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
|
||||
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
|
||||
Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions);
|
||||
assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
|
||||
|
||||
Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath();
|
||||
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
|
||||
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
|
||||
Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace);
|
||||
assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether"));
|
||||
|
||||
Path targetLevel = temporaryFolder.newFolder("linked-target").toPath();
|
||||
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
|
||||
Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
|
||||
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
|
||||
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonDirectoryStorageEntries() throws Exception {
|
||||
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
|
||||
Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory");
|
||||
assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
|
||||
|
||||
Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath();
|
||||
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
|
||||
Files.writeString(dimensions.resolve("iris"), "not a directory");
|
||||
assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld"));
|
||||
|
||||
Path targetLevel = temporaryFolder.newFolder("file-target").toPath();
|
||||
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
|
||||
Files.writeString(namespace.resolve("underworld"), "not a directory");
|
||||
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingAndFilesystemLevelRoots() throws Exception {
|
||||
Path missing = temporaryFolder.getRoot().toPath().resolve("missing");
|
||||
|
||||
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld"))
|
||||
);
|
||||
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(
|
||||
missing.toAbsolutePath().getRoot(),
|
||||
new NamespacedKey("iris", "underworld")
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.MISSING_LEVEL_ROOT, missingFailure.reason());
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason());
|
||||
}
|
||||
|
||||
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) {
|
||||
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason());
|
||||
}
|
||||
|
||||
private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) {
|
||||
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
|
||||
ExactWorldSlotPathPolicy.Rejection.class,
|
||||
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
|
||||
);
|
||||
|
||||
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, failure.reason());
|
||||
}
|
||||
|
||||
private record SlotExpectation(
|
||||
NamespacedKey worldKey,
|
||||
ExactWorldSlotPathPolicy.SlotKind slotKind,
|
||||
String relativePath
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
@@ -2927,6 +2928,37 @@ public class DatapackIngestServiceTest {
|
||||
.has(fixture.target().toPath().toAbsolutePath().normalize().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulUnchangedIngestKeepsStartupFingerprintStableAcrossNextReapply() throws Exception {
|
||||
ReapplyFixture fixture = reapplyFixture("reapply-ingest-cache");
|
||||
assertTrue(DatapackIngestService.reapplyStagedDirectories(
|
||||
fixture.root(), fixture.stagingRoot(), fixture.worlds(), false));
|
||||
DatapackIngestService.Entry entry = new Gson().fromJson(
|
||||
manifestEntry(fixture.root()), DatapackIngestService.Entry.class);
|
||||
DatapackIngestService.Report report = new DatapackIngestService.Report();
|
||||
|
||||
DatapackIngestService.recordInstallResult(
|
||||
null,
|
||||
report,
|
||||
fixture.staging(),
|
||||
fixture.worlds(),
|
||||
entry,
|
||||
new DatapackIngestService.InstallResult(false),
|
||||
entry.versionNumber
|
||||
);
|
||||
writePrettyManifest(fixture.root(), entry);
|
||||
String cachedFingerprint = DatapackIngestService.startupValidationFingerprint(
|
||||
fixture.root(), fixture.worlds());
|
||||
|
||||
assertFalse(entry.stagingMetadata.isBlank());
|
||||
assertEquals(1, entry.installMetadata.size());
|
||||
assertEquals(1, report.getUpToDate().size());
|
||||
assertTrue(DatapackIngestService.reapplyStagedDirectories(
|
||||
fixture.root(), fixture.stagingRoot(), fixture.worlds(), false));
|
||||
assertEquals(cachedFingerprint, DatapackIngestService.startupValidationFingerprint(
|
||||
fixture.root(), fixture.worlds()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reapplyOutcomeDistinguishesRepairFromAnUnchangedPass() throws Exception {
|
||||
ReapplyFixture fixture = reapplyFixture("reapply-outcome");
|
||||
@@ -3183,6 +3215,16 @@ public class DatapackIngestServiceTest {
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void writePrettyManifest(File root, DatapackIngestService.Entry entry) throws Exception {
|
||||
Map<String, Object> manifest = new LinkedHashMap<>();
|
||||
manifest.put("entries", List.of(entry));
|
||||
Files.writeString(
|
||||
new File(root, "manifest.json").toPath(),
|
||||
new GsonBuilder().setPrettyPrinting().create().toJson(manifest),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
|
||||
private String ownershipHash(File directory) throws Exception {
|
||||
String marker = Files.readString(new File(directory, ".iris-managed.json").toPath(), StandardCharsets.UTF_8);
|
||||
return JsonParser.parseString(marker).getAsJsonObject().get("contentHash").getAsString();
|
||||
|
||||
@@ -115,4 +115,206 @@ public class BukkitWorldConfigurationTest {
|
||||
1337L));
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replacementChangesOnlyGeneratorAndSeed() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
YamlConfiguration initial = new YamlConfiguration();
|
||||
initial.set("settings.allow-end", true);
|
||||
initial.set("worlds.world_nether.generator", "VanillaNether");
|
||||
initial.set("worlds.world_nether.seed", 7L);
|
||||
initial.set("worlds.world_nether.environment", "NETHER");
|
||||
initial.set("worlds.world_nether.keep-spawn-loaded", false);
|
||||
initial.save(configuration);
|
||||
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
|
||||
BukkitWorldConfiguration.GeneratorReplacement replacement =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
original,
|
||||
"underworld",
|
||||
1337L
|
||||
);
|
||||
|
||||
assertTrue(replacement.applied());
|
||||
assertEquals(original, replacement.observed());
|
||||
assertEquals("VanillaNether", original.generator());
|
||||
assertEquals(Long.valueOf(7L), original.seed());
|
||||
assertEquals("Iris:underworld", replacement.replacement().generator());
|
||||
assertEquals(Long.valueOf(1337L), replacement.replacement().seed());
|
||||
YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertEquals("Iris:underworld", loaded.getString("worlds.world_nether.generator"));
|
||||
assertEquals(1337L, loaded.getLong("worlds.world_nether.seed"));
|
||||
assertEquals("NETHER", loaded.getString("worlds.world_nether.environment"));
|
||||
assertFalse(loaded.getBoolean("worlds.world_nether.keep-spawn-loaded"));
|
||||
assertTrue(loaded.getBoolean("settings.allow-end"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleReplacementSnapshotDoesNotWrite() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "probe");
|
||||
BukkitWorldConfiguration.GeneratorReplacement first =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
original,
|
||||
"underworld",
|
||||
42L
|
||||
);
|
||||
assertTrue(first.applied());
|
||||
String beforeStaleAttempt = Files.readString(configuration.toPath());
|
||||
|
||||
BukkitWorldConfiguration.GeneratorReplacement stale =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
original,
|
||||
"theend",
|
||||
99L
|
||||
);
|
||||
|
||||
assertFalse(stale.applied());
|
||||
assertEquals(first.replacement(), stale.observed());
|
||||
assertEquals(beforeStaleAttempt, Files.readString(configuration.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restorationPreservesConcurrentUnrelatedFields() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
YamlConfiguration initial = new YamlConfiguration();
|
||||
initial.set("worlds.world_nether.generator", "VanillaNether");
|
||||
initial.set("worlds.world_nether.seed", 7L);
|
||||
initial.set("worlds.world_nether.environment", "NETHER");
|
||||
initial.save(configuration);
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
|
||||
BukkitWorldConfiguration.GeneratorReplacement replacement =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
original,
|
||||
"underworld",
|
||||
1337L
|
||||
);
|
||||
|
||||
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
|
||||
concurrent.set("worlds.world_nether.environment", "CUSTOM");
|
||||
concurrent.set("worlds.world_nether.extra", "preserve");
|
||||
concurrent.save(configuration);
|
||||
|
||||
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
replacement.replacement(),
|
||||
original
|
||||
));
|
||||
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertEquals("VanillaNether", restored.getString("worlds.world_nether.generator"));
|
||||
assertEquals(7L, restored.getLong("worlds.world_nether.seed"));
|
||||
assertEquals("CUSTOM", restored.getString("worlds.world_nether.environment"));
|
||||
assertEquals("preserve", restored.getString("worlds.world_nether.extra"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleRestorationDoesNotOverwriteChangedGenerator() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "probe");
|
||||
BukkitWorldConfiguration.GeneratorReplacement replacement =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
original,
|
||||
"underworld",
|
||||
42L
|
||||
);
|
||||
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
|
||||
concurrent.set("worlds.probe.generator", "ExternalGenerator");
|
||||
concurrent.save(configuration);
|
||||
String beforeRestore = Files.readString(configuration.toPath());
|
||||
|
||||
assertFalse(BukkitWorldConfiguration.restoreIfMatching(
|
||||
configuration,
|
||||
"probe",
|
||||
replacement.replacement(),
|
||||
original
|
||||
));
|
||||
assertEquals(beforeRestore, Files.readString(configuration.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restorationRemovesSectionsCreatedByReplacement() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
YamlConfiguration initial = new YamlConfiguration();
|
||||
initial.set("settings.allow-end", true);
|
||||
initial.save(configuration);
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
|
||||
BukkitWorldConfiguration.GeneratorReplacement replacement =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
original,
|
||||
"underworld",
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
replacement.replacement(),
|
||||
original
|
||||
));
|
||||
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertNull(restored.getConfigurationSection("worlds"));
|
||||
assertTrue(restored.getBoolean("settings.allow-end"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restorationRetainsConcurrentFieldsInNewWorldSection() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
|
||||
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
|
||||
BukkitWorldConfiguration.GeneratorReplacement replacement =
|
||||
BukkitWorldConfiguration.replaceIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
original,
|
||||
"underworld",
|
||||
1337L
|
||||
);
|
||||
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
|
||||
concurrent.set("worlds.world_nether.environment", "NETHER");
|
||||
concurrent.save(configuration);
|
||||
|
||||
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
|
||||
configuration,
|
||||
"world_nether",
|
||||
replacement.replacement(),
|
||||
original
|
||||
));
|
||||
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
|
||||
assertNull(restored.get("worlds.world_nether.generator"));
|
||||
assertNull(restored.get("worlds.world_nether.seed"));
|
||||
assertEquals("NETHER", restored.getString("worlds.world_nether.environment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshotRefusesMalformedGeneratorWithoutChangingFile() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
String malformed = "worlds:\n probe:\n generator:\n nested: value\n seed: 7\n";
|
||||
Files.writeString(configuration.toPath(), malformed);
|
||||
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> BukkitWorldConfiguration.snapshot(configuration, "probe"));
|
||||
|
||||
assertTrue(failure.getMessage().contains("generator"));
|
||||
assertEquals(malformed, Files.readString(configuration.toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class WorldReplacementFilesystemTest {
|
||||
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");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void publishesReplacementAndRetainsOriginalBackup() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, false, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retriesPublicationAfterOriginalWasMovedToBackup() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-first-move", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Files.move(paths.target(), paths.backup());
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retriesPublicationAfterStageWasMovedToTarget() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-second-move", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Files.move(paths.target(), paths.backup());
|
||||
Files.move(paths.stage(), paths.target());
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retriesAbsentTargetPublicationAfterStageWasMoved() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-absent", TRANSACTION_ID);
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Files.move(paths.stage(), paths.target());
|
||||
|
||||
WorldReplacementFilesystem.publish(paths, false, fingerprint);
|
||||
|
||||
assertEquals("replacement", readPackContent(paths.target()));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackRestoresOriginalAfterCompletedPublication() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-existing", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
WorldReplacementFilesystem.publish(paths, true, fingerprint);
|
||||
|
||||
WorldReplacementFilesystem.rollback(paths, true);
|
||||
|
||||
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.target().resolve("iris/pack")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackRemovesPublishedReplacementForOriginallyAbsentTarget() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-absent", TRANSACTION_ID);
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
WorldReplacementFilesystem.publish(paths, false, fingerprint);
|
||||
|
||||
WorldReplacementFilesystem.rollback(paths, false);
|
||||
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackRestoresOriginalFromFirstMoveCrashState() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-first-move", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
writeStage(paths, "replacement");
|
||||
Files.move(paths.target(), paths.backup());
|
||||
|
||||
WorldReplacementFilesystem.rollback(paths, true);
|
||||
|
||||
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPackMutationBeforePublication() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID);
|
||||
String fingerprint = writeStage(paths, "original-stage");
|
||||
Files.writeString(packContent(paths.stage()), "mutated-stage");
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
|
||||
);
|
||||
|
||||
assertTrue(Files.isDirectory(paths.stage()));
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymlinkOutsidePackBeforePublication() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("outside-pack-link", TRANSACTION_ID);
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Path outside = temporaryFolder.newFile("outside.txt").toPath();
|
||||
Path region = Files.createDirectories(paths.stage().resolve("region"));
|
||||
Files.createSymbolicLink(region.resolve("linked.mca"), outside);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
|
||||
);
|
||||
|
||||
assertTrue(Files.isDirectory(paths.stage()));
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSpecialEntryOutsidePackBeforePublication() throws Exception {
|
||||
Path shortTemp = Path.of("/tmp");
|
||||
Assume.assumeTrue(Files.isDirectory(shortTemp));
|
||||
Path parent = Files.createTempDirectory(shortTemp, "iw");
|
||||
String name = "u";
|
||||
String stem = artifactStem(name, TRANSACTION_ID);
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = new WorldReplacementFilesystem.ReplacementPaths(
|
||||
parent.resolve(name),
|
||||
parent.resolve(stem + ".stage"),
|
||||
parent.resolve(stem + ".backup")
|
||||
);
|
||||
try {
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Path socket = paths.stage().resolve("unsafe.sock");
|
||||
try (ServerSocketChannel channel = openUnixSocket(socket)) {
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
assertTrue(Files.isDirectory(paths.stage()));
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
} finally {
|
||||
deleteTestTree(parent);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymlinkInsidePackAndNonDirectoryArtifacts() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths linkedPaths = paths("inside-pack-link", TRANSACTION_ID);
|
||||
Path pack = Files.createDirectories(linkedPaths.stage().resolve("iris/pack"));
|
||||
Path outside = temporaryFolder.newFile("outside-pack.txt").toPath();
|
||||
Files.createSymbolicLink(pack.resolve("linked.json"), outside);
|
||||
|
||||
assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack));
|
||||
|
||||
WorldReplacementFilesystem.ReplacementPaths filePaths = paths("file-stage", OTHER_TRANSACTION_ID);
|
||||
Files.createFile(filePaths.stage());
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(filePaths, false, "0".repeat(64))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedAndCrossTransactionPaths() throws Exception {
|
||||
Path parent = temporaryFolder.newFolder("invalid-paths").toPath();
|
||||
Path target = parent.resolve("underworld");
|
||||
String firstStem = artifactStem("underworld", TRANSACTION_ID);
|
||||
String secondStem = artifactStem("underworld", OTHER_TRANSACTION_ID);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new WorldReplacementFilesystem.ReplacementPaths(
|
||||
target,
|
||||
parent.resolve("invalid.stage"),
|
||||
parent.resolve(firstStem + ".backup")
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new WorldReplacementFilesystem.ReplacementPaths(
|
||||
target,
|
||||
parent.resolve(firstStem + ".stage"),
|
||||
parent.resolve(secondStem + ".backup")
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new WorldReplacementFilesystem.ReplacementPaths(
|
||||
target,
|
||||
parent.resolve(firstStem + ".stage"),
|
||||
Files.createDirectories(parent.resolve("other")).resolve(firstStem + ".backup")
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new WorldReplacementFilesystem.ReplacementPaths(
|
||||
parent.resolve("safe/../underworld"),
|
||||
parent.resolve(firstStem + ".stage"),
|
||||
parent.resolve(firstStem + ".backup")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsImpossibleCombinedPublicationState() throws Exception {
|
||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("combined-state", TRANSACTION_ID);
|
||||
writeOriginalTarget(paths, "original");
|
||||
String fingerprint = writeStage(paths, "replacement");
|
||||
Files.createDirectories(paths.backup());
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldReplacementFilesystem.publish(paths, true, fingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
private WorldReplacementFilesystem.ReplacementPaths paths(String name, UUID transactionId) throws Exception {
|
||||
Path parent = temporaryFolder.newFolder(name).toPath();
|
||||
String stem = artifactStem(name, transactionId);
|
||||
return new WorldReplacementFilesystem.ReplacementPaths(
|
||||
parent.resolve(name),
|
||||
parent.resolve(stem + ".stage"),
|
||||
parent.resolve(stem + ".backup")
|
||||
);
|
||||
}
|
||||
|
||||
private String writeStage(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
|
||||
Path contentFile = packContent(paths.stage());
|
||||
Files.createDirectories(contentFile.getParent());
|
||||
Files.writeString(contentFile, content);
|
||||
return WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
|
||||
}
|
||||
|
||||
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
|
||||
Files.createDirectories(paths.target());
|
||||
Files.writeString(paths.target().resolve("original.txt"), content);
|
||||
}
|
||||
|
||||
private String readPackContent(Path worldDirectory) throws Exception {
|
||||
return Files.readString(packContent(worldDirectory));
|
||||
}
|
||||
|
||||
private Path packContent(Path worldDirectory) {
|
||||
return worldDirectory.resolve("iris/pack/dimensions/underworld.json");
|
||||
}
|
||||
|
||||
private String artifactStem(String name, UUID transactionId) {
|
||||
return ".iris-replace-" + name + "-" + transactionId;
|
||||
}
|
||||
|
||||
private ServerSocketChannel openUnixSocket(Path path) throws Exception {
|
||||
ServerSocketChannel channel = null;
|
||||
try {
|
||||
channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
|
||||
channel.bind(UnixDomainSocketAddress.of(path));
|
||||
return channel;
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
if (channel != null) {
|
||||
channel.close();
|
||||
}
|
||||
Assume.assumeNoException(exception);
|
||||
throw exception;
|
||||
} catch (Exception | Error failure) {
|
||||
if (channel != null) {
|
||||
channel.close();
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTestTree(Path root) throws Exception {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> paths = Files.walk(root)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.core.nms.datapack.v1217;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustomParticle;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -12,19 +14,35 @@ public class DataFixerV1217CustomBiomeTest {
|
||||
private final DataFixerV1217 fixer = new DataFixerV1217();
|
||||
|
||||
@Test
|
||||
public void keepsSpigotBiomeColorsInEffects() {
|
||||
public void movesEnvironmentColorsAndParticlesIntoAttributes() {
|
||||
IrisBiomeCustom biome = new IrisBiomeCustom();
|
||||
biome.setId("spigot_colors");
|
||||
biome.setGrassColor("#28a040");
|
||||
biome.setFoliageColor("#249030");
|
||||
biome.setFogColor("#330808");
|
||||
biome.setSkyColor("#102030");
|
||||
biome.setWaterFogColor("#405060");
|
||||
biome.setAmbientParticle(new IrisBiomeCustomParticle()
|
||||
.setParticle("minecraft:ash")
|
||||
.setRarity(40));
|
||||
|
||||
JSONObject json = new JSONObject(biome.generateJson(fixer));
|
||||
JSONObject effects = json.getJSONObject("effects");
|
||||
JSONObject attributes = json.getJSONObject("attributes");
|
||||
|
||||
assertFalse(json.has("attributes"));
|
||||
assertTrue(effects.has("water_color"));
|
||||
assertTrue(effects.has("water_fog_color"));
|
||||
assertEquals(0x28a040, effects.getInt("grass_color"));
|
||||
assertEquals(0x249030, effects.getInt("foliage_color"));
|
||||
assertFalse(effects.has("sky_color"));
|
||||
assertFalse(effects.has("fog_color"));
|
||||
assertFalse(effects.has("water_fog_color"));
|
||||
assertFalse(effects.has("particle"));
|
||||
assertEquals(0x330808, attributes.getInt("minecraft:visual/fog_color"));
|
||||
assertEquals(0x102030, attributes.getInt("minecraft:visual/sky_color"));
|
||||
assertEquals(0x405060, attributes.getInt("minecraft:visual/water_fog_color"));
|
||||
JSONArray ambientParticles = attributes.getJSONArray("minecraft:visual/ambient_particles");
|
||||
JSONObject ambientParticle = ambientParticles.getJSONObject(0);
|
||||
assertEquals("minecraft:ash", ambientParticle.getJSONObject("particle").getString("type"));
|
||||
assertEquals(0.025D, ambientParticle.getDouble("probability"), 0.000001D);
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.nms.datapack.v1217;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionTypeOptions;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -16,6 +17,8 @@ public class DataFixerV1217DimensionTypeTest {
|
||||
|
||||
assertTrue(json.has("has_ender_dragon_fight"));
|
||||
assertEquals(false, json.getBoolean("has_ender_dragon_fight"));
|
||||
assertEquals("#0a0a0a", json.getJSONObject("attributes")
|
||||
.getString("minecraft:visual/ambient_light_color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -24,5 +27,25 @@ public class DataFixerV1217DimensionTypeTest {
|
||||
|
||||
assertTrue(json.has("has_ender_dragon_fight"));
|
||||
assertEquals(true, json.getBoolean("has_ender_dragon_fight"));
|
||||
assertEquals("#3f473f", json.getJSONObject("attributes")
|
||||
.getString("minecraft:visual/ambient_light_color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsNetherDimensionWithVanillaAmbientColor() {
|
||||
JSONObject json = fixer.createDimension(Dimension.NETHER, -256, 768, 512, null);
|
||||
|
||||
assertEquals("#302821", json.getJSONObject("attributes")
|
||||
.getString("minecraft:visual/ambient_light_color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapsMaximumAmbientLightToWhite() {
|
||||
IrisDimensionTypeOptions options = new IrisDimensionTypeOptions().ambientLight(1F);
|
||||
JSONObject json = fixer.createDimension(Dimension.NETHER, -256, 768, 512, options);
|
||||
|
||||
assertEquals(1D, json.getDouble("ambient_light"), 0D);
|
||||
assertEquals("#ffffff", json.getJSONObject("attributes")
|
||||
.getString("minecraft:visual/ambient_light_color"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackCaveProfileValidatorTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void rejectsLegacyWaterFieldsAcrossEveryCaveProfileLocation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"caveProfile\":{\"allowWater\":false}}");
|
||||
write(pack, "regions/nested/region.json", "{\"caveProfile\":{\"waterMinDepthBelowSurface\":20}}");
|
||||
write(pack, "biomes/nested/biome.json", "{\"caveProfile\":{\"waterRequiresFloor\":true}}");
|
||||
write(pack, "snippet/cave-profile/nested/profile.json", "{\"allowWater\":true}");
|
||||
|
||||
assertEquals(List.of(
|
||||
"Dimension 'main' caveProfile.allowWater was removed; use caveProfile.allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water.",
|
||||
"Region 'nested/region' caveProfile.waterMinDepthBelowSurface was removed; use caveProfile.fluidMinDepthBelowSurface. Cave aquifers use the dimension fluidPalette, which defaults to water.",
|
||||
"Biome 'nested/biome' caveProfile.waterRequiresFloor was removed; use caveProfile.fluidRequiresFloor. Cave aquifers use the dimension fluidPalette, which defaults to water.",
|
||||
"Cave-profile snippet 'nested/profile' allowWater was removed; use allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water."
|
||||
), PackCaveProfileValidator.validateLegacyFields(pack));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsGenericFluidFields() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"caveProfile\":{\"allowFluid\":true,"
|
||||
+ "\"fluidMinDepthBelowSurface\":20,\"fluidRequiresFloor\":true}}");
|
||||
write(pack, "snippet/cave-profile/profile.json", "{\"allowFluid\":true,"
|
||||
+ "\"fluidMinDepthBelowSurface\":20,\"fluidRequiresFloor\":true}");
|
||||
|
||||
assertTrue(PackCaveProfileValidator.validateLegacyFields(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyFieldBlocksFullPackValidation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}");
|
||||
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
|
||||
write(pack, "biomes/biome.json", "{\"name\":\"Biome\",\"caveProfile\":{\"allowWater\":false}}");
|
||||
|
||||
PackValidationResult result = PackValidator.validate(pack);
|
||||
|
||||
assertFalse(result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Biome 'biome' caveProfile.allowWater was removed; use caveProfile.allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water."));
|
||||
}
|
||||
|
||||
private void write(File root, String relative, String content) throws Exception {
|
||||
Path path = root.toPath().resolve(relative);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public class PackValidationCacheTest {
|
||||
Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath();
|
||||
Files.writeString(cache, """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"schemaVersion": 2,
|
||||
"contentFingerprint": "content",
|
||||
"contextFingerprint": "context",
|
||||
"results": [
|
||||
@@ -91,6 +91,24 @@ public class PackValidationCacheTest {
|
||||
cache, "content", "context", List.of("overworld")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void previousValidationSchemaIsRejected() throws Exception {
|
||||
Path cache = new File(temporaryFolder.newFolder("previous-schema"), "validation.json").toPath();
|
||||
Files.writeString(cache, """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contentFingerprint": "content",
|
||||
"contextFingerprint": "context",
|
||||
"results": [
|
||||
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1}
|
||||
]
|
||||
}
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(PackValidationCache.load(
|
||||
cache, "content", "context", List.of("overworld")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinkCacheIsRejected() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("symbolic-cache").toPath();
|
||||
|
||||
@@ -19,15 +19,25 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidationRegistryTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
PackValidationRegistry.clear();
|
||||
@@ -60,6 +70,56 @@ public class PackValidationRegistryTest {
|
||||
assertEquals(result, PackValidationRegistry.requireLoadable("overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactRootsWithTheSameBasenameRemainIndependent() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("first").toPath().resolve("pack");
|
||||
Path secondRoot = temporaryFolder.newFolder("second").toPath().resolve("pack");
|
||||
PackValidationResult loadable = new PackValidationResult(
|
||||
"pack", List.of(), List.of(), 1L);
|
||||
PackValidationResult broken = new PackValidationResult(
|
||||
"pack", List.of("second snapshot is broken"), List.of(), 2L);
|
||||
|
||||
PackValidationRegistry.publish(firstRoot, loadable);
|
||||
PackValidationRegistry.publish(secondRoot, broken);
|
||||
|
||||
assertEquals(loadable, PackValidationRegistry.requireLoadable(firstRoot));
|
||||
assertEquals(broken, PackValidationRegistry.get(secondRoot));
|
||||
assertTrue(PackValidationRegistry.isBroken(secondRoot));
|
||||
assertNull(PackValidationRegistry.get("pack"));
|
||||
assertBroken(secondRoot, "second snapshot is broken");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removingOneExactRootDoesNotEvictItsSameNamedSibling() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("remove-first").toPath().resolve("pack");
|
||||
Path secondRoot = temporaryFolder.newFolder("keep-second").toPath().resolve("pack");
|
||||
PackValidationResult first = new PackValidationResult("pack", List.of(), List.of(), 1L);
|
||||
PackValidationResult second = new PackValidationResult("pack", List.of(), List.of(), 2L);
|
||||
PackValidationRegistry.publish(firstRoot, first);
|
||||
PackValidationRegistry.publish(secondRoot, second);
|
||||
|
||||
PackValidationRegistry.remove(firstRoot);
|
||||
|
||||
assertNull(PackValidationRegistry.get(firstRoot));
|
||||
assertEquals(second, PackValidationRegistry.requireLoadable(secondRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingRootAliasesResolveToTheSameRealPath() throws Exception {
|
||||
Path realRoot = temporaryFolder.newFolder("real-pack").toPath();
|
||||
Path linkedRoot = realRoot.getParent().resolve("linked-pack");
|
||||
try {
|
||||
Files.createSymbolicLink(linkedRoot, realRoot);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
PackValidationResult result = new PackValidationResult("pack", List.of(), List.of(), 1L);
|
||||
|
||||
PackValidationRegistry.publish(linkedRoot, result);
|
||||
|
||||
assertEquals(result, PackValidationRegistry.requireLoadable(realRoot));
|
||||
}
|
||||
|
||||
private void assertBroken(String pack, String expectedReason) {
|
||||
try {
|
||||
PackValidationRegistry.requireLoadable(pack);
|
||||
@@ -71,4 +131,16 @@ public class PackValidationRegistryTest {
|
||||
}
|
||||
throw new AssertionError("Expected pack validation to fail closed");
|
||||
}
|
||||
|
||||
private void assertBroken(Path packRoot, String expectedReason) {
|
||||
try {
|
||||
PackValidationRegistry.requireLoadable(packRoot);
|
||||
} catch (BrokenPackException e) {
|
||||
assertEquals(packRoot.toAbsolutePath().normalize().toString(), e.getPackName());
|
||||
assertTrue(e.getReasons().toString(), e.getReasons().stream().anyMatch(
|
||||
reason -> reason.contains(expectedReason)));
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("Expected pack validation to fail closed");
|
||||
}
|
||||
}
|
||||
|
||||
+41
-1
@@ -15,6 +15,10 @@ public class PackValidatorImportedStructurePolicyTest {
|
||||
public void denyOnlyPolicyAcceptsDisabledKeysAndAdjustments() {
|
||||
JSONObject policy = new JSONObject()
|
||||
.put("disabled", new JSONArray().put("minecraft:stronghold"))
|
||||
.put("disabledExact", new JSONArray().put("minecraft:ruined_portal"))
|
||||
.put("frequencyOverrides", new JSONArray().put(new JSONObject()
|
||||
.put("structureSet", "minecraft:nether_complexes")
|
||||
.put("multiplier", 1.1D)))
|
||||
.put("adjustments", new JSONArray().put(new JSONObject()
|
||||
.put("match", new JSONArray().put("minecraft:village"))));
|
||||
List<String> errors = validate(policy);
|
||||
@@ -22,6 +26,27 @@ public class PackValidatorImportedStructurePolicyTest {
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedFrequencyOverridesAreRejected() {
|
||||
JSONObject policy = new JSONObject()
|
||||
.put("frequencyOverrides", new JSONArray()
|
||||
.put("minecraft:nether_complexes")
|
||||
.put(new JSONObject().put("structureSet", "nether_complexes"))
|
||||
.put(new JSONObject()
|
||||
.put("structureSet", "minecraft:ruined_portals")
|
||||
.put("multiplier", 0D))
|
||||
.put(new JSONObject()
|
||||
.put("structureSet", "minecraft:nether_fossils")
|
||||
.put("multiplier", "often")));
|
||||
List<String> errors = validate(policy);
|
||||
|
||||
assertEquals(4, errors.size());
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[0] must be an object")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[1].structureSet")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[2].multiplier must be at least")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[3].multiplier must be a number")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encaseTerrainAndYBandAdjustmentsAreAccepted() {
|
||||
JSONObject policy = new JSONObject()
|
||||
@@ -99,14 +124,29 @@ public class PackValidatorImportedStructurePolicyTest {
|
||||
public void explicitNullsAndWrongShapesAreRejected() {
|
||||
JSONObject policy = new JSONObject()
|
||||
.put("disabled", JSONObject.NULL)
|
||||
.put("disabledExact", new JSONObject())
|
||||
.put("frequencyOverrides", JSONObject.NULL)
|
||||
.put("adjustments", new JSONObject());
|
||||
List<String> errors = validate(policy);
|
||||
|
||||
assertEquals(2, errors.size());
|
||||
assertEquals(4, errors.size());
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("'disabled' must be an array")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("'disabledExact' must be an array")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides must be an array")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("adjustments must be an array")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blankOrNonStringExactKeysAreRejected() {
|
||||
JSONObject policy = new JSONObject()
|
||||
.put("disabledExact", new JSONArray().put(" ").put(4));
|
||||
List<String> errors = validate(policy);
|
||||
|
||||
assertEquals(2, errors.size());
|
||||
assertTrue(errors.get(0).contains("'disabledExact' has a blank or non-string entry at index 0"));
|
||||
assertTrue(errors.get(1).contains("'disabledExact' has a blank or non-string entry at index 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitNullPolicyIsRejectedWhileOmissionUsesDefaults() {
|
||||
List<String> missingErrors = new ArrayList<>();
|
||||
|
||||
+8
-2
@@ -1,10 +1,12 @@
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
|
||||
import art.arcane.iris.engine.object.IrisVanillaStructureAdjustment;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
@@ -19,16 +21,20 @@ import static org.junit.Assert.assertTrue;
|
||||
* importedStructures.disabled and adjustments[].match accept family/namespace PREFIXES
|
||||
* ("minecraft:village", "nova_structures:") per IrisImportedStructureControl.matchesKey, so the
|
||||
* generated editor schema must not reject them with a strict registered-key enum. Prefix-capable
|
||||
* fields emit an anyOf of the registry enum plus a key/prefix pattern; exact-key fields (e.g.
|
||||
* nativeStructures[].structure) keep the strict enum.
|
||||
* fields emit an anyOf of the registry enum plus a key/prefix pattern. Exact-key fields, including
|
||||
* importedStructures.disabledExact and nativeStructures[].structure, keep the strict enum.
|
||||
*/
|
||||
public class VanillaStructurePrefixSchemaTest {
|
||||
@Test
|
||||
public void prefixCapableFieldsDeclareThePrefixAnnotation() throws NoSuchFieldException {
|
||||
assertTrue(IrisImportedStructureControl.class.getDeclaredField("disabled")
|
||||
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
|
||||
assertFalse(IrisImportedStructureControl.class.getDeclaredField("disabledExact")
|
||||
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
|
||||
assertTrue(IrisVanillaStructureAdjustment.class.getDeclaredField("match")
|
||||
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
|
||||
assertTrue(IrisStructureSetFrequencyOverride.class.getDeclaredField("structureSet")
|
||||
.isAnnotationPresent(RegistryListVanillaStructureSet.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,7 +2,11 @@ package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import org.junit.Assume;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
@@ -26,6 +30,11 @@ public class StudioSVCWorldPackPublishTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void clearValidationRegistry() {
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copiesToStageAndPublishesTheCompletePack() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("world").toPath();
|
||||
@@ -122,6 +131,24 @@ public class StudioSVCWorldPackPublishTest {
|
||||
assertFalse(Files.exists(target.resolve("rejected.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void finalPublishedSnapshotReplacesStalePathValidation() throws Exception {
|
||||
Path packRoot = temporaryFolder.newFolder("published-snapshot", "iris", "pack").toPath();
|
||||
writeValidPack(packRoot);
|
||||
PackValidationResult staleFailure = new PackValidationResult(
|
||||
"pack", List.of("stale failure"), List.of(), 1L);
|
||||
PackValidationRegistry.publish(packRoot, staleFailure);
|
||||
|
||||
PackValidationResult validated = StudioSVC.validatePublishedPack(packRoot);
|
||||
|
||||
assertTrue(validated.isLoadable());
|
||||
assertSame(validated, PackValidationRegistry.requireLoadable(packRoot));
|
||||
|
||||
Files.writeString(packRoot.resolve("dimensions/main.json"), "{");
|
||||
assertThrows(BrokenPackException.class, () -> StudioSVC.validatePublishedPack(packRoot));
|
||||
assertTrue(PackValidationRegistry.isBroken(packRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException {
|
||||
Path root = temporaryFolder.newFolder("project-cache-rollback").toPath();
|
||||
@@ -176,4 +203,13 @@ public class StudioSVCWorldPackPublishTest {
|
||||
secondGate.complete("second");
|
||||
assertEquals("second", second.join());
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
Files.createDirectories(packRoot.resolve("biomes"));
|
||||
Files.writeString(packRoot.resolve("dimensions/main.json"), "{\"regions\":[\"region\"]}");
|
||||
Files.writeString(packRoot.resolve("regions/region.json"), "{\"landBiomes\":[\"biome\"]}");
|
||||
Files.writeString(packRoot.resolve("biomes/biome.json"), "{\"name\":\"Biome\"}");
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class NativeStructureFrequencyScaleTest {
|
||||
@Test
|
||||
public void netherRandomSpreadSetsResolveToNearestLegalSpacing() {
|
||||
NativeStructureFrequencyScale complexes = NativeStructureFrequencyScale.randomSpread(
|
||||
1F, 27, 4, 1.1D);
|
||||
NativeStructureFrequencyScale portals = NativeStructureFrequencyScale.randomSpread(
|
||||
1F, 40, 15, 1.1D);
|
||||
NativeStructureFrequencyScale fossils = NativeStructureFrequencyScale.randomSpread(
|
||||
1F, 2, 1, 1.1D);
|
||||
|
||||
assertEquals(26, complexes.spacing());
|
||||
assertEquals(38, portals.spacing());
|
||||
assertEquals(2, fossils.spacing());
|
||||
assertEquals(1F, complexes.frequency(), 0F);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void probabilityScalesBeforeIntegerSpacing() {
|
||||
NativeStructureFrequencyScale increased = NativeStructureFrequencyScale.randomSpread(
|
||||
0.5F, 32, 8, 1.5D);
|
||||
NativeStructureFrequencyScale decreased = NativeStructureFrequencyScale.randomSpread(
|
||||
1F, 32, 8, 0.25D);
|
||||
|
||||
assertEquals(32, increased.spacing());
|
||||
assertEquals(0.75F, increased.frequency(), 0F);
|
||||
assertEquals(32, decreased.spacing());
|
||||
assertEquals(0.25F, decreased.frequency(), 0F);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPlacementInputsFailClosed() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativeStructureFrequencyScale.randomSpread(1F, 8, 8, 1.1D));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativeStructureFrequencyScale.randomSpread(1F, 32, 8, Double.NaN));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NativeStructureFrequencyScale.probability(1F, 17D));
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -53,12 +53,28 @@ public class NativeStructureGenerationPolicyTest {
|
||||
assertFalse(decision.generate());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactDisabledKeyDoesNotDisableSiblingVariant() {
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl();
|
||||
control.getDisabledExact().add("minecraft:ruined_portal");
|
||||
Engine engine = engineWithControlAndRegionPlacement(control, "nova_structures:tavern_oak");
|
||||
|
||||
assertEquals(NativeStructureGenerationStatus.DISABLED_BY_PACK,
|
||||
NativeStructureGenerationPolicy.resolve(engine, "minecraft:ruined_portal", false).status());
|
||||
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
NativeStructureGenerationPolicy.resolve(engine, "minecraft:ruined_portal_nether", false).status());
|
||||
}
|
||||
|
||||
private Engine engineWithDisabledNamespaceAndRegionPlacement(String placedKey) {
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl();
|
||||
control.getDisabled().add("nova_structures:");
|
||||
return engineWithControlAndRegionPlacement(control, placedKey);
|
||||
}
|
||||
|
||||
private Engine engineWithControlAndRegionPlacement(IrisImportedStructureControl control, String placedKey) {
|
||||
IrisData data = mock(IrisData.class);
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl();
|
||||
control.getDisabled().add("nova_structures:");
|
||||
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement();
|
||||
placement.getNativeStructures().add(new IrisNativeStructure().setStructure(placedKey));
|
||||
|
||||
+81
-70
@@ -52,7 +52,7 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
private static Field surfaceBreakDensityField;
|
||||
private static Field thresholdRngField;
|
||||
private static Field carveAirField;
|
||||
private static Field carveWaterField;
|
||||
private static Field carveFluidField;
|
||||
private static Field carveLavaField;
|
||||
private static Field carveForcedAirField;
|
||||
|
||||
@@ -74,14 +74,25 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
thresholdRngField.setAccessible(true);
|
||||
carveAirField = IrisCaveCarver3D.class.getDeclaredField("carveAir");
|
||||
carveAirField.setAccessible(true);
|
||||
carveWaterField = IrisCaveCarver3D.class.getDeclaredField("carveWater");
|
||||
carveWaterField.setAccessible(true);
|
||||
carveFluidField = IrisCaveCarver3D.class.getDeclaredField("carveFluid");
|
||||
carveFluidField.setAccessible(true);
|
||||
carveLavaField = IrisCaveCarver3D.class.getDeclaredField("carveLava");
|
||||
carveLavaField.setAccessible(true);
|
||||
carveForcedAirField = IrisCaveCarver3D.class.getDeclaredField("carveForcedAir");
|
||||
carveForcedAirField.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void genericFluidContractKeepsOverworldDefaults() {
|
||||
IrisCaveProfile profile = new IrisCaveProfile();
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
|
||||
assertTrue(profile.isAllowFluid());
|
||||
assertEquals(12, profile.getFluidMinDepthBelowSurface());
|
||||
assertTrue(profile.isFluidRequiresFloor());
|
||||
assertEquals("water", dimension.getFluidPalette().getPalette().get(0).getBlock());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvedCellDistributionStableAcrossEquivalentCarvers() {
|
||||
Engine engine = createEngine(128, 92);
|
||||
@@ -194,12 +205,12 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
double[] columnWeights = fullWeights();
|
||||
int[] precomputedSurfaceHeights = filledHeights(46);
|
||||
|
||||
IrisCaveProfile lavaProfile = createProfile(false, false).setAllowLava(true).setAllowWater(false);
|
||||
IrisCaveProfile lavaProfile = createProfile(false, false).setAllowLava(true).setAllowFluid(false);
|
||||
IrisCaveCarver3D lavaCarver = new IrisCaveCarver3D(engine, lavaProfile);
|
||||
WriterCapture lavaCapture = createWriterCapture(48);
|
||||
lavaCarver.carve(lavaCapture.writer, 0, 0, columnWeights, 0D, 0D, new IrisRange(0D, 80D), precomputedSurfaceHeights);
|
||||
|
||||
IrisCaveProfile forcedAirProfile = createProfile(false, false).setAllowLava(false).setAllowWater(false);
|
||||
IrisCaveProfile forcedAirProfile = createProfile(false, false).setAllowLava(false).setAllowFluid(false);
|
||||
IrisCaveCarver3D forcedAirCarver = new IrisCaveCarver3D(engine, forcedAirProfile);
|
||||
WriterCapture forcedAirCapture = createWriterCapture(48);
|
||||
forcedAirCarver.carve(forcedAirCapture.writer, 0, 0, columnWeights, 0D, 0D, new IrisRange(0D, 80D), precomputedSurfaceHeights);
|
||||
@@ -211,22 +222,22 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waterPrecedesForcedAirWhenLavaIsDisabled() {
|
||||
public void fluidPrecedesForcedAirWhenLavaIsDisabled() {
|
||||
Engine engine = createEngine(48, 46);
|
||||
int[] surfaceHeights = filledHeights(46);
|
||||
IrisCaveProfile wetProfile = createWaterProfile()
|
||||
IrisCaveProfile wetProfile = createFluidProfile()
|
||||
.setVerticalRange(new IrisRange(2D, 18D))
|
||||
.setAllowLava(false)
|
||||
.setWaterRequiresFloor(false);
|
||||
.setFluidRequiresFloor(false);
|
||||
WriterCapture wetCapture = createWriterCapture(48);
|
||||
new IrisCaveCarver3D(engine, wetProfile).carve(
|
||||
wetCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
|
||||
IrisCaveProfile dryProfile = createWaterProfile()
|
||||
IrisCaveProfile dryProfile = createFluidProfile()
|
||||
.setVerticalRange(new IrisRange(2D, 18D))
|
||||
.setAllowWater(false)
|
||||
.setAllowFluid(false)
|
||||
.setAllowLava(false)
|
||||
.setWaterRequiresFloor(false);
|
||||
.setFluidRequiresFloor(false);
|
||||
WriterCapture dryCapture = createWriterCapture(48);
|
||||
new IrisCaveCarver3D(engine, dryProfile).carve(
|
||||
dryCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
@@ -239,16 +250,16 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waterToggleAndMinimumDepthUseEachColumnsTerrainSurface() {
|
||||
public void fluidToggleAndMinimumDepthUseEachColumnsTerrainSurface() {
|
||||
Engine engine = createEngine(80, 70);
|
||||
int[] surfaceHeights = splitSurfaceHeights(40, 70);
|
||||
|
||||
IrisCaveProfile enabledProfile = createWaterProfile().setAllowWater(true).setWaterMinDepthBelowSurface(10);
|
||||
IrisCaveProfile enabledProfile = createFluidProfile().setAllowFluid(true).setFluidMinDepthBelowSurface(10);
|
||||
WriterCapture enabledCapture = createWriterCapture(80);
|
||||
new IrisCaveCarver3D(engine, enabledProfile).carve(
|
||||
enabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
|
||||
IrisCaveProfile disabledProfile = createWaterProfile().setAllowWater(false).setWaterMinDepthBelowSurface(10);
|
||||
IrisCaveProfile disabledProfile = createFluidProfile().setAllowFluid(false).setFluidMinDepthBelowSurface(10);
|
||||
WriterCapture disabledCapture = createWriterCapture(80);
|
||||
new IrisCaveCarver3D(engine, disabledProfile).carve(
|
||||
disabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
@@ -256,15 +267,15 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
assertEquals(enabledCapture.carvedCells, disabledCapture.carvedCells);
|
||||
assertTrue(countLiquid(enabledCapture, (byte) 1) > 0);
|
||||
assertEquals(0, countLiquid(disabledCapture, (byte) 1));
|
||||
assertWaterRespectsSplitCutoff(enabledCapture, 30, 60);
|
||||
assertFluidRespectsSplitCutoff(enabledCapture, 30, 60);
|
||||
assertTrue(enabledCapture.carvedCells.contains(cellKey(0, 40, 0)));
|
||||
assertTrue(enabledCapture.carvedCells.contains(cellKey(15, 70, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dimensionFluidHeightCapsWaterIntent() {
|
||||
public void dimensionFluidHeightCapsFluidIntent() {
|
||||
Engine engine = createEngine(80, 70);
|
||||
IrisCaveProfile profile = createWaterProfile().setWaterMinDepthBelowSurface(0);
|
||||
IrisCaveProfile profile = createFluidProfile().setFluidMinDepthBelowSurface(0);
|
||||
WriterCapture capture = createWriterCapture(80);
|
||||
|
||||
new IrisCaveCarver3D(engine, profile).carve(
|
||||
@@ -276,36 +287,36 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void floorRequiredWaterResolvesAfterTheCompleteCarveMask() {
|
||||
public void floorRequiredFluidResolvesAfterTheCompleteCarveMask() {
|
||||
Engine engine = createEngine(80, 70);
|
||||
int[] surfaceHeights = filledHeights(70);
|
||||
int chunkX = -8;
|
||||
int chunkZ = -1;
|
||||
|
||||
IrisStyledRange cupThreshold = new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT));
|
||||
IrisCaveProfile supportedProfile = createWaterProfile()
|
||||
IrisCaveProfile supportedProfile = createFluidProfile()
|
||||
.setDensityThreshold(cupThreshold)
|
||||
.setWaterMinDepthBelowSurface(0)
|
||||
.setWaterRequiresFloor(true);
|
||||
.setFluidMinDepthBelowSurface(0)
|
||||
.setFluidRequiresFloor(true);
|
||||
WriterCapture firstCapture = createWriterCapture(80);
|
||||
CaveWaterSupportPlan supportPlan = new CaveWaterSupportPlan();
|
||||
CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan();
|
||||
new IrisCaveCarver3D(engine, supportedProfile).carve(
|
||||
firstCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights, null, supportPlan);
|
||||
int candidateCount = countLiquid(firstCapture, (byte) 1);
|
||||
supportPlan.resolve(firstCapture.writer.acquireChunk(chunkX, chunkZ));
|
||||
|
||||
IrisCaveProfile repeatedProfile = createWaterProfile()
|
||||
IrisCaveProfile repeatedProfile = createFluidProfile()
|
||||
.setDensityThreshold(cupThreshold)
|
||||
.setWaterMinDepthBelowSurface(0)
|
||||
.setWaterRequiresFloor(true);
|
||||
.setFluidMinDepthBelowSurface(0)
|
||||
.setFluidRequiresFloor(true);
|
||||
WriterCapture secondCapture = createWriterCapture(80);
|
||||
new IrisCaveCarver3D(engine, repeatedProfile).carve(
|
||||
secondCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
|
||||
IrisCaveProfile unrestrictedProfile = createWaterProfile()
|
||||
IrisCaveProfile unrestrictedProfile = createFluidProfile()
|
||||
.setDensityThreshold(cupThreshold)
|
||||
.setWaterMinDepthBelowSurface(0)
|
||||
.setWaterRequiresFloor(false);
|
||||
.setFluidMinDepthBelowSurface(0)
|
||||
.setFluidRequiresFloor(false);
|
||||
WriterCapture unrestrictedCapture = createWriterCapture(80);
|
||||
new IrisCaveCarver3D(engine, unrestrictedProfile).carve(
|
||||
unrestrictedCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights);
|
||||
@@ -314,23 +325,23 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids);
|
||||
assertTrue(countLiquid(firstCapture, (byte) 1) > 0);
|
||||
assertTrue(countLiquid(firstCapture, (byte) 1) < countLiquid(unrestrictedCapture, (byte) 1));
|
||||
assertWaterCellsHaveSolidSupport(firstCapture);
|
||||
assertFluidCellsHaveSolidSupport(firstCapture);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void finalWaterSupportRejectsUnknownNeighborChunkEdges() {
|
||||
public void finalFluidSupportRejectsUnknownNeighborChunkEdges() {
|
||||
WriterCapture capture = createWriterCapture(80);
|
||||
MantleChunk<Matter> chunk = capture.writer.acquireChunk(0, 0);
|
||||
MatterSlice<MatterCavern> slice = chunk.getOrCreate(3).slice(MatterCavern.class);
|
||||
MatterCavern water = new MatterCavern(true, "", (byte) 1);
|
||||
MatterCavern fluid = new MatterCavern(true, "", (byte) 1);
|
||||
MatterCavern air = new MatterCavern(true, "", (byte) 0);
|
||||
int y = 56;
|
||||
int z = 8;
|
||||
slice.set(0, y & 15, z, water);
|
||||
slice.set(8, y & 15, z, water);
|
||||
CaveWaterSupportPlan supportPlan = new CaveWaterSupportPlan();
|
||||
supportPlan.add(0, y, z, water, air);
|
||||
supportPlan.add(8, y, z, water, air);
|
||||
slice.set(0, y & 15, z, fluid);
|
||||
slice.set(8, y & 15, z, fluid);
|
||||
CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan();
|
||||
supportPlan.add(0, y, z, fluid, air);
|
||||
supportPlan.add(8, y, z, fluid, air);
|
||||
|
||||
supportPlan.resolve(chunk);
|
||||
|
||||
@@ -339,29 +350,29 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void floorRequiredWaterSeesLaterProfileCarvePasses() {
|
||||
public void floorRequiredFluidSeesLaterProfileCarvePasses() {
|
||||
Engine engine = createEngine(80, 70);
|
||||
int[] surfaceHeights = filledHeights(70);
|
||||
WriterCapture capture = createWriterCapture(80);
|
||||
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
|
||||
IrisCaveProfile waterProfile = createWaterProfile()
|
||||
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
|
||||
IrisCaveProfile fluidProfile = createFluidProfile()
|
||||
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)))
|
||||
.setWaterRequiresFloor(true);
|
||||
IrisCaveProfile airProfile = createWaterProfile().setAllowWater(false);
|
||||
.setFluidRequiresFloor(true);
|
||||
IrisCaveProfile airProfile = createFluidProfile().setAllowFluid(false);
|
||||
|
||||
new IrisCaveCarver3D(engine, waterProfile).carve(
|
||||
new IrisCaveCarver3D(engine, fluidProfile).carve(
|
||||
capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights,
|
||||
new IrisRange(20D, 64D), waterSupportPlan);
|
||||
String waterCell = firstCellWithLiquid(capture, (byte) 1);
|
||||
assertTrue(waterCell != null);
|
||||
int waterY = coordinate(waterCell, 1);
|
||||
new IrisRange(20D, 64D), fluidSupportPlan);
|
||||
String fluidCell = firstCellWithLiquid(capture, (byte) 1);
|
||||
assertTrue(fluidCell != null);
|
||||
int fluidY = coordinate(fluidCell, 1);
|
||||
new IrisCaveCarver3D(engine, airProfile).carve(
|
||||
capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights,
|
||||
new IrisRange(waterY - 1D, waterY - 1D), waterSupportPlan);
|
||||
new IrisRange(fluidY - 1D, fluidY - 1D), fluidSupportPlan);
|
||||
|
||||
assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(waterCell));
|
||||
waterSupportPlan.resolve(capture.writer.acquireChunk(0, 0));
|
||||
assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(waterCell));
|
||||
assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(fluidCell));
|
||||
fluidSupportPlan.resolve(capture.writer.acquireChunk(0, 0));
|
||||
assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(fluidCell));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -474,7 +485,7 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
CNG surfaceBreakDensity = (CNG) surfaceBreakDensityField.get(carver);
|
||||
RNG thresholdRng = (RNG) thresholdRngField.get(carver);
|
||||
MatterCavern carveAir = (MatterCavern) carveAirField.get(carver);
|
||||
MatterCavern carveWater = (MatterCavern) carveWaterField.get(carver);
|
||||
MatterCavern carveFluid = (MatterCavern) carveFluidField.get(carver);
|
||||
MatterCavern carveLava = (MatterCavern) carveLavaField.get(carver);
|
||||
MatterCavern carveForcedAir = (MatterCavern) carveForcedAirField.get(carver);
|
||||
|
||||
@@ -505,7 +516,7 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
int[] columnTopY = new int[256];
|
||||
int[] surfaceBreakFloorY = new int[256];
|
||||
boolean[] surfaceBreakColumn = new boolean[256];
|
||||
int[] waterMaxY = new int[256];
|
||||
int[] fluidMaxY = new int[256];
|
||||
double[] passThreshold = new double[256];
|
||||
double[] verticalEdgeFade = computeVerticalEdgeFade(profile, minY, maxY);
|
||||
MatterCavern[] matterByY = computeMatterByY(engine, profile, carveAir, carveLava, carveForcedAir, minY, maxY);
|
||||
@@ -528,8 +539,8 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
boolean breakColumn = allowSurfaceBreak && signed(surfaceBreakDensity.noiseFast2D(x, z)) >= surfaceBreakNoiseThreshold;
|
||||
int resolvedTopY = breakColumn ? Math.min(maxY, Math.max(minY, columnSurfaceY)) : clearanceTopY;
|
||||
columnTopY[columnIndex] = resolvedTopY;
|
||||
waterMaxY[columnIndex] = profile.isAllowWater()
|
||||
? Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - Math.max(0, profile.getWaterMinDepthBelowSurface()))
|
||||
fluidMaxY[columnIndex] = profile.isAllowFluid()
|
||||
? Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - Math.max(0, profile.getFluidMinDepthBelowSurface()))
|
||||
: Integer.MIN_VALUE;
|
||||
surfaceBreakFloorY[columnIndex] = Math.max(minY, columnSurfaceY - surfaceBreakDepth);
|
||||
surfaceBreakColumn[columnIndex] = breakColumn;
|
||||
@@ -576,9 +587,9 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
MatterSlice<MatterCavern> cavernSlice = sectionMatter.slice(MatterCavern.class);
|
||||
MatterCavern verticalMatter = matterByY[y - minY];
|
||||
boolean aquifer = verticalMatter == carveAir
|
||||
&& y <= waterMaxY[columnIndex]
|
||||
&& y <= fluidMaxY[columnIndex]
|
||||
&& (boolean) aquiferCandidateMethod.invoke(carver, x, y, z, localThreshold);
|
||||
MatterCavern matter = aquifer ? carveWater : verticalMatter;
|
||||
MatterCavern matter = aquifer ? carveFluid : verticalMatter;
|
||||
cavernSlice.set(localX, y & 15, localZ, matter);
|
||||
carved++;
|
||||
}
|
||||
@@ -678,9 +689,9 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
profile.setSurfaceBreakNoiseThreshold(0.16D);
|
||||
profile.setSurfaceBreakDepth(12);
|
||||
profile.setSurfaceBreakThresholdBoost(0.17D);
|
||||
profile.setAllowWater(true);
|
||||
profile.setWaterMinDepthBelowSurface(8);
|
||||
profile.setWaterRequiresFloor(false);
|
||||
profile.setAllowFluid(true);
|
||||
profile.setFluidMinDepthBelowSurface(8);
|
||||
profile.setFluidRequiresFloor(false);
|
||||
profile.setAllowLava(true);
|
||||
if (modules) {
|
||||
KList<IrisCaveFieldModule> caveModules = new KList<>();
|
||||
@@ -705,7 +716,7 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
return profile;
|
||||
}
|
||||
|
||||
private IrisCaveProfile createWaterProfile() {
|
||||
private IrisCaveProfile createFluidProfile() {
|
||||
return createProfile(false, false)
|
||||
.setVerticalRange(new IrisRange(20D, 70D))
|
||||
.setVerticalEdgeFade(0)
|
||||
@@ -716,8 +727,8 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
.setAllowSurfaceBreak(true)
|
||||
.setSurfaceBreakNoiseThreshold(-1D)
|
||||
.setSurfaceBreakThresholdBoost(0D)
|
||||
.setAllowWater(true)
|
||||
.setWaterRequiresFloor(false)
|
||||
.setAllowFluid(true)
|
||||
.setFluidRequiresFloor(false)
|
||||
.setAllowLava(true);
|
||||
}
|
||||
|
||||
@@ -803,7 +814,7 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
return heights;
|
||||
}
|
||||
|
||||
private void assertWaterCellsHaveSolidSupport(WriterCapture capture) {
|
||||
private void assertFluidCellsHaveSolidSupport(WriterCapture capture) {
|
||||
for (Map.Entry<String, Byte> entry : capture.carvedLiquids.entrySet()) {
|
||||
if (entry.getValue() != 1) {
|
||||
continue;
|
||||
@@ -835,9 +846,9 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void assertWaterRespectsSplitCutoff(WriterCapture capture, int lowCutoff, int highCutoff) {
|
||||
boolean lowWater = false;
|
||||
boolean highWater = false;
|
||||
private void assertFluidRespectsSplitCutoff(WriterCapture capture, int lowCutoff, int highCutoff) {
|
||||
boolean lowFluid = false;
|
||||
boolean highFluid = false;
|
||||
for (Map.Entry<String, Byte> entry : capture.carvedLiquids.entrySet()) {
|
||||
if (entry.getValue() != 1) {
|
||||
continue;
|
||||
@@ -846,14 +857,14 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
int y = coordinate(entry.getKey(), 1);
|
||||
if (x < 8) {
|
||||
assertTrue(y <= lowCutoff);
|
||||
lowWater = true;
|
||||
lowFluid = true;
|
||||
} else {
|
||||
assertTrue(y <= highCutoff);
|
||||
highWater = true;
|
||||
highFluid = true;
|
||||
}
|
||||
}
|
||||
assertTrue(lowWater);
|
||||
assertTrue(highWater);
|
||||
assertTrue(lowFluid);
|
||||
assertTrue(highFluid);
|
||||
}
|
||||
|
||||
private void assertLiquidAtOrBelow(WriterCapture capture, byte liquid, int maxY) {
|
||||
|
||||
+4
-3
@@ -15,7 +15,7 @@ public class IrisCarveModifierFluidIntentTest {
|
||||
@Test
|
||||
public void explicitCavernIntentsOverrideExistingFluid() {
|
||||
MatterCavern airIntent = new MatterCavern(true, "", (byte) 0);
|
||||
MatterCavern waterIntent = new MatterCavern(true, "", (byte) 1);
|
||||
MatterCavern fluidIntent = new MatterCavern(true, "", (byte) 1);
|
||||
MatterCavern lavaIntent = new MatterCavern(true, "", (byte) 2);
|
||||
MatterCavern forcedAirIntent = new MatterCavern(true, "", (byte) 3);
|
||||
PlatformBlockState existingFluid = mock(PlatformBlockState.class);
|
||||
@@ -26,11 +26,12 @@ public class IrisCarveModifierFluidIntentTest {
|
||||
|
||||
assertFalse(IrisCarveModifier.hasExplicitCarveIntent(null));
|
||||
assertTrue(IrisCarveModifier.shouldPreserveExistingFluid(airIntent, existingFluid));
|
||||
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(waterIntent, existingFluid));
|
||||
assertTrue(IrisCarveModifier.isFluidIntent(fluidIntent));
|
||||
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(fluidIntent, existingFluid));
|
||||
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(lavaIntent, existingFluid));
|
||||
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(forcedAirIntent, existingFluid));
|
||||
assertNull(IrisCarveModifier.resolveExplicitCarveState(null, fluid, lava, air));
|
||||
assertSame(fluid, IrisCarveModifier.resolveExplicitCarveState(waterIntent, fluid, lava, air));
|
||||
assertSame(fluid, IrisCarveModifier.resolveExplicitCarveState(fluidIntent, fluid, lava, air));
|
||||
assertSame(lava, IrisCarveModifier.resolveExplicitCarveState(lavaIntent, fluid, lava, air));
|
||||
assertSame(air, IrisCarveModifier.resolveExplicitCarveState(forcedAirIntent, fluid, lava, air));
|
||||
assertNull(IrisCarveModifier.resolveExplicitCarveState(airIntent, fluid, lava, air));
|
||||
|
||||
+96
-3
@@ -10,20 +10,54 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisDimensionReachableBiomesTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void includesOnlyBiomesReachableThroughSelectedRegions() {
|
||||
public void includesExactRecursiveGenerationClosure() {
|
||||
IrisDimensionCarvingEntry deepBand = new IrisDimensionCarvingEntry()
|
||||
.setId("global-deep-band")
|
||||
.setBiome("deep-root");
|
||||
IrisDimensionCarvingEntry disabledBand = new IrisDimensionCarvingEntry()
|
||||
.setId("disabled-band")
|
||||
.setEnabled(false)
|
||||
.setBiome("disabled-deep");
|
||||
IrisDimensionCarvingEntry floatingCarvingEntry = new IrisDimensionCarvingEntry()
|
||||
.setId("floating-carving-entry")
|
||||
.setEnabled(false)
|
||||
.setBiome("entry-floating-carve");
|
||||
IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable", "missing"));
|
||||
dimension.setCarving(new KList<>(deepBand, disabledBand, floatingCarvingEntry));
|
||||
IrisRegion reachable = new IrisRegion()
|
||||
.setLandBiomes(new KList<>("parent", "shared"))
|
||||
.setSeaBiomes(new KList<>("shared"));
|
||||
IrisBiome parent = biome("parent").setChildren(new KList<>("child", "shared")).setCarvingBiome("carve");
|
||||
IrisBiome parent = biome("parent")
|
||||
.setChildren(new KList<>("child", "shared"))
|
||||
.setCarvingBiome("carve")
|
||||
.setFloatingChildBiomes(new KList<>(floating("floating-target", "direct-floating-carve")));
|
||||
IrisBiome child = biome("child").setChildren(new KList<>("parent"));
|
||||
IrisBiome shared = biome("shared");
|
||||
IrisBiome carve = biome("carve");
|
||||
IrisBiome floatingTarget = biome("floating-target")
|
||||
.setChildren(new KList<>("floating-child"))
|
||||
.setCarvingBiome("floating-carve");
|
||||
IrisBiome floatingChild = biome("floating-child")
|
||||
.setFloatingChildBiomes(new KList<>(floating("nested-floating", "floating-carving-entry")));
|
||||
IrisBiome floatingCarve = biome("floating-carve");
|
||||
IrisBiome directFloatingCarve = biome("direct-floating-carve");
|
||||
IrisBiome entryFloatingCarve = biome("entry-floating-carve");
|
||||
IrisBiome shadowedFloatingCarve = biome("floating-carving-entry");
|
||||
IrisBiome nestedFloating = biome("nested-floating")
|
||||
.setFloatingChildBiomes(new KList<>(floating("parent")));
|
||||
IrisBiome deepRoot = biome("deep-root").setChildren(new KList<>("deep-child"));
|
||||
IrisBiome deepChild = biome("deep-child").setCarvingBiome("deep-carve");
|
||||
IrisBiome deepCarve = biome("deep-carve")
|
||||
.setFloatingChildBiomes(new KList<>(floating("deep-floating")));
|
||||
IrisBiome deepFloating = biome("deep-floating").setChildren(new KList<>("deep-root"));
|
||||
IrisBiome disabledDeep = biome("disabled-deep");
|
||||
IrisBiome unused = biome("unused");
|
||||
|
||||
IrisData data = mock(IrisData.class);
|
||||
@@ -36,18 +70,77 @@ public class IrisDimensionReachableBiomesTest {
|
||||
when(biomeLoader.load("child")).thenReturn(child);
|
||||
when(biomeLoader.load("shared")).thenReturn(shared);
|
||||
when(biomeLoader.load("carve")).thenReturn(carve);
|
||||
when(biomeLoader.load("floating-target")).thenReturn(floatingTarget);
|
||||
when(biomeLoader.load("floating-child")).thenReturn(floatingChild);
|
||||
when(biomeLoader.load("floating-carve")).thenReturn(floatingCarve);
|
||||
when(biomeLoader.load("direct-floating-carve")).thenReturn(directFloatingCarve);
|
||||
when(biomeLoader.load("entry-floating-carve")).thenReturn(entryFloatingCarve);
|
||||
when(biomeLoader.load("floating-carving-entry")).thenReturn(shadowedFloatingCarve);
|
||||
when(biomeLoader.load("nested-floating")).thenReturn(nestedFloating);
|
||||
when(biomeLoader.load("deep-root")).thenReturn(deepRoot);
|
||||
when(biomeLoader.load("deep-child")).thenReturn(deepChild);
|
||||
when(biomeLoader.load("deep-carve")).thenReturn(deepCarve);
|
||||
when(biomeLoader.load("deep-floating")).thenReturn(deepFloating);
|
||||
when(biomeLoader.load("disabled-deep")).thenReturn(disabledDeep);
|
||||
when(biomeLoader.load("unused")).thenReturn(unused);
|
||||
|
||||
KList<IrisBiome> biomes = dimension.getReachableBiomes(() -> data);
|
||||
Set<String> keys = biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet());
|
||||
|
||||
assertEquals(Set.of("parent", "child", "shared", "carve"), keys);
|
||||
assertEquals(Set.of(
|
||||
"parent", "child", "shared", "carve",
|
||||
"floating-target", "floating-child", "floating-carve", "direct-floating-carve",
|
||||
"entry-floating-carve", "nested-floating",
|
||||
"deep-root", "deep-child", "deep-carve", "deep-floating"
|
||||
), keys);
|
||||
assertEquals(keys.size(), biomes.size());
|
||||
}
|
||||
|
||||
@Test(timeout = 1000L)
|
||||
@SuppressWarnings("unchecked")
|
||||
public void terminatesMixedDependencyCyclesWithoutReloadingBiomes() {
|
||||
IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable"));
|
||||
IrisRegion reachable = new IrisRegion().setLandBiomes(new KList<>("a"));
|
||||
IrisBiome a = biome("a").setChildren(new KList<>("b"));
|
||||
IrisBiome b = biome("b").setCarvingBiome("c");
|
||||
IrisBiome c = biome("c").setFloatingChildBiomes(new KList<>(floating("d", "a")));
|
||||
IrisBiome d = biome("d")
|
||||
.setChildren(new KList<>("a"))
|
||||
.setFloatingChildBiomes(new KList<>(floating("b")));
|
||||
|
||||
IrisData data = mock(IrisData.class);
|
||||
ResourceLoader<IrisRegion> regionLoader = mock(ResourceLoader.class);
|
||||
ResourceLoader<IrisBiome> biomeLoader = mock(ResourceLoader.class);
|
||||
when(data.getRegionLoader()).thenReturn(regionLoader);
|
||||
when(data.getBiomeLoader()).thenReturn(biomeLoader);
|
||||
when(regionLoader.load("reachable")).thenReturn(reachable);
|
||||
when(biomeLoader.load("a")).thenReturn(a);
|
||||
when(biomeLoader.load("b")).thenReturn(b);
|
||||
when(biomeLoader.load("c")).thenReturn(c);
|
||||
when(biomeLoader.load("d")).thenReturn(d);
|
||||
|
||||
KList<IrisBiome> biomes = dimension.getReachableBiomes(() -> data);
|
||||
Set<String> keys = biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet());
|
||||
|
||||
assertEquals(Set.of("a", "b", "c", "d"), keys);
|
||||
assertEquals(keys.size(), biomes.size());
|
||||
verify(biomeLoader, times(1)).load("a");
|
||||
verify(biomeLoader, times(1)).load("b");
|
||||
verify(biomeLoader, times(1)).load("c");
|
||||
verify(biomeLoader, times(1)).load("d");
|
||||
}
|
||||
|
||||
private IrisBiome biome(String loadKey) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(loadKey);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private IrisFloatingChildBiomes floating(String biomeKey) {
|
||||
return new IrisFloatingChildBiomes().setBiome(biomeKey);
|
||||
}
|
||||
|
||||
private IrisFloatingChildBiomes floating(String biomeKey, String carvingKey) {
|
||||
return floating(biomeKey).setCarving(carvingKey);
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -45,6 +45,25 @@ public class IrisImportedStructureControlTest {
|
||||
assertTrue(control.shouldGenerate("minecraft:monument"));
|
||||
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
control.resolve("minecraft:monument", false).status());
|
||||
assertEquals(1D, control.frequencyMultiplier("minecraft:villages"), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frequencyOverrideUsesExactNormalizedSetKeyAndLastEntry() {
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.05D));
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet(" MINECRAFT:NETHER_COMPLEXES ")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
assertTrue(control.hasFrequencyOverrides());
|
||||
assertEquals(1.1D, control.frequencyMultiplier("minecraft:nether_complexes"), 0D);
|
||||
assertEquals(1D, control.frequencyMultiplier("minecraft:nether_fossils"), 0D);
|
||||
assertEquals(1D, control.frequencyMultiplier("minecraft:nether_complexes_extra"), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,6 +91,30 @@ public class IrisImportedStructureControlTest {
|
||||
assertTrue(control.shouldGenerate("minecraft:stronghold"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactBlacklistDoesNotExpandToStructureFamilies() {
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setDisabledExact(keys(" MINECRAFT:RUINED_PORTAL "));
|
||||
|
||||
assertFalse(control.shouldGenerate("minecraft:ruined_portal"));
|
||||
assertEquals(NativeStructureGenerationStatus.DISABLED_BY_PACK,
|
||||
control.resolve(" MINECRAFT:RUINED_PORTAL ", false).status());
|
||||
assertTrue(control.shouldGenerate("minecraft:ruined_portal_nether"));
|
||||
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
control.resolve("minecraft:ruined_portal_nether", false).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void familyBlacklistRetainsPrefixMatchingBesideExactBlacklist() {
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setDisabled(keys("minecraft:village"))
|
||||
.setDisabledExact(keys("minecraft:ruined_portal"));
|
||||
|
||||
assertFalse(control.shouldGenerate("minecraft:village_plains"));
|
||||
assertFalse(control.shouldGenerate("minecraft:ruined_portal"));
|
||||
assertTrue(control.shouldGenerate("minecraft:ruined_portal_nether"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void datapackOverridesFalseDoesNotDisableModOrDatapackNamespaces() {
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
@@ -305,14 +348,23 @@ public class IrisImportedStructureControlTest {
|
||||
@Test
|
||||
public void malformedNullPolicyListsFailWithTheirExactField() {
|
||||
IrisImportedStructureControl nullDisabled = new IrisImportedStructureControl().setDisabled(null);
|
||||
IrisImportedStructureControl nullDisabledExact = new IrisImportedStructureControl().setDisabledExact(null);
|
||||
IrisImportedStructureControl nullAdjustments = new IrisImportedStructureControl().setAdjustments(null);
|
||||
IrisImportedStructureControl nullFrequencyOverrides = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(null);
|
||||
|
||||
NullPointerException disabled = assertThrows(NullPointerException.class,
|
||||
() -> nullDisabled.shouldGenerate("minecraft:monument"));
|
||||
NullPointerException disabledExact = assertThrows(NullPointerException.class,
|
||||
() -> nullDisabledExact.shouldGenerate("minecraft:monument"));
|
||||
NullPointerException adjustments = assertThrows(NullPointerException.class,
|
||||
() -> nullAdjustments.resolve("minecraft:monument", false));
|
||||
NullPointerException frequencyOverrides = assertThrows(NullPointerException.class,
|
||||
() -> nullFrequencyOverrides.frequencyMultiplier("minecraft:villages"));
|
||||
|
||||
assertTrue(disabled.getMessage().contains("importedStructures.disabled"));
|
||||
assertTrue(disabledExact.getMessage().contains("importedStructures.disabledExact"));
|
||||
assertTrue(adjustments.getMessage().contains("importedStructures.adjustments"));
|
||||
assertTrue(frequencyOverrides.getMessage().contains("importedStructures.frequencyOverrides"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user